104 lines
4.9 KiB
PHP
104 lines
4.9 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// The mesh entry point for the AI subsystem. Another node forwards an action here over SSH; this
|
|
// runs it through the same vv_ai_dispatch() a browser request on this node would have used, and
|
|
// writes the response back as JSON on stdout.
|
|
//
|
|
// CLI only. It is never served over HTTP and takes nothing from the environment but stdin.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// stdin {"action":"ask","params":{…},"is_post":true}
|
|
// stdout {"_vv_rpc":1,"status":200,"body":{…}}
|
|
//
|
|
// The wrapper exists so an HTTP status can travel with the body — a 405 raised in the dispatcher
|
|
// has to arrive at the calling node as a 405, not as a 200 carrying an error string.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// One implementation, two doors.
|
|
// This shares every handler with api/ai.php through include/ai_actions.php. A mesh request
|
|
// and a browser request cannot diverge in behaviour because there is only one behaviour.
|
|
//
|
|
// The caller's node id is a label, not a claim.
|
|
// params._vv_node says which node asked, so findings and incidents are stored against the
|
|
// node they describe. It is not consulted for authorization — possession of the partnership
|
|
// SSH key already settled that, at onboard.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Refuses to run anywhere but the AI owner.
|
|
// A mirror that somehow received a forwarded request must not answer it from its own empty
|
|
// stores. Exits non-zero so the caller reports a transport failure rather than rendering an
|
|
// empty success.
|
|
//
|
|
// Refuses to run over the web.
|
|
// Guarded on PHP_SAPI. Reachable over HTTP this would be an unauthenticated bypass of every
|
|
// gate api/ai.php applies, since it takes its whole request from stdin.
|
|
//
|
|
// The master switch is honoured here too.
|
|
// AI_ENABLED false on the owner means the mesh gets the same refusal a local request gets.
|
|
// The calling node checks its own switch; this one checks the owner's.
|
|
//
|
|
// Input is size-capped before it is decoded.
|
|
// stdin is an untrusted stream from another process. A malformed or endless payload must
|
|
// fail as a bad request, not as an out-of-memory.
|
|
//
|
|
// RUNTIME MODES
|
|
// Not invoked by hand. include/ai_rpc.php opens an SSH session to the owner and runs this
|
|
// file with the request as JSON on stdin; the response is JSON on stdout. There are no flags
|
|
// and no arguments — the action, the profile and the payload all arrive in the request body.
|
|
//
|
|
// DEPENDS ON
|
|
// include/ai_actions.php vv_ai_dispatch() — the shared handlers
|
|
// include/config.php vv_ai_is_owner()
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
if (PHP_SAPI !== 'cli') {
|
|
http_response_code(404);
|
|
exit(1);
|
|
}
|
|
|
|
require_once dirname(__DIR__) . '/include/ai_actions.php';
|
|
|
|
function vv_rpc_out(int $status, array $body): void {
|
|
echo json_encode(['_vv_rpc' => 1, 'status' => $status, 'body' => $body],
|
|
JSON_UNESCAPED_SLASHES), "\n";
|
|
}
|
|
|
|
// Not this node's job. Exit non-zero: the caller must see a transport failure, not an answer
|
|
// assembled from stores that are empty here by design.
|
|
if (!vv_ai_is_owner()) {
|
|
fwrite(STDERR, "ai_rpc: this node is not the AI owner\n");
|
|
exit(2);
|
|
}
|
|
|
|
// 1 MiB. A turn's history is the largest legitimate payload and is capped far below this by the
|
|
// per-message truncation in the dispatcher; anything larger is not a request this serves.
|
|
$raw = stream_get_contents(STDIN, 1024 * 1024);
|
|
$req = json_decode((string)$raw, true);
|
|
if (!is_array($req)) {
|
|
vv_rpc_out(400, ['ok' => false, 'error' => 'ai_rpc: unreadable request']);
|
|
exit(0);
|
|
}
|
|
|
|
$action = trim((string)($req['action'] ?? ''));
|
|
$params = is_array($req['params'] ?? null) ? $req['params'] : [];
|
|
$isPost = (bool)($req['is_post'] ?? false);
|
|
|
|
if ($action === '') {
|
|
vv_rpc_out(400, ['ok' => false, 'error' => 'ai_rpc: no action']);
|
|
exit(0);
|
|
}
|
|
|
|
// The owner's own switch. The calling node already checked its own; this is the other half, and
|
|
// it is what makes turning AI off here take it off the whole mesh.
|
|
if (!vv_ai_enabled()) {
|
|
vv_rpc_out(200, ['ok' => false, 'error' => 'AI_ENABLED is false on the AI owner — AI features are off']);
|
|
exit(0);
|
|
}
|
|
|
|
vv_ai_log(sprintf('rpc action=%s from=%s', $action, (string)($params['_vv_node'] ?? '?')));
|
|
|
|
$httpStatus = 200;
|
|
$body = vv_ai_dispatch($action, $params, $isPost, $httpStatus);
|
|
vv_rpc_out($httpStatus, $body);
|