From 1d69b240e2305b3b76e1eae5e024e41a39d570c1 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 9 Sep 2026 01:30:29 +0000 Subject: [PATCH] Split UI updates 4/6: admission --- src/features/agents/functions.ts | 8 +- src/features/chat/functions.ts | 11 +- src/features/coding/functions.ts | 1 - src/server.ts | 39 ++++-- src/server/auth/readiness.server.ts | 47 ++++++++ src/server/codex/login.server.ts | 3 + src/server/coding/worker.server.ts | 1 + src/server/computer/session.server.ts | 4 + src/server/computer/socket.server.ts | 8 ++ src/server/runs/store.server.ts | 2 +- src/server/runs/worker.server.ts | 16 ++- src/server/update-gate.server.ts | 145 ++++++++++++++++++++++ src/server/worker-plugin.ts | 8 ++ tests/coding-worker.test.ts | 83 +++++++++++++ tests/runs.test.ts | 111 +++++++++++++++++ tests/steering.test.ts | 17 +++ tests/update-auth-readiness.test.ts | 52 ++++++++ tests/update-gate.test.ts | 166 ++++++++++++++++++++++++++ 18 files changed, 702 insertions(+), 20 deletions(-) create mode 100644 src/server/auth/readiness.server.ts create mode 100644 src/server/update-gate.server.ts create mode 100644 tests/update-auth-readiness.test.ts create mode 100644 tests/update-gate.test.ts diff --git a/src/features/agents/functions.ts b/src/features/agents/functions.ts index ff2daa6..0214729 100644 --- a/src/features/agents/functions.ts +++ b/src/features/agents/functions.ts @@ -36,9 +36,11 @@ const result = ( ), ); -export const getAgents = createServerFn({ method: "GET" }) - .middleware([available]) - .handler(() => result(listAgents())); +// Read-only navigation remains available during update drain. Full startup gates +// still reject ordinary HTTP before any loader runs. +export const getAgents = createServerFn({ method: "GET" }).handler(() => + result(listAgents()), +); export const getConnection = createServerFn({ method: "GET" }) .middleware([available]) diff --git a/src/features/chat/functions.ts b/src/features/chat/functions.ts index 2f6a5c4..e889a22 100644 --- a/src/features/chat/functions.ts +++ b/src/features/chat/functions.ts @@ -3,9 +3,11 @@ import { setResponseHeader } from "@tanstack/react-start/server"; import { Effect, Schema } from "effect"; import { withAgentStore } from "../../server/agents/store.server"; import { available } from "../../server/available"; +import { isMaintenance } from "../../server/maintenance.server"; import { readConversationSnapshot } from "../../server/runs/conversation-snapshot.server"; import { cancelRun, enqueueChat } from "../../server/runs/store.server"; import { ensureTimeline, startWorker } from "../../server/runs/worker.server"; +import { appGate } from "../../updater/gate"; import { SendMessage } from "./schema"; const result = (effect: Effect.Effect) => @@ -33,7 +35,6 @@ export type InitialConversation = Awaited< >; export const getConversation = createServerFn({ method: "GET" }) - .middleware([available]) .validator( Schema.decodeUnknownSync( Schema.Struct({ @@ -43,7 +44,12 @@ export const getConversation = createServerFn({ method: "GET" }) }), ), ) - .handler(({ data }) => { + .handler(async ({ data }) => { + if ( + appGate().mode === "drain" || + (await Effect.runPromise(withAgentStore(isMaintenance))) + ) + return result(readConversationSnapshot(data.agentId, data)); startWorker(); return result( @@ -79,7 +85,6 @@ export const stopMessage = createServerFn({ method: "POST" }) .handler(({ data }) => result(cancelRun(data.agentId, data.id))); export const getActivityOutput = createServerFn({ method: "GET" }) - .middleware([available]) .validator( Schema.decodeUnknownSync( Schema.Struct({ agentId: Schema.UUID, id: Schema.String }), diff --git a/src/features/coding/functions.ts b/src/features/coding/functions.ts index 079fd37..d75d697 100644 --- a/src/features/coding/functions.ts +++ b/src/features/coding/functions.ts @@ -64,7 +64,6 @@ export const removeExecutionProfile = createServerFn({ method: "POST" }) ); export const getCodingJobs = createServerFn({ method: "GET" }) - .middleware([available]) .validator(Schema.decodeUnknownSync(AgentInput)) .handler(({ data }) => result(listCodingJobs(data.agentId))); diff --git a/src/server.ts b/src/server.ts index 652f711..774e70a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,22 +8,39 @@ import { authPage } from "./server/auth/page.server"; import { nativeAuthEnabled } from "./server/auth/store.server"; import { compressHtml } from "./server/html-compression.server"; import { followStartupRedirect } from "./server/startup-response.server"; +import { + trackUpdateRequest, + updateGateRequest, +} from "./server/update-gate.server"; const handler = createStartHandler(defaultStreamHandler); export default createServerEntry({ async fetch(request, options) { - const auth = await authGate(request, authPage); - if (auth) return auth; - const response = await followStartupRedirect( - request, - await handler(request, options), - (nextRequest) => handler(nextRequest, options), + const gate = await updateGateRequest(request, () => + // A fixed, capability-authorized read-only shell probe. Settings' external + // connection loaders remain blocked by maintenance; no client JS executes. + handler( + new Request(new URL("/settings?group=updates", request.url), { + headers: request.headers, + }), + options, + ), ); - if (nativeAuthEnabled()) { - response.headers.set("Cache-Control", "private, no-store"); - response.headers.set("Referrer-Policy", "no-referrer"); - } - return compressHtml(request, response); + if (gate) return gate; + return trackUpdateRequest(async () => { + const auth = await authGate(request, authPage); + if (auth) return auth; + const response = await followStartupRedirect( + request, + await handler(request, options), + (nextRequest) => handler(nextRequest, options), + ); + if (nativeAuthEnabled()) { + response.headers.set("Cache-Control", "private, no-store"); + response.headers.set("Referrer-Policy", "no-referrer"); + } + return compressHtml(request, response); + }); }, }); diff --git a/src/server/auth/readiness.server.ts b/src/server/auth/readiness.server.ts new file mode 100644 index 0000000..bf48edb --- /dev/null +++ b/src/server/auth/readiness.server.ts @@ -0,0 +1,47 @@ +import type { DatabaseSync } from "node:sqlite"; +import { validateOrigin } from "./store.server"; + +/** Read-only startup verification. SQLite integrity alone cannot detect malformed + * auth JSON or missing tables that would leave the owner unable to sign in. */ +export function verifyAuthReadiness(db: DatabaseSync) { + const config = JSON.parse( + String(db.prepare("SELECT value FROM config WHERE id=1").get()?.value), + ); + validateOrigin(config.origin); + if ( + typeof config.owner !== "string" || + !/^[A-Za-z0-9_-]{43}$/.test(config.owner) || + !Number.isSafeInteger(config.generation) || + config.generation < 1 || + !Number.isFinite(config.expires) || + config.expires < 0 || + (config.bootstrap !== null && + (typeof config.bootstrap !== "string" || + !/^[a-f0-9]{64}$/.test(config.bootstrap))) + ) + throw new Error("Auth configuration failed readiness checks."); + const credentials = db.prepare("SELECT id,value FROM credentials").all(); + if ( + !credentials.length && + (!config.bootstrap || config.expires <= Date.now()) + ) + throw new Error("Auth has no owner credential or enrollment path."); + for (const row of credentials) { + const credential = JSON.parse(String(row.value)); + if ( + credential.id !== row.id || + typeof credential.id !== "string" || + !credential.id || + typeof credential.publicKey !== "string" || + !/^[A-Za-z0-9_-]+$/.test(credential.publicKey) || + !Number.isSafeInteger(credential.counter) || + credential.counter < 0 + ) + throw new Error("Auth credential failed readiness checks."); + } + db.prepare( + "SELECT id,credential,created,expires FROM sessions LIMIT 0", + ).all(); + db.prepare("SELECT id,value,expires FROM ceremonies LIMIT 0").all(); + db.prepare("SELECT id,count,expires FROM limits LIMIT 0").all(); +} diff --git a/src/server/codex/login.server.ts b/src/server/codex/login.server.ts index 1673bee..4cd2717 100644 --- a/src/server/codex/login.server.ts +++ b/src/server/codex/login.server.ts @@ -171,3 +171,6 @@ export async function closeLogin() { await state.starting; await state.cancel?.(); } + +export const loginActive = () => + Boolean(state.starting || state.value.status === "pending"); diff --git a/src/server/coding/worker.server.ts b/src/server/coding/worker.server.ts index be62150..9f6d598 100644 --- a/src/server/coding/worker.server.ts +++ b/src/server/coding/worker.server.ts @@ -186,6 +186,7 @@ export async function tickCodingJobs( status: "blocked", error: "This terminal now belongs to a different worker. No input was sent; inspect it in Herdr.", + lastWorkerState: "unknown", }); if (worker.state === "missing") return persist(job, { diff --git a/src/server/computer/session.server.ts b/src/server/computer/session.server.ts index 1e65c1c..189d047 100644 --- a/src/server/computer/session.server.ts +++ b/src/server/computer/session.server.ts @@ -147,3 +147,7 @@ export const endComputerAction = () => { export const releaseComputer = (agentId: string) => { if (state.agent === agentId) state.agent = undefined; }; + +export const computerActivity = () => + Number(state.acting) + + [...state.viewers.values()].filter((viewer) => viewer.connected).length; diff --git a/src/server/computer/socket.server.ts b/src/server/computer/socket.server.ts index 3140824..e20256c 100644 --- a/src/server/computer/socket.server.ts +++ b/src/server/computer/socket.server.ts @@ -1,5 +1,6 @@ import { connect, type Socket } from "node:net"; import { defineWebSocketHandler } from "nitro"; +import { appGate } from "../../updater/gate"; import { authenticatedSocket, sessionActive } from "../auth/session.server"; import { attachViewer, @@ -13,6 +14,8 @@ const authTimers = new Map>(); export default defineWebSocketHandler({ upgrade(request) { + if (appGate().mode !== "open") + throw new Response("Update admission is closed", { status: 503 }); const session = authenticatedSocket(request); const id = new URL(request.url).searchParams.get("ticket") ?? ""; if (!connectViewer(id, request.headers.get("origin"), session)) @@ -57,6 +60,11 @@ export default defineWebSocketHandler({ }, message(peer, message) { + if (["hold", "verify", "manual"].includes(appGate().mode)) { + sockets.get(peer.id)?.destroy(); + peer.close(1013, "Roost is updating"); + return; + } if ( !sessionActive( typeof peer.context.session === "string" ? peer.context.session : null, diff --git a/src/server/runs/store.server.ts b/src/server/runs/store.server.ts index a455841..abde7d4 100644 --- a/src/server/runs/store.server.ts +++ b/src/server/runs/store.server.ts @@ -330,7 +330,7 @@ export const claimSteeringRun = (run: Run) => (db) => db .prepare( - "UPDATE runs SET status='steering',owner=?,startedAt=?,threadId=(SELECT threadId FROM runs WHERE id=?) WHERE id=(SELECT q.id FROM runs q WHERE q.agentId=? AND q.kind='chat' AND q.status='queued' AND q.cancelRequested=0 AND EXISTS (SELECT 1 FROM runs r WHERE r.id=? AND r.owner=? AND r.status='running' AND r.kind IN ('chat','handoff') AND r.cancelRequested=0) AND EXISTS (SELECT 1 FROM worker_lease WHERE owner=? AND heartbeat>?) ORDER BY q.createdAt,q.rowid LIMIT 1) RETURNING *", + "UPDATE runs SET status='steering',owner=?,startedAt=?,threadId=(SELECT threadId FROM runs WHERE id=?) WHERE id=(SELECT q.id FROM runs q WHERE q.agentId=? AND q.kind='chat' AND q.status='queued' AND (SELECT maintenance FROM runtime_control WHERE id=1)=0 AND q.cancelRequested=0 AND EXISTS (SELECT 1 FROM runs r WHERE r.id=? AND r.owner=? AND r.status='running' AND r.kind IN ('chat','handoff') AND r.cancelRequested=0) AND EXISTS (SELECT 1 FROM worker_lease WHERE owner=? AND heartbeat>?) ORDER BY q.createdAt,q.rowid LIMIT 1) RETURNING *", ) .get( run.owner, diff --git a/src/server/runs/worker.server.ts b/src/server/runs/worker.server.ts index 0be6220..d16aa3c 100644 --- a/src/server/runs/worker.server.ts +++ b/src/server/runs/worker.server.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; import { Effect } from "effect"; import type { ChatEvent, Message } from "../../features/chat/schema"; +import { appGate } from "../../updater/gate"; import { AgentStoreError, withAgentStore } from "../agents/store.server"; import { CodexError } from "../codex/app-server.server"; import { @@ -201,7 +202,12 @@ export function startWorker() { } const current = worker; current.tick = async () => { - if (current.ticking || current.stopped) return; + if ( + current.ticking || + current.stopped || + ["hold", "verify", "manual"].includes(appGate().mode) + ) + return; current.ticking = true; try { const owns = await Effect.runPromise(schedulerTick(current.owner)); @@ -290,3 +296,11 @@ export function startWorker() { workers.delete(root); }; } + +export function workerActivity() { + const current = workers.get(resolve(process.env.ROOST_DATA_DIR ?? ".roost")); + return { + initialized: !!current, + tasks: current ? current.tasks.size + Number(current.ticking) : 0, + }; +} diff --git a/src/server/update-gate.server.ts b/src/server/update-gate.server.ts new file mode 100644 index 0000000..a910a1b --- /dev/null +++ b/src/server/update-gate.server.ts @@ -0,0 +1,145 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { Effect } from "effect"; +import { appGate } from "../updater/gate"; +import { withAgentStore } from "./agents/store.server"; +import { verifyAuthReadiness } from "./auth/readiness.server"; +import { loginActive } from "./codex/login.server"; +import { computerActivity } from "./computer/session.server"; +import { workerActivity } from "./runs/worker.server"; + +let requests = 0; +export async function trackUpdateRequest(action: () => Promise) { + requests++; + try { + return await action(); + } finally { + requests--; + } +} +export async function updateGateRequest( + request: Request, + renderShell?: () => Response | Promise, +): Promise { + const gate = appGate(); + const url = new URL(request.url); + const authorized = + request.headers.get("X-Roost-Updater") === (gate.token ?? gate.operation) && + gate.mode !== "open"; + if (url.pathname === "/api/updates/quiescence" && authorized) { + const activity = workerActivity(); + return Response.json({ + requests, + tasks: activity.tasks + computerActivity(), + login: loginActive(), + frozen: ["hold", "verify", "manual"].includes(gate.mode), + }); + } + if ( + url.pathname === "/api/updates/probe" && + authorized && + gate.mode === "verify" + ) { + const appSchema = await Effect.runPromise( + withAgentStore((db) => { + if ( + db + .prepare("PRAGMA integrity_check") + .all() + .some((r) => r.integrity_check !== "ok") + ) + throw new Error("App integrity failed."); + return Number(db.prepare("PRAGMA user_version").get()?.user_version); + }), + ); + const auth = new DatabaseSync( + join(process.env.ROOST_DATA_DIR!, "auth.sqlite"), + { readOnly: true }, + ); + let authSchema = 0; + try { + authSchema = Number( + auth.prepare("PRAGMA user_version").get()?.user_version, + ); + if ( + auth + .prepare("PRAGMA integrity_check") + .all() + .some((r) => r.integrity_check !== "ok") + ) + throw new Error("Auth integrity failed."); + verifyAuthReadiness(auth); + } finally { + auth.close(); + } + const assets = process.env.ROOST_PUBLIC_DIR; + const names = assets ? await readdir(join(assets, "assets")) : []; + const script = names.find((name) => name.endsWith(".js")); + const style = names.find((name) => name.endsWith(".css")); + const healthyAssets = !!( + script && + style && + assets && + (await readFile(join(assets, "assets", script))).length && + (await readFile(join(assets, "assets", style))).length + ); + let shell = false; + let referencedAssets = false; + if (renderShell) { + const response = await renderShell(); + const { boundedBytes } = await import("../updater/releases"); + const html = (await boundedBytes(response, 2 * 1024 * 1024)).toString(); + const references = [ + ...new Set(html.match(/\/assets\/[^"'<>\s)]+\.(?:js|css)/g) ?? []), + ]; + referencedAssets = + !!assets && + references.some((p) => p.endsWith(".js")) && + references.some((p) => p.endsWith(".css")); + for (const path of references) { + const name = path.slice("/assets/".length); + if (!/^[A-Za-z0-9_.-]+\.(?:js|css)$/.test(name)) { + referencedAssets = false; + break; + } + const info = await stat(join(assets!, "assets", name)).catch( + () => null, + ); + if (!info?.isFile() || info.size === 0) referencedAssets = false; + } + shell = + response.ok && + response.headers.get("content-type")?.includes("text/html") === true && + html.includes("Software updates") && + html.includes(""); + } + return Response.json({ + shell, + operation: gate.operation, + token: gate.token, + version: process.env.ROOST_RELEASE_VERSION, + integrity: true, + appSchema, + authSchema, + assets: healthyAssets && referencedAssets, + workerReady: workerActivity().initialized, + }); + } + if (["hold", "verify", "manual"].includes(gate.mode)) { + if (url.pathname === "/api/health") + return Response.json( + { status: "maintenance" }, + { status: 503, headers: { "Cache-Control": "no-store" } }, + ); + return new Response( + "Roost is updating or recovering. Reconnect to check the durable result. Do not resubmit work.", + { + status: 503, + headers: { "Cache-Control": "no-store", "Retry-After": "2" }, + }, + ); + } + return null; +} diff --git a/src/server/worker-plugin.ts b/src/server/worker-plugin.ts index ec8a852..a626049 100644 --- a/src/server/worker-plugin.ts +++ b/src/server/worker-plugin.ts @@ -1,9 +1,17 @@ import { definePlugin } from "nitro"; +import { appRoot, startupGuard } from "../updater/gate"; import { closeAgentRuntimes } from "./codex/agent-runtime.server"; import { closeLogin } from "./codex/login.server"; import { startWorker } from "./runs/worker.server"; export default definePlugin((app) => { + const root = appRoot(); + if (root) + startupGuard( + root, + process.env.ROOST_RELEASE_VERSION ?? "dev", + process.env.ROOST_UPDATE_TOKEN, + ); const stop = startWorker(); app.hooks.hook("close", async () => { await stop(); diff --git a/tests/coding-worker.test.ts b/tests/coding-worker.test.ts index 740602d..f8144fc 100644 --- a/tests/coding-worker.test.ts +++ b/tests/coding-worker.test.ts @@ -771,3 +771,86 @@ test("a backlog over forty queued jobs cannot starve active monitoring or stop r "cancelled", ); })); + +for (const dispatch of ["launch", "follow-up"] as const) { + test(`update admission closing during coding ${dispatch} preparation prevents submission and replay`, async () => + fixture(async (agentId) => { + const job = await create(agentId); + const mock = mockAdapter(); + if (dispatch === "follow-up") { + await tickCodingJobs(owner, signal(), mock.adapter); + await run( + withAgentStore((db) => { + db.prepare( + "INSERT INTO coding_job_inputs(id,jobId,agentId,prompt,createdAt) VALUES(?,?,?,?,?)", + ).run( + randomUUID(), + job.id, + agentId, + "Unsent follow-up", + Date.now(), + ); + }), + ); + await poll(); + } + let entered!: () => void; + let release!: () => void; + const preparing = new Promise((resolve) => { + entered = resolve; + }); + const ready = new Promise((resolve) => { + release = resolve; + }); + const prepare = async (beforeSend: (() => Promise) | undefined) => { + entered(); + await ready; + assert.ok(beforeSend); + await beforeSend(); + mock.calls.prompts++; + return worker(); + }; + if (dispatch === "launch") { + mock.adapter.startCodingWorker = async (_target, options) => { + mock.calls.starts++; + return prepare(options.beforeSend); + }; + } else { + mock.adapter.promptCodingWorker = async ( + _target, + _worker, + _prompt, + _identity, + _signal, + beforeSend, + ) => prepare(beforeSend); + } + const ticking = tickCodingJobs(owner, signal(), mock.adapter); + await preparing; + try { + await run( + withAgentStore((db) => + db.exec("UPDATE runtime_control SET maintenance=1 WHERE id=1"), + ), + ); + } finally { + release(); + await ticking; + } + assert.equal(mock.calls.prompts, 0); + const blocked = await run(getCodingJob(agentId, job.id)); + assert.equal(blocked.status, "blocked"); + // Reopening admission is not fresh authorization to retry the interrupted + // dispatch. Existing identity is inspected and the prompt stays unsent. + await run( + withAgentStore((db) => + db.exec("UPDATE runtime_control SET maintenance=0 WHERE id=1"), + ), + ); + await poll(); + mock.state.worker = worker("missing"); + await tickCodingJobs(owner, signal(), mock.adapter); + assert.equal(mock.calls.starts, 1); + assert.equal(mock.calls.prompts, 0); + })); +} diff --git a/tests/runs.test.ts b/tests/runs.test.ts index adf48f3..7b516c6 100644 --- a/tests/runs.test.ts +++ b/tests/runs.test.ts @@ -21,6 +21,7 @@ import { closeAgentRuntimes } from "../src/server/codex/agent-runtime.server"; import { cancelRun, claimRun, + claimSteeringRun, enqueueChat, finishRun, listRuns, @@ -767,3 +768,113 @@ test("background work reserves capacity for user conversations", async () => { rmSync(directory, { recursive: true, force: true }); } }); + +test("update admission fences queued claims and steering while observations finish; downtime catches up once", async () => { + const directory = mkdtempSync("/tmp/roost-update-admission-"); + const old = process.env.ROOST_DATA_DIR; + process.env.ROOST_DATA_DIR = directory; + try { + const agent = await create("Update admission"); + const automation = await run( + saveAutomation({ + agentId: agent.id, + id: randomUUID(), + name: "Due during outage", + prompt: "quiet", + schedule: { kind: "interval", minutes: 60 }, + notification: "when-needed", + }), + ); + await run( + enqueueChat({ + agentId: agent.id, + messageId: randomUUID(), + text: "Accepted before drain", + }), + ); + await run(schedulerTick("update-test")); + const active = (await run(claimRun("update-test")))!; + assert.ok(active); + await run( + enqueueChat({ + agentId: agent.id, + messageId: randomUUID(), + text: "Queued before drain", + }), + ); + const overdue = Date.now() - 10 * 3600000; + await run( + withAgentStore((db) => { + db.exec("UPDATE runtime_control SET maintenance=1 WHERE id=1"); + db.prepare("UPDATE automations SET nextRunAt=? WHERE id=?").run( + overdue, + automation.id, + ); + }), + ); + assert.equal(await run(claimSteeringRun(active)), undefined); + assert.equal(await run(claimRun("update-test")), undefined); + await assert.rejects( + run( + enqueueChat({ + agentId: agent.id, + messageId: randomUUID(), + text: "After drain", + }), + ), + /updating/, + ); + await run(schedulerTick("update-test")); + await run(schedulerTick("update-test")); + assert.equal((await run(listAutomations(agent.id)))[0]!.nextRunAt, overdue); + assert.equal( + (await run(listRuns(agent.id))).filter((r) => r.kind === "automation") + .length, + 0, + ); + await run( + persistRun(active, [ + { + id: "observed-during-drain", + role: "assistant", + text: "Existing work remains observable", + }, + ]), + ); + await run( + finishRun(active, "completed", [ + { + id: "observed-during-drain", + role: "assistant", + text: "Existing work completed", + }, + ]), + ); + assert.equal( + (await run(listRuns(agent.id))).find((r) => r.id === active.id)?.status, + "completed", + ); + assert.equal(await run(claimRun("update-test")), undefined); + await run( + withAgentStore((db) => + db.exec("UPDATE runtime_control SET maintenance=0 WHERE id=1"), + ), + ); + await run(schedulerTick("update-test")); + await run(schedulerTick("update-test")); + const runs = await run(listRuns(agent.id)); + assert.equal(runs.filter((r) => r.kind === "automation").length, 1); + assert.ok( + (await run(listAutomations(agent.id)))[0]!.nextRunAt! > Date.now(), + ); + assert.equal(runs.filter((r) => r.id === active.id).length, 1); + assert.equal( + (await run(claimRun("update-test")))?.prompt, + "Queued before drain", + ); + } finally { + if (old === undefined) delete process.env.ROOST_DATA_DIR; + else process.env.ROOST_DATA_DIR = old; + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/steering.test.ts b/tests/steering.test.ts index a50f4e1..32a1d9e 100644 --- a/tests/steering.test.ts +++ b/tests/steering.test.ts @@ -200,6 +200,23 @@ test("finished turns leave pending messages queued; ownership and restart recove await run(claimSteeringRun({ ...first, owner: "foreign" })), undefined, ); + await run( + withAgentStore((db) => + db.prepare("UPDATE runtime_control SET maintenance=1 WHERE id=1").run(), + ), + ); + assert.equal(await run(claimSteeringRun(first)), undefined); + assert.equal( + (await run(listRuns(agentId))).find( + (entry) => entry.id === second.messageId, + )?.status, + "queued", + ); + await run( + withAgentStore((db) => + db.prepare("UPDATE runtime_control SET maintenance=0 WHERE id=1").run(), + ), + ); await run(finishRun(first, "completed", [])); assert.equal(await run(claimSteeringRun(first)), undefined); const next = (await run(claimRun("owner")))!; diff --git a/tests/update-auth-readiness.test.ts b/tests/update-auth-readiness.test.ts new file mode 100644 index 0000000..45b7261 --- /dev/null +++ b/tests/update-auth-readiness.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { test } from "node:test"; +import { verifyAuthReadiness } from "../src/server/auth/readiness.server"; +import { AuthStore } from "../src/server/auth/store.server"; + +test("candidate auth checks reject malformed JSON, lost credentials and missing API tables without writing", () => { + const root = mkdtempSync("/tmp/roost-auth-readiness-"); + const store = new AuthStore(root); + store.setup("http://localhost:4195"); + store.close(); + const db = new DatabaseSync(join(root, "auth.sqlite")); + try { + verifyAuthReadiness(db); + const original = db + .prepare("SELECT value FROM config WHERE id=1") + .get()!.value; + for (const bad of [ + "{", + JSON.stringify({ + ...JSON.parse(String(original)), + origin: "http://untrusted.test", + }), + JSON.stringify({ ...JSON.parse(String(original)), bootstrap: null }), + JSON.stringify({ ...JSON.parse(String(original)), expires: 1 }), + ]) { + db.prepare("UPDATE config SET value=?").run(bad); + assert.throws(() => verifyAuthReadiness(db)); + } + db.prepare("UPDATE config SET value=?").run(original); + db.prepare("INSERT INTO credentials VALUES (?,?)").run("bad", "{}"); + assert.throws(() => verifyAuthReadiness(db)); + db.exec("DELETE FROM credentials; DROP TABLE sessions"); + assert.throws(() => verifyAuthReadiness(db)); + db.exec( + "CREATE TABLE sessions(id TEXT,credential TEXT,created INTEGER,expires INTEGER)", + ); + const readonly = new DatabaseSync(join(root, "auth.sqlite"), { + readOnly: true, + }); + try { + verifyAuthReadiness(readonly); + } finally { + readonly.close(); + } + } finally { + db.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/update-gate.test.ts b/tests/update-gate.test.ts new file mode 100644 index 0000000..15b49fa --- /dev/null +++ b/tests/update-gate.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { test } from "node:test"; +import { AuthStore } from "../src/server/auth/store.server"; +import { + trackUpdateRequest, + updateGateRequest, +} from "../src/server/update-gate.server"; +import { startupGuard, writeGate } from "../src/updater/gate"; + +test("boot and candidate gates reject stale readiness, ordinary HTTP/auth and in-flight quiescence", async () => { + const root = await mkdtemp("/tmp/roost-gate-"); + const previous = { + ROOST_HOME: process.env.ROOST_HOME, + ROOST_DATA_DIR: process.env.ROOST_DATA_DIR, + }; + process.env.ROOST_HOME = root; + process.env.ROOST_DATA_DIR = join(root, "data"); + try { + await mkdir(join(root, "updates")); + await writeFile(join(root, "updater.json"), "{}"); + const boot = ( + await readFile("/proc/sys/kernel/random/boot_id", "utf8") + ).trim(); + await writeFile( + join(root, "updates", "ready.json"), + JSON.stringify({ boot: "old-boot" }), + ); + await writeGate(root, { protocol: 1, operation: null, mode: "open" }); + assert.throws(() => startupGuard(root, "0.1.41", undefined)); + await writeFile( + join(root, "updates", "ready.json"), + JSON.stringify({ boot }), + ); + startupGuard(root, "0.1.41", undefined); + await writeGate(root, { + protocol: 1, + operation: "operation", + mode: "verify", + version: "0.1.41", + token: "private-capability", + }); + assert.throws(() => startupGuard(root, "0.1.41", undefined)); + assert.throws(() => startupGuard(root, "0.1.40", "private-capability")); + startupGuard(root, "0.1.41", "private-capability"); + for (const path of [ + "/", + "/api/health", + "/auth", + "/auth/api/login-options", + "/api/updates", + "/api/updates/probe", + "/_serverFn/mutation", + "/api/desktop/socket", + ]) { + const response = await updateGateRequest( + new Request(`http://localhost${path}`), + ); + assert.equal(response?.status, 503, path); + } + let release!: () => void; + const action = trackUpdateRequest(async () => { + await new Promise((r) => { + release = r; + }); + return new Response(); + }); + const probe = () => + updateGateRequest( + new Request("http://localhost/api/updates/quiescence", { + headers: { "X-Roost-Updater": "private-capability" }, + }), + ); + const during = await (await probe())!.json(); + assert.equal(during.requests, 1); + assert.equal(during.frozen, true); + release(); + await action; + assert.equal((await (await probe())!.json()).requests, 0); + await writeFile(join(root, "updates", "gate.json"), "corrupt"); + assert.throws(() => startupGuard(root, "0.1.41", "private-capability")); + assert.equal( + (await updateGateRequest(new Request("http://localhost/auth")))?.status, + 503, + ); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await rm(root, { recursive: true, force: true }); + } +}); + +test("candidate probes validate referenced assets and actual native-auth storage before commit", async () => { + const root = await mkdtemp("/tmp/roost-probe-"); + const keys = [ + "ROOST_HOME", + "ROOST_DATA_DIR", + "ROOST_PUBLIC_DIR", + "ROOST_RELEASE_VERSION", + ]; + const previous = Object.fromEntries( + keys.map((key) => [key, process.env[key]]), + ); + Object.assign(process.env, { + ROOST_HOME: root, + ROOST_DATA_DIR: join(root, "data"), + ROOST_PUBLIC_DIR: join(root, "public"), + ROOST_RELEASE_VERSION: "0.1.41", + }); + try { + await mkdir(join(root, "updates")); + await mkdir(join(root, "public", "assets"), { recursive: true }); + await writeFile(join(root, "updater.json"), "{}"); + await writeFile( + join(root, "updates", "ready.json"), + JSON.stringify({ + boot: ( + await readFile("/proc/sys/kernel/random/boot_id", "utf8") + ).trim(), + }), + ); + const auth = new AuthStore(join(root, "data")); + auth.setup("http://localhost:4195"); + auth.close(); + await writeGate(root, { + protocol: 1, + operation: "probe", + mode: "verify", + version: "0.1.41", + token: "probe-capability", + }); + for (const name of ["main.js", "decoy.js", "main.css"]) + await writeFile(join(root, "public", "assets", name), "fixture"); + const probe = () => + updateGateRequest( + new Request("http://localhost/api/updates/probe", { + headers: { "X-Roost-Updater": "probe-capability" }, + }), + () => + new Response( + 'Software updates', + { headers: { "Content-Type": "text/html" } }, + ), + ); + const healthy = await (await probe())!.json(); + assert.equal(healthy.integrity, true); + assert.equal(healthy.assets, true); + assert.equal(healthy.shell, true); + await rm(join(root, "public", "assets", "main.js")); + assert.equal((await (await probe())!.json()).assets, false); + const db = new DatabaseSync(join(root, "data", "auth.sqlite")); + db.exec("UPDATE config SET value='malformed'"); + db.close(); + await assert.rejects(probe()); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await rm(root, { recursive: true, force: true }); + } +});