Files

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;
}