job state // GET ?action=memory_get the operator memory file and its budget // GET ?action=chats stored conversations, newest first, metadata only // GET ?action=chat_get&id= one stored conversation with its transcript // GET ?action=findings [all=1] repair findings, open only unless all=1 // POST action=finding_action id= act=fix|ack|dismiss|reopen|cancel [note=…] // POST action=ask question=… [history=] [kind=…] [think=0|1] // POST action=memory_set memory=… replace the memory file // POST action=clear token= discard a finished job // POST action=chat_save [id=] profile=… messages= // POST action=chat_delete id= // // RESPONSE // stats {"ok":true,"stats":{…}} // ask {"ok":true,"token":""} // poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}} // clear {"ok":true} // chats {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N} // findings {"ok":true,"repair":{enabled,autofix,last},"findings":[…],"counts":{…}} // finding_action {"ok":true,"action":"fix"} // chat_save {"ok":true,"id":"","title":…} // {"ok":false,"error":…} // // DEPENDS ON // include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*() // include/ai_repair.php the findings store — loaded only by the two actions that read it // Tools/ai_chat_worker.php the detached worker // ═══════════════════════════════════════════════════════════════════════════════════════════════ // First executable statement, deliberately dependency-free. A request that is rejected by the // CSRF prepend never reaches here and a request that dies inside the include never reaches the // action log below, and those two look identical from outside — which is what made a POST that // the browser demonstrably sent leave no trace anywhere on the server. // Polls are excluded here for the same reason they are excluded from the action log below: one // line per second per open tab buries every line worth reading. if (strpos($_SERVER['REQUEST_URI'] ?? '', 'action=poll') === false) { @file_put_contents('/var/log/varaverk/ai.log', date('Y-m-d H:i:s') . ' ENTER ' . ($_SERVER['REQUEST_METHOD'] ?? '?') . ' ' . ($_SERVER['REQUEST_URI'] ?? '?') . ' ct=' . substr($_SERVER['CONTENT_TYPE'] ?? '-', 0, 40) . ' len=' . ($_SERVER['CONTENT_LENGTH'] ?? '-') . "\n", FILE_APPEND | LOCK_EX); } header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache'); require_once dirname(__DIR__) . '/include/ai.php'; // History depth is per profile and still decided server-side rather than by the page — the page // simply no longer carries a second copy of the numbers. include/ai_profiles.php holds them. // Reasoning is not stored in history, so it does not compound. const VV_AI_MAX_QUESTION = 4000; // characters const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped $isPost = $_SERVER['REQUEST_METHOD'] === 'POST'; $action = trim($isPost ? ($_POST['action'] ?? '') : ($_GET['action'] ?? 'stats')); // Request trace. There is no nginx access log on this host and the CSRF prepend exits with an // empty body, so without this there is no way to tell "the request never arrived" from "the // request arrived and failed" — which is exactly the ambiguity that made the first hang // undiagnosable. Excludes poll, which would otherwise write a line per second per open tab. function vv_ai_log(string $msg): void { if (!is_dir('/var/log/varaverk')) return; @file_put_contents('/var/log/varaverk/ai.log', date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX); } if ($action !== 'poll') { vv_ai_log(sprintf('%s action=%s from=%s', $_SERVER['REQUEST_METHOD'] ?? '?', $action ?: '(none)', $_SERVER['REMOTE_ADDR'] ?? '?')); } // Host gate, ahead of the dispatch rather than inside each action. Varaverk.page omits the tab // on any host but HOST1, but a hidden link is not access control and this endpoint is reachable // directly. Every action is refused rather than just the expensive ones — there is no such thing // as a read this host is entitled to, since the index and the model are not here. if (!vv_is_ai_host()) { http_response_code(404); echo json_encode(['ok' => false, 'error' => 'AI is not available on this host']); exit; } // Master switch, on the same footing as the host gate rather than only in front of ask. With // AI_ENABLED false the tab is not in the tab list and the scheduler dock is not rendered, so // nothing in the UI can legitimately reach any action here — including the cheap reads, which // would otherwise still answer with index and token figures for a subsystem the operator has // turned off. Not a 404: the switch is a setting, and the message names the setting. if (!vv_ai_enabled()) { echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']); exit; } // ── stats ───────────────────────────────────────────────────────────────────── // Served from the shared 'ai' cache that Tools/api_cache_writer.sh refreshes every minute, on // the same terms as the monitor and arrs payloads. This action is polled every 30 seconds by // every open tab and used to pay a full collection each time — around a second, most of it spent // waiting on Ollama and nvidia-smi — for numbers that only change when the writer next runs. // // ?live=1 bypasses it, for the case where something was just changed and the point is to see the // result. A missing cache always falls back to collecting, so the cache can never be the reason // the banner fails to render. if ($action === 'stats') { echo json_encode(['ok' => true, 'stats' => vv_ai_stats_cached(isset($_GET['live']))]); exit; } // ── tokens ──────────────────────────────────────────────────────────────────── // Separate from stats rather than folded into it. stats is polled every 30 seconds by every // open tab; this reads a file that grows without bound between prunes. The totals only move // when a turn completes, and the page knows exactly when that happened, so it asks then. if ($action === 'tokens') { echo json_encode(['ok' => true, 'tokens' => vv_ai_token_stats()]); exit; } // ── poll ────────────────────────────────────────────────────────────────────── if ($action === 'poll') { $token = trim($_GET['token'] ?? ''); if (vv_ai_job_path($token) === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; } $job = vv_ai_job_read($token); if ($job === null) { // The worker writes its first state after this request may already have arrived. echo json_encode(['ok' => true, 'job' => ['status' => 'pending']]); exit; } echo json_encode(['ok' => true, 'job' => $job]); exit; } // ── memory ──────────────────────────────────────────────────────────────────── if ($action === 'memory_get') { $m = vv_ai_memory_read(); echo json_encode(['ok' => true, 'memory' => $m['text'], 'chars' => $m['chars'], 'max' => vv_ai_memory_max(), 'exists' => $m['exists'], 'path' => vv_ai_memory_path()]); exit; } if ($action === 'memory_set') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $r = vv_ai_memory_write((string)($_POST['memory'] ?? '')); vv_ai_log('memory_set ' . ($r['ok'] ? 'ok chars=' . $r['chars'] : 'FAILED: ' . $r['error'])); echo json_encode($r + ['max' => vv_ai_memory_max()]); exit; } // ── learned-memory proposals ────────────────────────────────────────────────── // The store the assistant files candidates into. Accepting is the only path by which model-written // text reaches a prompt, and it is a POST so the CSRF prepend covers it. if ($action === 'mem_proposals') { require_once __DIR__ . '/../include/ai_memory_learn.php'; $m = vv_ai_memory_read('learned'); echo json_encode([ 'ok' => true, 'enabled' => vv_ai_mem_learn_enabled(), 'auto' => vv_ai_mem_learn_auto(), // The list states the gate as well as the rows: an empty list means "nothing proposed" // when learning is on and "nothing is looking" when it is off, and those are different. 'open' => vv_ai_mem_list('open'), 'recent' => array_slice(vv_ai_mem_list(), 0, 25), 'learned' => ['chars' => $m['chars'], 'max' => vv_ai_memory_learned_max()], ]); exit; } if ($action === 'mem_proposal_action') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } require_once __DIR__ . '/../include/ai_memory_learn.php'; $id = trim($_POST['id'] ?? ''); $act = trim($_POST['act'] ?? ''); $r = vv_ai_mem_action($id, $act); vv_ai_log(sprintf('mem_proposal id=%s act=%s %s', $id, $act, $r['ok'] ? 'ok' : ('FAILED: ' . ($r['error'] ?? '?')))); echo json_encode($r); exit; } // ── stop ────────────────────────────────────────────────────────────────────── // Cancels a generation in flight. Only ever signals ONE pid, verified to be the worker for this // exact job — never a process group. Signalling a group is what took the WebGUI down on // 2026-08-07, and no group kill is needed here: the worker is a single php process whose only // child-like thing is an HTTP connection to Ollama, which dies with it. // // Whatever was already generated is kept. A turn stopped at 80% is usually stopped because the // operator has seen enough, not because they want it discarded. if ($action === 'stop') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $token = trim($_POST['token'] ?? ''); if (vv_ai_job_path($token) === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; } $job = vv_ai_job_read($token); if ($job === null) { echo json_encode(['ok' => false, 'error' => 'No such job']); exit; } $status = (string)($job['status'] ?? ''); if ($status === 'done' || $status === 'error' || $status === 'stopped') { echo json_encode(['ok' => true, 'already' => true, 'status' => $status]); exit; } $pid = (int)($job['pid'] ?? 0); // Below 2 is init or nonsense. A pid we cannot verify is a pid we do not signal. $killed = false; if ($pid >= 2) { // Pid reuse is the reason for this: the recorded worker may have exited seconds ago and // the number been handed to something else entirely. The cmdline must name both this // worker and this job's own file before anything is signalled. $cmdline = @file_get_contents("/proc/$pid/cmdline"); $cmdline = $cmdline === false ? '' : str_replace("\0", ' ', $cmdline); if (strpos($cmdline, 'ai_chat_worker.php') !== false && strpos($cmdline, $token) !== false) { $killed = @posix_kill($pid, SIGTERM); // No escalation ladder. The worker holds no lock and writes the job file atomically, // so there is no cleanup that a delay would protect — and a SIGKILL race could land // between the temp write and the rename. } } // The job file is rewritten either way. If the pid could not be verified the worker is // already gone, and the page still needs a terminal state instead of polling to its ceiling. $job['status'] = 'stopped'; $job['stopped'] = true; $job['answer'] = trim((string)($job['partial'] ?? $job['answer'] ?? '')); unset($job['partial']); @file_put_contents(vv_ai_job_path($token), json_encode($job)); vv_ai_log(sprintf('stop token=%s pid=%d signalled=%s kept=%d chars', substr($token, 0, 12), $pid, $killed ? 'yes' : 'no', strlen($job['answer']))); echo json_encode(['ok' => true, 'signalled' => $killed, 'kept' => strlen($job['answer'])]); exit; } // ── clear ───────────────────────────────────────────────────────────────────── if ($action === 'clear') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $p = vv_ai_job_path(trim($_POST['token'] ?? '')); if ($p === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; } if (file_exists($p)) @unlink($p); echo json_encode(['ok' => true]); exit; } // ── chats ───────────────────────────────────────────────────────────────────── // Stored conversations. Listing and reading are GET because they change nothing; saving and // deleting are POST, so they ride Unraid's CSRF prepend like every other mutation here. // // Messages are validated per message on the way in, exactly as ask validates history and for // the same reason: a stored chat is replayed into a later prompt when the operator reopens it, // so a crafted role in the store would be an injection that survives a reload. if ($action === 'chats') { echo json_encode(['ok' => true, 'chats' => vv_ai_chats_list(), 'max' => vv_ai_chats_max()]); exit; } if ($action === 'chat_get') { $chat = vv_ai_chat_read(trim($_GET['id'] ?? '')); if ($chat === null) { echo json_encode(['ok' => false, 'error' => 'No such chat']); exit; } echo json_encode(['ok' => true, 'chat' => $chat]); exit; } if ($action === 'chat_save') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $profile = trim($_POST['profile'] ?? 'chat'); if (!vv_ai_profile_ok($profile)) { echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit; } $clean = []; $msgs = json_decode($_POST['messages'] ?? '[]', true); if (is_array($msgs)) { foreach ($msgs as $m) { $role = $m['role'] ?? ''; $text = trim((string)($m['content'] ?? '')); if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue; $clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)]; } } // Capped at the deepest profile's window rather than that of the profile in hand. A chat // saved under one profile can be reopened under another, and the reopened turn is trimmed // again on the way back out by ask — so storing a little more than any single profile will // send costs nothing and keeps the transcript readable. $cap = vv_ai_profiles_max_turns() * 2; if (count($clean) > $cap) $clean = array_slice($clean, -$cap); // Whitelisted exactly as ask's is, and for the same reason: a scope is only ever a name from // a page's own view state, it is stored and later replayed into a prompt, and anything // richer than a file name is an instruction-injection surface for no benefit. $scope = trim($_POST['scope'] ?? ''); if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = ''; $r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean, $scope); vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12) : 'FAILED: ' . $r['error'])); echo json_encode($r); exit; } if ($action === 'chat_delete') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $ok = vv_ai_chat_delete(trim($_POST['id'] ?? '')); echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'No such chat']); exit; } // ── bugs / bug_close ────────────────────────────────────────────────────────── // Reports the troubleshooter filed. Listing is a GET because it changes nothing; dismissing is // a POST, like every other mutation in this plugin. if ($action === 'bugs') { echo json_encode(['ok' => true, 'bugs' => vv_ai_bugs_list(($_GET['all'] ?? '') !== '1')]); exit; } if ($action === 'bug_close') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $ok = vv_ai_bug_set_open(trim($_POST['id'] ?? ''), ($_POST['open'] ?? '0') === '1'); echo json_encode(['ok' => $ok]); exit; } // The report, rendered server-side. Read-only by design: what the operator reviews is byte for // byte what gets sent, so approving one text and transmitting another is not possible. It is also // the only renderer — the page used to build its own markdown, which is two formats to keep in // step and one of them always losing. if ($action === 'bug_report') { $id = trim($_GET['id'] ?? ''); $bug = null; foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; } if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; } $t = vv_ai_bug_targets(); $title = '[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''); echo json_encode([ 'ok' => true, 'title' => $title, 'markdown' => vv_ai_bug_report($bug), 'targets' => $t, // Built here because the repo name lives here. Length is the caller's problem to notice: // GitHub truncates a very long query rather than refusing it, which would silently send a // half report — so the page checks and falls back to the copy box. 'github' => 'https://github.com/' . $t['github_repo'] . '/issues/new?title=' . rawurlencode($title) . '&body=' . rawurlencode(vv_ai_bug_report($bug)), ]); exit; } // Sends to the operator's own Gitea, and only there. Never falls back to GitHub on failure: the // two destinations are different people, and a silent substitution is how a report meant for a // private backlog ends up public. if ($action === 'bug_send_local') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $id = trim($_POST['id'] ?? ''); $bug = null; foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; } if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; } // Re-rendered from the store rather than taken from the request. The browser showed this text // read-only; accepting a body from the page would make that guarantee decorative. $r = vv_ai_bug_send_local('[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''), vv_ai_bug_report($bug)); vv_ai_log(sprintf('bug_send_local id=%s %s', $id, $r['ok'] ? 'ok ' . ($r['url'] ?? '') : 'failed: ' . ($r['error'] ?? '?'))); echo json_encode($r); exit; } // ── findings / finding_action ───────────────────────────────────────────────── // What the repair sweep found, and the operator's answer to it. include/ai_repair.php is pulled // in here rather than at the top of the file: it is the largest include in the plugin and poll // runs once a second per open tab, so it is loaded by the two actions that need it and by nothing // else. // // Neither action is gated on AI_REPAIR_ENABLED. Findings filed while it was on do not stop being // true when it goes off, and answering them — including saying "this was never a problem" — is // exactly what an operator turning the feature off is likely to want to do first. The gate states // are reported instead, so the card can say what is running rather than the endpoint pretending // the store is empty. if ($action === 'findings' || $action === 'finding_action') { require_once dirname(__DIR__) . '/include/ai_repair.php'; if ($action === 'findings') { // Closed findings are the history — what was dismissed, what a fix actually fixed — and // they are asked for explicitly rather than shipped with every poll of the open list. $rows = []; $open = 0; $needs = 0; foreach (vv_ai_findings_list(($_GET['all'] ?? '') === '1' ? [] : ['open', 'needs_operator']) as $f) { $state = (string)($f['state'] ?? 'open'); if ($state === 'open') $open++; elseif ($state === 'needs_operator') $needs++; // The three things the page must not decide for itself: which actions this row // offers, and what its state and kind mean in words. $f['actions'] = vv_ai_finding_actions($f); $f['state_label'] = VV_AI_FINDING_STATES[$state] ?? ''; $f['kind_label'] = VV_AI_FINDING_KINDS[(string)($f['kind'] ?? '')] ?? ''; $rows[] = $f; } echo json_encode(['ok' => true, 'repair' => ['enabled' => vv_ai_repair_enabled(), 'autofix' => vv_ai_repair_autofix_enabled(), 'last' => vv_ai_sweep_last()], 'findings' => $rows, 'counts' => ['open' => $open, 'needs_operator' => $needs, 'shown' => count($rows)]]); exit; } // POST, because fix writes conf through the guarded path and every other answer writes state. // Which actions are legal for a given row is vv_ai_finding_apply_action()'s call, not this // endpoint's — a tab left open overnight is holding buttons the store has moved past. if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $fid = trim($_POST['id'] ?? ''); $act = trim($_POST['act'] ?? ''); $r = vv_ai_finding_apply_action($fid, $act, trim($_POST['note'] ?? '')); vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act, $r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?'))); echo json_encode($r); exit; } // ── incident_add ────────────────────────────────────────────────────────────── // Appends one operator-written "this was the fix" note against a scope. POST only, and the // scope is whitelisted the same way ask's is — it is written to a file that later rides in a // prompt, so it gets the same treatment as anything else that reaches the model. if ($action === 'incident_add') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } echo json_encode(vv_ai_incident_add( trim($_POST['scope'] ?? ''), trim($_POST['symptom'] ?? ''), trim($_POST['fix'] ?? ''))); exit; } // ── ask ─────────────────────────────────────────────────────────────────────── if ($action === 'ask') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $cfg = vv_ai_config(); if ($cfg['model'] === '') { echo json_encode(['ok' => false, 'error' => 'No generation model configured']); exit; } $question = trim($_POST['question'] ?? ''); if ($question === '') { echo json_encode(['ok' => false, 'error' => 'question is required']); exit; } if (mb_strlen($question) > VV_AI_MAX_QUESTION) { echo json_encode(['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters']); exit; } $profile = trim($_POST['profile'] ?? 'varaverk'); if (!vv_ai_profile_ok($profile)) { echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit; } $maxTurns = vv_ai_profile_turns($profile); // Where the caller is standing — "master.conf", "daily_sync_maintenance.sh", a log name. // The scheduler page sends it so a question can say "this setting" and mean something; the // AI tab sends nothing and the worker simply omits the location line. // // Whitelisted hard, not escaped and hoped for. It reaches the model as text, so anything // richer than a file name is an instruction-injection surface for no benefit — a scope is // only ever a name from this page's own view state. $scope = trim($_POST['scope'] ?? ''); if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = ''; // The retrieval filter only means anything to the profile that retrieves. $kind = vv_ai_profile_can($profile, 'kind_filter') ? trim($_POST['kind'] ?? '') : ''; if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) { echo json_encode(['ok' => false, 'error' => 'Unknown kind: ' . $kind]); exit; } // Validate per message rather than trusting the blob: a crafted history could otherwise // inject a system role, or push the context past the offload ceiling. $clean = []; $hist = json_decode($_POST['history'] ?? '[]', true); if (is_array($hist)) { foreach ($hist as $m) { $role = $m['role'] ?? ''; $text = trim((string)($m['content'] ?? '')); if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue; $clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)]; } } if (count($clean) > $maxTurns * 2) { $clean = array_slice($clean, -($maxTurns * 2)); } $dir = vv_ai_job_dir(); foreach (glob($dir . '/*.json') ?: [] as $old) { if (time() - (int)@filemtime($old) > VV_AI_JOB_TTL) @unlink($old); } $token = bin2hex(random_bytes(16)); $jobFile = vv_ai_job_path($token); $worker = dirname(__DIR__) . '/Tools/ai_chat_worker.php'; if (!file_exists($worker)) { echo json_encode(['ok' => false, 'error' => 'ai_chat_worker.php not found']); exit; } // Not suppressed: if the job file cannot be written the worker has nowhere to report and // the page polls a token that will never resolve — which looks exactly like a hang. if (file_put_contents($jobFile, json_encode(['status' => 'pending'])) === false) { vv_ai_log('ask FAILED — cannot write ' . $jobFile); echo json_encode(['ok' => false, 'error' => 'Cannot write job file to ' . VV_AI_JOB_DIR]); exit; } // setsid, not just nohup. nohup detaches from the terminal but leaves the child in the // caller's process group — php-fpm's. That is the arrangement that took the WebGUI down on // 2026-08-07 when a Stop signalled a group it did not own. Stop below signals one verified // pid and never a group, so this is belt and braces, but it also means a php-fpm restart no // longer takes a running generation with it. $cmd = 'setsid nohup php ' . escapeshellarg($worker) . ' ' . escapeshellarg($jobFile) . ' ' . escapeshellarg($question) . ' ' . escapeshellarg(json_encode($clean)) . ' ' . escapeshellarg($kind) . ' ' . escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' ' . escapeshellarg($profile) . ' ' . escapeshellarg($scope) . ' ' // Asked for per turn. Only meaningful on a profile holding web_search — the worker // checks that, so a crafted web=1 against any other profile changes nothing. . escapeshellarg(($_POST['web'] ?? '') === '1' ? '1' : '0') . ' >/dev/null 2>&1 true, 'token' => $token]); exit; } echo json_encode(['ok' => false, 'error' => 'Unknown action']);