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