Curated state copied to every node is state that can disagree, so the index, the model and the shared memory stay on the owner and each node reaches them over the SSH trust onboarding already builds. Chats stay on the node that had them; memory and bug reports stay the owner's to write.
189 lines
9.8 KiB
PHP
189 lines
9.8 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
|
|
//
|
|
// 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;
|
|
}
|