_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. // 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; }