198 lines
10 KiB
PHP
198 lines
10 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Where an AI action runs, and how it gets there. The mesh shares one AI: the owner node holds
|
|
// the model, the index and the shared memory, and every other node reaches them over SSH rather
|
|
// than keeping a second copy of any of it.
|
|
//
|
|
// Two exports. vv_ai_route() answers "local, remote, or refused" for one action on this node;
|
|
// vv_ai_rpc() carries a remote one to the owner and brings back its answer verbatim.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Three routes, decided per action rather than per host:
|
|
//
|
|
// LOCAL this node answers. Everything on the owner. On a mirror, the chat store only —
|
|
// conversations are per-node by design, so they never leave the box they were had on.
|
|
// REMOTE forwarded to the owner: generation, retrieval, stats, the shared memory, findings.
|
|
// DENY refused with a 404. The curated writes — memory and bug reports — are the owner's.
|
|
//
|
|
// Transport is SSH over the trust partnership_onboard.sh already establishes, the same as
|
|
// node_chat and conf_sync: no listener, no new port, Tailscale-only for free. The request is
|
|
// JSON on stdin, the response is JSON on stdout, and Tools/ai_rpc.php on the far side hands both
|
|
// to the same vv_ai_dispatch() this node would have called locally.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Share, not copy.
|
|
// A mirror does not hold the index, the memory or the model and does not sync them. There
|
|
// is one of each, on the owner, and the mesh asks it. Copies of curated, hand-edited state
|
|
// are copies that can disagree, and reconciling them needs tombstones and an offline story
|
|
// — the same complexity node_chat's local-only delete deliberately refused.
|
|
//
|
|
// The job lives where the model lives.
|
|
// ask returns the owner's token and poll asks the owner about it, so the token-and-poll
|
|
// contract is unchanged; it simply resolves on another box. Nothing about the page changes.
|
|
//
|
|
// Chats stay home, memory is shared.
|
|
// A conversation is this operator's, on this node. What the assistant *knows* — the memory
|
|
// file, the learned notes, the phrasebook — is the owner's and is shared by everyone. The
|
|
// history for a turn travels in the request, so where chats are stored is independent of
|
|
// where generation happens.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// The remote path is the WebGUI symlink, not a discovered one.
|
|
// /usr/local/emhttp/plugins/varaverk is what Unraid serves the plugin from on every node,
|
|
// whatever storage mode it uses. node_chat reads the partner's varaverk.cfg first because a
|
|
// delivery is occasional; poll runs once a second per open tab and cannot afford a second
|
|
// SSH round trip to find a path. If this symlink is wrong the whole plugin is already
|
|
// broken on that node, so it is not a weaker assumption than the one it replaces.
|
|
//
|
|
// One multiplexed connection, not one per call.
|
|
// ControlMaster with ControlPersist, socket in tmpfs. A fresh SSH handshake is 100-300ms;
|
|
// paying it per poll, per open tab, would make the assistant feel broken on a mirror.
|
|
//
|
|
// A transport failure is named, never rendered as an empty success.
|
|
// Unreachable owner, missing shim and unparseable output are three different errors and
|
|
// each says so. An empty banner that looks like "nothing to report" is the failure mode
|
|
// worth spending three messages on.
|
|
//
|
|
// Nothing here decides trust.
|
|
// Possession of the partnership SSH key is the authorization, established at onboard. This
|
|
// file routes; it does not authenticate.
|
|
//
|
|
// EXPORTS
|
|
// VV_AI_ROUTE_LOCAL / _REMOTE / _DENY
|
|
// vv_ai_route() action → route for this node
|
|
// vv_ai_rpc() forward one action to the owner, return its response body
|
|
//
|
|
// CONFIGURATION
|
|
// host*.conf
|
|
// <HOST>_SSH_KEY private key this node authenticates to the owner with. Absent or not a
|
|
// file means no remote route exists, and vv_ai_route() says so rather than
|
|
// attempting a hop that cannot succeed.
|
|
// <OWNER> the owner's hostname, looked up by the id vv_ai_owner_host() returns —
|
|
// resolved to an address through vv_resolve_tailscale_ip(), never used as
|
|
// a hostname directly, because MagicDNS does not resolve across the tailnets.
|
|
//
|
|
// DEPENDS ON
|
|
// include/config.php vv_ai_owner_host(), vv_ai_is_owner(), vv_resolve_tailscale_ip()
|
|
// Tools/ai_rpc.php the far side — reached at the WebGUI symlink path
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
const VV_AI_ROUTE_LOCAL = 0;
|
|
const VV_AI_ROUTE_REMOTE = 1;
|
|
const VV_AI_ROUTE_DENY = 2;
|
|
|
|
// Curated on one node on purpose. Memory is the text that rides in every prompt; a bug report is
|
|
// what leaves this mesh for a tracker. Both are the owner's to write, and a second node keeping a
|
|
// divergent copy of either is the failure this refuses.
|
|
const VV_AI_OWNER_ONLY = ['memory_set', 'mem_proposal_action',
|
|
'bugs', 'bug_close', 'bug_report', 'bug_send_local'];
|
|
|
|
// Answered on the node that asked, even on a mirror. A conversation belongs to the operator in
|
|
// front of it, and the transcript for a turn travels in the request anyway.
|
|
const VV_AI_NODE_LOCAL = ['chats', 'chat_get', 'chat_save', 'chat_delete'];
|
|
|
|
// Reached over the mesh by a sweep on another node, never by a browser. Denied from the web on
|
|
// every node — on a mirror because it is not a page action, and on the owner because the only
|
|
// legitimate caller is the RPC shim, which does not come through here.
|
|
const VV_AI_RPC_ONLY = ['finding_write'];
|
|
|
|
function vv_ai_route(string $action): int {
|
|
if (in_array($action, VV_AI_RPC_ONLY, true)) return VV_AI_ROUTE_DENY;
|
|
if (vv_ai_is_owner()) return VV_AI_ROUTE_LOCAL;
|
|
if (in_array($action, VV_AI_NODE_LOCAL, true)) return VV_AI_ROUTE_LOCAL;
|
|
if (in_array($action, VV_AI_OWNER_ONLY, true)) return VV_AI_ROUTE_DENY;
|
|
return VV_AI_ROUTE_REMOTE;
|
|
}
|
|
|
|
// Where the multiplexed control socket lives. tmpfs is the right lifetime — a reboot should not
|
|
// inherit a stale socket — and the path is kept short because a unix socket path is capped near
|
|
// 108 characters and ssh composes this one with the user and host appended.
|
|
function vv_ai_rpc_socket_dir(): string {
|
|
$dir = rtrim(VV_CACHE_ROOT, '/') . '/ssh';
|
|
if (!is_dir($dir)) @mkdir($dir, 0700, true);
|
|
return $dir;
|
|
}
|
|
|
|
// Forward one action to the AI owner and return its response body.
|
|
//
|
|
// $httpStatus is set from the owner's own status when it reports one, so a 405 raised over there
|
|
// arrives here as a 405 rather than as a 200 carrying an error string.
|
|
function vv_ai_rpc(string $action, array $params, bool $isPost, int &$httpStatus = 200): array {
|
|
$vars = vv_conf_vars();
|
|
$me = strtoupper(vv_detect_host());
|
|
$owner = vv_ai_owner_host();
|
|
|
|
$sshKey = $vars[$me . '_SSH_KEY'] ?? '';
|
|
if (!$sshKey || !is_file($sshKey)) {
|
|
return ['ok' => false, 'error' => "No SSH key for this node ({$me}_SSH_KEY) — cannot reach the AI owner"];
|
|
}
|
|
|
|
$hostname = trim((string)($vars[strtoupper($owner)] ?? ''));
|
|
if ($hostname === '') {
|
|
return ['ok' => false, 'error' => "No hostname recorded for the AI owner ($owner)"];
|
|
}
|
|
|
|
$ip = vv_resolve_tailscale_ip($hostname);
|
|
if (!$ip) {
|
|
return ['ok' => false, 'error' => "Cannot resolve $hostname on the tailnet — the AI owner is unreachable"];
|
|
}
|
|
|
|
// Which node is asking. Not an authorization claim — the SSH key already settled that — but a
|
|
// label, so findings and incidents filed from here are stored against the node they describe.
|
|
$params['_vv_node'] = strtolower(vv_detect_host());
|
|
|
|
$remote = '/usr/local/emhttp/plugins/varaverk/Tools/ai_rpc.php';
|
|
$sock = vv_ai_rpc_socket_dir() . '/ai-%h';
|
|
|
|
$cmd = 'ssh -i ' . escapeshellarg($sshKey)
|
|
. ' -o BatchMode=yes -o StrictHostKeyChecking=no'
|
|
. ' -o ConnectTimeout=8'
|
|
. ' -o ControlMaster=auto -o ControlPersist=60s'
|
|
. ' -o ControlPath=' . escapeshellarg($sock)
|
|
. ' root@' . escapeshellarg($ip)
|
|
. ' ' . escapeshellarg('[ -f ' . $remote . ' ] || exit 127; php ' . $remote);
|
|
|
|
$desc = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
|
$pr = @proc_open($cmd, $desc, $pipes);
|
|
if (!is_resource($pr)) {
|
|
return ['ok' => false, 'error' => 'Cannot start ssh to the AI owner'];
|
|
}
|
|
|
|
fwrite($pipes[0], json_encode([
|
|
'action' => $action,
|
|
'params' => $params,
|
|
'is_post' => $isPost,
|
|
], JSON_UNESCAPED_SLASHES));
|
|
fclose($pipes[0]);
|
|
|
|
$out = stream_get_contents($pipes[1]); fclose($pipes[1]);
|
|
$err = stream_get_contents($pipes[2]); fclose($pipes[2]);
|
|
$rc = proc_close($pr);
|
|
|
|
// 127 is the guard above finding no shim — the owner is reachable but has not pulled a build
|
|
// that has one. Distinguished from a transport failure because the fix is entirely different.
|
|
if ($rc === 127) {
|
|
return ['ok' => false, 'error' => 'The AI owner has no Tools/ai_rpc.php — it needs a git pull'];
|
|
}
|
|
if ($rc !== 0) {
|
|
$detail = trim($err) !== '' ? ': ' . mb_substr(trim($err), 0, 200) : '';
|
|
return ['ok' => false, 'error' => "Cannot reach the AI owner ($hostname)$detail"];
|
|
}
|
|
|
|
$decoded = json_decode(trim($out), true);
|
|
if (!is_array($decoded)) {
|
|
return ['ok' => false, 'error' => 'The AI owner returned an unreadable response'];
|
|
}
|
|
|
|
// The shim wraps the body so a status can travel with it. An older owner that answers with a
|
|
// bare body still works — it simply carries no status, which is the 200 default.
|
|
if (isset($decoded['_vv_rpc'])) {
|
|
$httpStatus = (int)($decoded['status'] ?? 200);
|
|
return is_array($decoded['body'] ?? null) ? $decoded['body'] : ['ok' => false, 'error' => 'Malformed response from the AI owner'];
|
|
}
|
|
return $decoded;
|
|
}
|