void stop(job.id)}
xstyle={styles.stop}
>
diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx
index bc56c4e..9b802a4 100644
--- a/src/routes/settings.tsx
+++ b/src/routes/settings.tsx
@@ -12,6 +12,7 @@ import { PasskeySetting } from "../components/passkey-setting";
import { PushNotifications } from "../components/push-notifications";
import { ThemeSetting } from "../components/theme-setting";
import { Button } from "../components/ui/button";
+import { UpdateSetting } from "../components/update-setting";
import { getCodexAccount, getCodexLogin } from "../features/auth/functions";
import { getPushSettings } from "../features/notifications/functions";
import {
@@ -164,6 +165,9 @@ function SettingsPage() {
+
Looking for an agent’s model, instructions, or automations? Open
that agent to manage its settings.
diff --git a/src/server.ts b/src/server.ts
index 652f711..0cefb5b 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -8,22 +8,42 @@ 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";
+import { updatesRequest } from "./server/updates.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;
+ if (new URL(request.url).pathname.startsWith("/api/updates"))
+ return updatesRequest(request);
+ 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/client.js b/src/server/auth/client.js
index 8cc48e5..b77ab0f 100644
--- a/src/server/auth/client.js
+++ b/src/server/auth/client.js
@@ -1,10 +1,13 @@
const content = document.querySelector("#content");
const status = document.querySelector("#status");
let setup = null;
+// A fixed destination for updater reauthentication, never a supplied URL/path.
+const returnToUpdates =
+ new URLSearchParams(location.search).get("updates") === "1";
function captureSetup() {
const value = new URLSearchParams(location.hash.slice(1)).get("setup");
if (value) setup = value;
- history.replaceState(null, "", "/auth");
+ history.replaceState(null, "", returnToUpdates ? "/auth?updates=1" : "/auth");
}
captureSetup();
window.addEventListener("hashchange", () => {
@@ -111,7 +114,15 @@ async function passkey(register, name, returnToSettings = false) {
name,
});
setup = null;
- location.assign((register && name) || returnToSettings ? "/auth" : "/");
+ location.assign(
+ register && name
+ ? "/auth"
+ : returnToUpdates
+ ? "/settings?group=updates"
+ : returnToSettings
+ ? "/auth"
+ : "/",
+ );
}
async function render() {
const response = await fetch("/auth/api/state", { cache: "no-store" });
@@ -152,7 +163,7 @@ async function render() {
return;
}
const back = element("a", "Back to Roost");
- back.href = "/settings";
+ back.href = returnToUpdates ? "/settings?group=updates" : "/settings";
content.append(
back,
element("h1", "Passkeys and sessions"),
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("",
+ assets: [
+ {
+ id: 3,
+ name: "roost-linux-x64.tar.gz",
+ digest: `sha256:${"a".repeat(64)}`,
+ size: 1234,
+ url: "https://api.github.com/repos/srctl/roost/releases/assets/3",
+ },
+ ],
+});
+const contract = {
+ protocol: 1,
+ app: { min: 10, max: 10, output: 10 },
+ auth: { min: 0, max: 0, output: 0 },
+ data: "complete-snapshot-v1",
+ externalState: "unchanged",
+ codex: "0.153.4",
+};
+
+test("capability never infers enrollment or activates unqualified installations", () => {
+ for (const facts of [
+ { packaged: false, platform: "linux", arch: "x64", systemd: true },
+ { packaged: true, platform: "linux", arch: "arm64", systemd: true },
+ { packaged: true, platform: "linux", arch: "x64", systemd: false },
+ { packaged: true, platform: "linux", arch: "x64", systemd: true },
+ ])
+ assert.equal(capability(facts).canActivate, false);
+ for (const [extra, code] of [
+ [{ distribution: "fedora:43" }, "unsupported-distribution"],
+ [
+ { distribution: "ubuntu:24.04", filesystem: "overlay" },
+ "unsupported-storage",
+ ],
+ ] as const)
+ assert.equal(
+ capability({
+ packaged: true,
+ platform: "linux",
+ arch: "x64",
+ systemd: true,
+ ...extra,
+ }).code,
+ code,
+ );
+ assert.equal(
+ capability({
+ packaged: false,
+ platform: "darwin",
+ arch: "arm64",
+ systemd: false,
+ }).code,
+ "externally-managed",
+ );
+});
+
+test("stable numeric versions and explicit database/runtime compatibility default deny", () => {
+ assert.equal(compareVersions("0.1.40", "0.1.9"), 1);
+ assert.equal(compareVersions("1.0.0", "1.0.0"), 0);
+ for (const invalid of [
+ "1.2.3-beta",
+ "01.2.3",
+ "9007199254740992.1.0",
+ "v1.2.3",
+ "1.2.3\n",
+ ])
+ assert.throws(() => compareVersions(invalid, "1.2.3"));
+ assertCompatible(contract, { app: 10, auth: 0, codex: "0.153.4" });
+ for (const installed of [
+ { app: 11, auth: 0, codex: "0.153.4" },
+ { app: 9, auth: 0, codex: "0.153.4" },
+ { app: 10, auth: 1, codex: "0.153.4" },
+ { app: 10, auth: 0, codex: "0.154.0" },
+ ])
+ assert.throws(() => assertCompatible(contract, installed));
+ assert.throws(() =>
+ assertCompatible(undefined, { app: 10, auth: 0, codex: "0.153.4" }),
+ );
+});
+
+test("offers pin stable release, asset identity, digest, size and expiry", () => {
+ const source = metadata();
+ const offer = parseOffer(source, "srctl/roost", 1000);
+ assert.ok(
+ Buffer.byteLength(
+ JSON.stringify(
+ parseOffer(
+ { ...source, body: "\u0000".repeat(16000) },
+ "srctl/roost",
+ 1000,
+ ),
+ ),
+ ) < 60000,
+ );
+ assertOffer(offer, offer.id, "0.1.40", 2000);
+ assert.throws(() => assertOffer(offer, offer.id, "0.2.0", 2000));
+ assert.throws(() => assertOffer(offer, offer.id, "0.1.40", offer.expiresAt));
+ const changed = metadata();
+ changed.assets[0]!.id = 4;
+ changed.assets[0]!.url =
+ "https://api.github.com/repos/srctl/roost/releases/assets/4";
+ assert.notEqual(parseOffer(changed, "srctl/roost", 1000).id, offer.id);
+ for (const changed of [
+ { ...source, draft: true },
+ { ...source, prerelease: true },
+ { ...source, tag_name: "v0.2.0-rc1" },
+ { ...source, assets: [...source.assets, ...source.assets] },
+ { ...source, assets: [{ ...source.assets[0], digest: "" }] },
+ {
+ ...source,
+ assets: [{ ...source.assets[0], url: "https://evil.example/archive" }],
+ },
+ { ...source, assets: [{ ...source.assets[0], size: 2 ** 40 }] },
+ ])
+ assert.throws(() => parseOffer(changed, "srctl/roost", 1000));
+});
+
+test("metadata checks coalesce, back off and never redirect credentials", async () => {
+ let calls = 0;
+ let now = 1000;
+ const fetcher = (async (_url, init) => {
+ calls++;
+ assert.equal(init?.redirect, "error");
+ assert.equal(
+ (init!.headers as Record).Authorization,
+ "Bearer private-token",
+ );
+ return Response.json(metadata(), { headers: { etag: '"pinned"' } });
+ }) as typeof fetch;
+ const checker = new ReleaseChecker(
+ "srctl/roost",
+ fetcher,
+ "private-token",
+ () => now,
+ );
+ const [a, b] = await Promise.all([checker.check(), checker.check()]);
+ assert.deepEqual(a, b);
+ assert.equal(calls, 1);
+ await assert.rejects(checker.check(), /Wait a minute/);
+ now += 60001;
+ await checker.check();
+ assert.equal(calls, 2);
+ for (const status of [401, 404, 429, 500]) {
+ const failed = new ReleaseChecker(
+ "srctl/roost",
+ async () => new Response("private-token", { status }),
+ );
+ await assert.rejects(
+ failed.check(),
+ (error: Error) => !error.message.includes("private-token"),
+ );
+ assert.equal(failed.cached, undefined);
+ }
+});
+
+test("streamed metadata and request bodies enforce actual byte limits and field allowlists", async () => {
+ await assert.rejects(boundedBytes(new Response("12345"), 4), /size limit/);
+ await assert.rejects(
+ boundedBytes(
+ new Response("1", { headers: { "content-length": "100" } }),
+ 4,
+ ),
+ /size limit/,
+ );
+ assert.equal(
+ (await boundedBytes(new Response("1234"), 4)).toString(),
+ "1234",
+ );
+ const request = (value: unknown) =>
+ new Request("https://roost.example/api/updates", {
+ method: "POST",
+ body: JSON.stringify(value),
+ });
+ for (const value of [
+ { unit: "other" },
+ { path: "/" },
+ { url: "https://evil.example" },
+ [],
+ null,
+ ])
+ await assert.rejects(updateBody(request(value), []));
+ assert.deepEqual(await updateBody(request({}), []), {});
+});
+
+test("mutations require recent native authentication, exact host/origin, JSON and session-bound CSRF", () => {
+ const context = {
+ origin: "https://roost.example",
+ session: { id: "session-a", created: 1000 },
+ secret: "server-secret",
+ now: 2000,
+ };
+ const headers = {
+ origin: context.origin,
+ "content-type": "application/json",
+ "x-roost-csrf": csrfToken(context.secret, context.session.id),
+ };
+ const request = (patch = {}, url = context.origin) =>
+ new Request(`${url}/api/updates`, {
+ method: "POST",
+ headers: { ...headers, ...patch },
+ body: "{}",
+ });
+ authorizeMutation(request(), context);
+ for (const patch of [
+ { origin: "" },
+ { origin: "https://evil.example" },
+ { "sec-fetch-site": "cross-site" },
+ { "content-type": "text/plain" },
+ { "x-roost-csrf": csrfToken(context.secret, "session-b") },
+ { "x-roost-csrf": "" },
+ ])
+ assert.throws(() => authorizeMutation(request(patch), context));
+ assert.throws(() =>
+ authorizeMutation(request({}, "https://evil.example"), context),
+ );
+ assert.throws(() =>
+ authorizeMutation(request(), { ...context, session: undefined }),
+ );
+ assert.throws(() =>
+ authorizeMutation(request(), { ...context, now: 400000 }),
+ );
+ assert.throws(() =>
+ authorizeMutation(new Request(context.origin, { headers }), context),
+ );
+});
+
+test("kernel ownership survives flock exit, excludes CLI, releases after SIGKILL, rejects symlink locks", async () => {
+ const root = await mkdtemp("/tmp/ui-update-kernel-");
+ try {
+ await withKernelLock(root, async () => {
+ await withKernelLock(
+ root,
+ async () => {
+ await assert.rejects(
+ withKernelLock(root, async () => {}, "supervisor"),
+ /operation is active/,
+ );
+ },
+ "supervisor",
+ );
+ await assert.rejects(
+ withKernelLock(root, async () => {}),
+ /operation is active/,
+ );
+ await assert.rejects(
+ withLock(root, async () => {}),
+ /operation is active/,
+ );
+ });
+ const child = spawn(
+ process.execPath,
+ [
+ "--import",
+ "tsx",
+ "--input-type=module",
+ "-e",
+ `import { withKernelLock } from './src/updater/lock.ts'; await withKernelLock(${JSON.stringify(root)},async()=>{console.log('owned');await new Promise(()=>{setInterval(()=>{},1000)});});`,
+ ],
+ { stdio: ["ignore", "pipe", "pipe"] },
+ );
+ await once(child.stdout!, "data");
+ child.kill("SIGKILL");
+ await once(child, "exit");
+ await withKernelLock(root, async () => {});
+ await rm(join(root, "updater.lock"));
+ await writeFile(join(root, "target"), "unchanged");
+ await symlink(join(root, "target"), join(root, "updater.lock"));
+ await assert.rejects(withKernelLock(root, async () => {}));
+ assert.equal(await readFile(join(root, "target"), "utf8"), "unchanged");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("journal recovery decisions never restore after durable commit and reject corrupt evidence", async () => {
+ const root = await mkdtemp("/tmp/ui-update-journal-");
+ const journal: Journal = {
+ protocol: 1,
+ id: randomUUID(),
+ sequence: 1,
+ phase: "accepted",
+ previous: "0.1.40",
+ candidate: "0.2.0",
+ wasRunning: true,
+ updatedAt: 1000,
+ };
+ try {
+ await writeJournal(root, journal);
+ assert.deepEqual(await readJournal(root, journal.id), journal);
+ for (const phase of phases) {
+ const value = { ...journal, phase, snapshotDigest: "a".repeat(64) };
+ const plan = recoveryPlan(value);
+ if (phase === "committed")
+ assert.equal(plan, "finish-commit-never-restore");
+ if (
+ ["snapshot-complete", "activating", "verifying", "restoring"].includes(
+ phase,
+ )
+ )
+ assert.equal(plan, "restore-matching-pair");
+ }
+ assert.throws(() => recoveryPlan({ ...journal, phase: "verifying" }));
+ await writeFile(join(root, "updates", journal.id, "journal.json"), "{");
+ await assert.rejects(readJournal(root, journal.id));
+ await assert.rejects(readJournal(root, "../data"));
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("complete protected snapshots include both databases and files; tampering and links fail closed", async () => {
+ const root = await mkdtemp("/tmp/ui-update-snapshot-");
+ try {
+ await mkdir(join(root, "data"), { mode: 0o700 });
+ for (const name of ["roost.sqlite", "auth.sqlite"]) {
+ const db = new DatabaseSync(join(root, "data", name));
+ db.exec(
+ "CREATE TABLE saved(value TEXT); INSERT INTO saved VALUES ('preserved')",
+ );
+ db.close();
+ await chmod(join(root, "data", name), 0o600);
+ }
+ await writeFile(join(root, "data", "secret.txt"), "private", {
+ mode: 0o600,
+ });
+ const id = randomUUID();
+ const digest = await createSnapshot(root, id);
+ const snapshot = await verifySnapshot(root, id, digest);
+ assert.equal(snapshot.entries.filter((e) => e.kind === "file").length, 3);
+ await writeFile(
+ join(root, "updates", id, "snapshot", "secret.txt"),
+ "modified",
+ );
+ await assert.rejects(
+ verifySnapshot(root, id, digest),
+ /digest verification/,
+ );
+ await symlink("/tmp", join(root, "data", "external"));
+ await assert.rejects(
+ createSnapshot(root, randomUUID()),
+ /Unsupported link/,
+ );
+ await assert.rejects(
+ requireHeadroom(root, Number.MAX_SAFE_INTEGER, 100),
+ /Insufficient/,
+ );
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("steering, missing and null worker observations all defer service stop", async () => {
+ const root = await mkdtemp("/tmp/ui-update-work-");
+ await mkdir(join(root, "data"));
+ const db = new DatabaseSync(join(root, "data", "roost.sqlite"));
+ try {
+ db.exec(
+ "CREATE TABLE runs(status TEXT); INSERT INTO runs VALUES('steering'); CREATE TABLE coding_jobs(status TEXT,lastWorkerState TEXT); INSERT INTO coding_jobs VALUES('blocked','missing'),('review',NULL),('queued','unknown')",
+ );
+ assert.equal(activeRuns(root), 3);
+ } finally {
+ db.close();
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("journal updates reject sequence reuse and rollback after commit", async () => {
+ const root = await mkdtemp("/tmp/ui-update-journal-sequence-");
+ try {
+ const journal: Journal = {
+ protocol: 1,
+ id: randomUUID(),
+ sequence: 1,
+ phase: "accepted",
+ previous: "0.1.40",
+ candidate: "0.2.0",
+ wasRunning: true,
+ updatedAt: 1000,
+ };
+ await writeJournal(root, journal);
+ await assert.rejects(writeJournal(root, journal), /Conflicting/);
+ const committed: Journal = {
+ ...journal,
+ sequence: 2,
+ phase: "committed",
+ snapshotDigest: "a".repeat(64),
+ };
+ await writeJournal(root, committed);
+ await assert.rejects(
+ writeJournal(root, { ...committed, sequence: 3, phase: "restoring" }),
+ /Conflicting/,
+ );
+ assert.equal((await readJournal(root, journal.id)).phase, "committed");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("source status is read-only and unauthenticated activation cannot reach any updater", async () => {
+ const directory = await mkdtemp("/tmp/ui-update-http-");
+ const old = process.env.ROOST_DATA_DIR;
+ process.env.ROOST_DATA_DIR = directory;
+ try {
+ const response = await updatesRequest(
+ new Request("https://roost.example/api/updates"),
+ );
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get("cache-control"), "private, no-store");
+ const status = await response.json();
+ assert.equal(status.capability.canActivate, false);
+ assert.equal(status.version, "dev");
+ assert.equal(status.canCheck, false);
+ assert.equal(status.csrf, null);
+ for (const path of ["/api/updates", "/api/updates/check"]) {
+ const denied = await updatesRequest(
+ new Request(`https://roost.example${path}`, {
+ method: "POST",
+ body: JSON.stringify({ unit: "arbitrary.service" }),
+ }),
+ );
+ assert.equal(denied.status, 401);
+ }
+ } finally {
+ if (old === undefined) delete process.env.ROOST_DATA_DIR;
+ else process.env.ROOST_DATA_DIR = old;
+ await rm(directory, { recursive: true, force: true });
+ }
+});
diff --git a/tests/update-artifact.test.ts b/tests/update-artifact.test.ts
new file mode 100644
index 0000000..bcfdb47
--- /dev/null
+++ b/tests/update-artifact.test.ts
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { test } from "node:test";
+import { gzipSync } from "node:zlib";
+
+function archive(name: string, type = "0", contents = "hello") {
+ const header = Buffer.alloc(512);
+ header.write(name, 0, 100, "utf8");
+ header.write("0000644\0", 100);
+ header.write(`${contents.length.toString(8).padStart(11, "0")}\0`, 124);
+ header.fill(32, 148, 156);
+ header.write(type, 156);
+ header.write("ustar\0", 257);
+ const sum = header.reduce((a, b) => a + b, 0);
+ header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148);
+ return gzipSync(
+ Buffer.concat([
+ header,
+ Buffer.from(contents),
+ Buffer.alloc((512 - (contents.length % 512)) % 512),
+ Buffer.alloc(1024),
+ ]),
+ );
+}
+test("bounded extractor accepts regular GNU/ustar files and refuses path/link/device/extension attacks", async () => {
+ const root = await mkdtemp("/tmp/roost-extraction-");
+ try {
+ const file = join(root, "release.gz");
+ await writeFile(file, archive("./directory/file"));
+ execFileSync(
+ "/usr/bin/python3",
+ ["src/updater/extract.py", file, join(root, "good")],
+ { stdio: "pipe" },
+ );
+ assert.equal(
+ await readFile(join(root, "good/directory/file"), "utf8"),
+ "hello",
+ );
+ let n = 0;
+ for (const [name, type] of [
+ ["../escape", "0"],
+ ["/absolute", "0"],
+ ["link", "2"],
+ ["hardlink", "1"],
+ ["device", "3"],
+ ["pax", "x"],
+ ["file", "6"],
+ ]) {
+ await writeFile(file, archive(name!, type));
+ assert.throws(() =>
+ execFileSync(
+ "/usr/bin/python3",
+ ["src/updater/extract.py", file, join(root, `bad-${n++}`)],
+ { stdio: "pipe" },
+ ),
+ );
+ }
+ await writeFile(file, gzipSync(Buffer.alloc(700)));
+ assert.throws(() =>
+ execFileSync(
+ "/usr/bin/python3",
+ ["src/updater/extract.py", file, join(root, "truncated")],
+ { stdio: "pipe" },
+ ),
+ );
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
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-download.test.ts b/tests/update-download.test.ts
new file mode 100644
index 0000000..22147db
--- /dev/null
+++ b/tests/update-download.test.ts
@@ -0,0 +1,380 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawn } from "node:child_process";
+import { createHash, randomUUID } from "node:crypto";
+import { once } from "node:events";
+import {
+ chmod,
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ writeFile,
+} from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { DatabaseSync } from "node:sqlite";
+import { test } from "node:test";
+import { activate } from "../src/cli/releases";
+import { readSchemaVersions, stageArtifact } from "../src/updater/artifact";
+import { parseOffer } from "../src/updater/releases";
+
+test("staging schema reads wait for transient writers and bound persistent contention", async () => {
+ const root = await mkdtemp("/tmp/roost-schema-lock-");
+ try {
+ await mkdir(join(root, "data"));
+ for (const name of ["roost.sqlite", "auth.sqlite"]) {
+ const db = new DatabaseSync(join(root, "data", name));
+ db.exec(`PRAGMA user_version=${name === "roost.sqlite" ? 10 : 0}`);
+ db.close();
+ }
+ for (const [name, transient] of [
+ ["roost.sqlite", true],
+ ["auth.sqlite", true],
+ ["auth.sqlite", false],
+ ] as const) {
+ const writer = spawn(
+ process.execPath,
+ [
+ "--input-type=module",
+ "-e",
+ `import {DatabaseSync} from 'node:sqlite';
+ const db=new DatabaseSync(process.argv[1]);
+ db.exec('BEGIN EXCLUSIVE');
+ process.stdout.write('locked\\n');
+ process.stdin.once('data',()=>setTimeout(()=>{db.exec('COMMIT');db.close();process.exit(0)},200));`,
+ join(root, "data", name),
+ ],
+ { stdio: ["pipe", "pipe", "ignore"] },
+ );
+ const exited = once(writer, "exit");
+ try {
+ assert.equal(
+ String((await once(writer.stdout!, "data"))[0]),
+ "locked\n",
+ );
+ if (transient) writer.stdin!.write("release\n");
+ const start = Date.now();
+ if (transient) {
+ assert.deepEqual(readSchemaVersions(root), { app: 10, auth: 0 });
+ assert.ok(
+ Date.now() - start >= 100,
+ "The actual writer lock was reached",
+ );
+ } else {
+ assert.throws(() => readSchemaVersions(root), /database is locked/);
+ assert.ok(Date.now() - start >= 4500);
+ assert.ok(Date.now() - start < 10000);
+ }
+ } finally {
+ if (!transient && writer.exitCode === null)
+ writer.stdin!.end("release\n");
+ await exited;
+ }
+ }
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("pinned download validates identity/digest/manifest and strips credentials on trusted redirects", async () => {
+ const root = await mkdtemp("/tmp/roost-download-");
+ const compatibility = {
+ protocol: 1,
+ startupGate: 1,
+ app: { min: 10, max: 10, output: 10 },
+ auth: { min: 0, max: 0, output: 0 },
+ data: "complete-snapshot-v1",
+ externalState: "unchanged",
+ codex: "0.153.4",
+ };
+ const manifest = {
+ schema: 1,
+ version: "0.1.40",
+ platform: "linux",
+ arch: "x64",
+ node: "24.15.0",
+ codex: "0.153.4",
+ };
+ try {
+ await mkdir(join(root, "data"));
+ for (const name of ["roost.sqlite", "auth.sqlite"]) {
+ const db = new DatabaseSync(join(root, "data", name));
+ db.exec(`PRAGMA user_version=${name === "roost.sqlite" ? 10 : 0}`);
+ db.close();
+ }
+ for (const version of ["0.1.40", "0.1.41"]) {
+ const target = join(
+ root,
+ version === "0.1.40" ? "releases" : "fixture",
+ version,
+ );
+ for (const [path, contents] of Object.entries({
+ "release.json": JSON.stringify({ ...manifest, version }),
+ "compatibility.json": JSON.stringify(compatibility),
+ "runtime/node": "#!/bin/sh\necho v24.15.0\n",
+ "runtime/codex/bin/codex": "#!/bin/sh\necho codex-cli 0.153.4\n",
+ "cli/roost.mjs": "export {};",
+ "app/server/index.mjs": "export {};",
+ "bin/roost": "#!/bin/sh\n",
+ })) {
+ await mkdir(join(target, path, ".."), { recursive: true });
+ await writeFile(join(target, path), contents);
+ await chmod(join(target, path), 0o700);
+ }
+ }
+ await activate(root, join(root, "releases", "0.1.40"));
+ const archive = join(root, "fixture.tar.gz");
+ execFileSync("tar", [
+ "--format=gnu",
+ "-czf",
+ archive,
+ "-C",
+ join(root, "fixture", "0.1.41"),
+ ".",
+ ]);
+ const bytes = await readFile(archive);
+ const metadata = {
+ id: 1,
+ tag_name: "v0.1.41",
+ draft: false,
+ prerelease: false,
+ assets: [
+ {
+ id: 2,
+ name: "roost-linux-x64.tar.gz",
+ size: bytes.length,
+ digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
+ url: "https://api.github.com/repos/srctl/roost/releases/assets/2",
+ },
+ ],
+ };
+ const offer = parseOffer(metadata, "srctl/roost", Date.now());
+ for (const mode of [
+ "changed",
+ "evil-redirect",
+ "bad-digest",
+ "success",
+ "retry",
+ ] as const) {
+ let calls = 0;
+ const fetcher = (async (url, init) => {
+ calls++;
+ if (calls === 1)
+ return Response.json(
+ mode === "changed"
+ ? {
+ ...metadata,
+ assets: [
+ {
+ ...metadata.assets[0],
+ id: 3,
+ url: "https://api.github.com/repos/srctl/roost/releases/assets/3",
+ },
+ ],
+ }
+ : metadata,
+ );
+ if (calls === 2) {
+ assert.equal(
+ new Headers(init?.headers).get("authorization"),
+ "Bearer private-token",
+ );
+ return new Response(null, {
+ status: 302,
+ headers: {
+ location:
+ mode === "evil-redirect"
+ ? "https://evil.example/payload"
+ : "https://release-assets.githubusercontent.com/payload",
+ },
+ });
+ }
+ assert.equal(
+ String(url),
+ "https://release-assets.githubusercontent.com/payload",
+ );
+ assert.equal(new Headers(init?.headers).get("authorization"), null);
+ return new Response(
+ mode === "bad-digest" ? Buffer.alloc(bytes.length) : bytes,
+ );
+ }) as typeof fetch;
+ const id = randomUUID();
+ await mkdir(join(root, "updates", id), { recursive: true });
+ const run = stageArtifact(
+ root,
+ offer,
+ id,
+ resolve("src/updater"),
+ new AbortController().signal,
+ () => {},
+ "private-token",
+ fetcher,
+ );
+ if (["success", "retry"].includes(mode)) await run;
+ else await assert.rejects(run);
+ assert.equal(
+ await readFile(join(root, "current", "release.json"), "utf8"),
+ JSON.stringify(manifest),
+ );
+ }
+ const fixture = join(root, "fixture", "0.1.41");
+ const validManifest = { ...manifest, version: "0.1.41" };
+ for (const [name, file, contents, error] of [
+ [
+ "tag",
+ "release.json",
+ JSON.stringify({ ...validManifest, version: "0.1.42" }),
+ /Manifest\/tag mismatch/,
+ ],
+ [
+ "platform",
+ "release.json",
+ JSON.stringify({ ...validManifest, arch: "arm64" }),
+ /invalid Roost release manifest/,
+ ],
+ [
+ "protocol",
+ "compatibility.json",
+ JSON.stringify({ ...compatibility, protocol: 2 }),
+ /compatibility contract/,
+ ],
+ [
+ "app schema",
+ "compatibility.json",
+ JSON.stringify({
+ ...compatibility,
+ app: { min: 11, max: 11, output: 11 },
+ }),
+ /Database or bundled Codex compatibility/,
+ ],
+ [
+ "auth schema",
+ "compatibility.json",
+ JSON.stringify({
+ ...compatibility,
+ auth: { min: 1, max: 1, output: 1 },
+ }),
+ /Database or bundled Codex compatibility/,
+ ],
+ [
+ "Codex",
+ "compatibility.json",
+ JSON.stringify({ ...compatibility, codex: "0.154.0" }),
+ /Database or bundled Codex compatibility/,
+ ],
+ [
+ "startup gate",
+ "compatibility.json",
+ JSON.stringify({ ...compatibility, startupGate: 0 }),
+ /startup verification gate/,
+ ],
+ [
+ "runtime version substring",
+ "runtime/node",
+ "#!/bin/sh\necho v124.15.0\n",
+ /Bundled runtime cannot execute/,
+ ],
+ ["runtime ABI", "runtime/node", "#!/unavailable-abi-interpreter\n", /./],
+ ] as const) {
+ const original = await readFile(join(fixture, file));
+ try {
+ await writeFile(join(fixture, file), contents);
+ execFileSync("tar", [
+ "--format=gnu",
+ "-czf",
+ archive,
+ "-C",
+ fixture,
+ ".",
+ ]);
+ const payload = await readFile(archive);
+ const release = {
+ ...metadata,
+ assets: [
+ {
+ ...metadata.assets[0]!,
+ size: payload.length,
+ digest: `sha256:${createHash("sha256").update(payload).digest("hex")}`,
+ },
+ ],
+ };
+ const invalid = parseOffer(release, "srctl/roost", Date.now());
+ const id = randomUUID();
+ await mkdir(join(root, "updates", id), { recursive: true });
+ let calls = 0;
+ const fetcher = (async () =>
+ ++calls === 1
+ ? Response.json(release)
+ : new Response(payload)) as typeof fetch;
+ await assert.rejects(
+ stageArtifact(
+ root,
+ invalid,
+ id,
+ resolve("src/updater"),
+ new AbortController().signal,
+ () => {},
+ undefined,
+ fetcher,
+ ),
+ error,
+ name,
+ );
+ assert.equal(
+ calls,
+ 2,
+ `${name}: artifact must reach actual validation`,
+ );
+ assert.equal(
+ await readFile(join(root, "current", "release.json"), "utf8"),
+ JSON.stringify(manifest),
+ );
+ } finally {
+ await writeFile(join(fixture, file), original);
+ }
+ }
+ for (const prior of [
+ { ...compatibility, app: { min: 0, max: 9, output: 9 } },
+ { ...compatibility, codex: "0.154.0" },
+ ]) {
+ await writeFile(
+ join(root, "current", "compatibility.json"),
+ JSON.stringify(prior),
+ );
+ const id = randomUUID();
+ await mkdir(join(root, "updates", id), { recursive: true });
+ let calls = 0;
+ const fetcher = (async () =>
+ ++calls === 1
+ ? Response.json(metadata)
+ : new Response(bytes)) as typeof fetch;
+ await assert.rejects(
+ stageArtifact(
+ root,
+ offer,
+ id,
+ resolve("src/updater"),
+ new AbortController().signal,
+ () => {},
+ undefined,
+ fetcher,
+ ),
+ /Database or bundled Codex compatibility/,
+ );
+ }
+ await writeFile(
+ join(root, "current", "compatibility.json"),
+ JSON.stringify(compatibility),
+ );
+ assert.equal(
+ JSON.parse(
+ await readFile(
+ join(root, "releases", "0.1.41", "release.json"),
+ "utf8",
+ ),
+ ).version,
+ "0.1.41",
+ );
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/tests/update-drafts.test.ts b/tests/update-drafts.test.ts
new file mode 100644
index 0000000..34e5829
--- /dev/null
+++ b/tests/update-drafts.test.ts
@@ -0,0 +1,93 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { readDraft, saveDraft } from "../src/features/updates/drafts";
+import { rememberResult } from "../src/features/updates/state";
+
+function storage() {
+ const values: Record = {};
+ return new Proxy(values, {
+ get: (target, key) =>
+ key === "getItem"
+ ? (k: string) => target[k] ?? null
+ : key === "setItem"
+ ? (k: string, v: string) => {
+ target[k] = v;
+ }
+ : target[String(key)],
+ });
+}
+test("drafts survive reload, preserve independent tab text, and do not revive sent prompts", () => {
+ const oldLocal = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
+ const oldSession = Object.getOwnPropertyDescriptor(
+ globalThis,
+ "sessionStorage",
+ );
+ try {
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: storage(),
+ });
+ const first = storage();
+ Object.defineProperty(globalThis, "sessionStorage", {
+ configurable: true,
+ value: first,
+ });
+ saveDraft("agent", "unsent text", []);
+ assert.equal(readDraft("agent")?.text, "unsent text");
+ assert.equal(readDraft("different"), null);
+ Object.defineProperty(globalThis, "sessionStorage", {
+ configurable: true,
+ value: storage(),
+ });
+ assert.equal(readDraft("agent")?.text, "unsent text");
+ saveDraft("agent", "other tab", []);
+ Object.defineProperty(globalThis, "sessionStorage", {
+ configurable: true,
+ value: first,
+ });
+ assert.equal(readDraft("agent")?.text, "unsent text");
+ saveDraft("agent", "", []);
+ assert.equal(readDraft("agent")?.text, "");
+ assert.throws(() => saveDraft("agent", "x".repeat(2 * 1024 * 1024), []));
+ } finally {
+ if (oldLocal) Object.defineProperty(globalThis, "localStorage", oldLocal);
+ else Reflect.deleteProperty(globalThis, "localStorage");
+ if (oldSession)
+ Object.defineProperty(globalThis, "sessionStorage", oldSession);
+ else Reflect.deleteProperty(globalThis, "sessionStorage");
+ }
+});
+
+test("unavailable browser storage is reported separately from connectivity", () => {
+ const old = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
+ try {
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: {
+ setItem() {
+ throw new Error("disabled");
+ },
+ },
+ });
+ assert.equal(rememberResult(null), true);
+ assert.equal(
+ rememberResult({
+ id: "operation",
+ requestKey: "key",
+ phase: "accepted",
+ previous: "0.1.40",
+ version: "0.1.41",
+ updatedAt: Date.now(),
+ cancellable: true,
+ committed: false,
+ error: undefined,
+ blockers: undefined,
+ bytes: undefined,
+ }),
+ false,
+ );
+ } finally {
+ if (old) Object.defineProperty(globalThis, "localStorage", old);
+ else Reflect.deleteProperty(globalThis, "localStorage");
+ }
+});
diff --git a/tests/update-engine.test.ts b/tests/update-engine.test.ts
new file mode 100644
index 0000000..0082222
--- /dev/null
+++ b/tests/update-engine.test.ts
@@ -0,0 +1,577 @@
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import fs, {
+ chmod,
+ mkdir,
+ mkdtemp,
+ readFile,
+ realpath,
+ rm,
+ writeFile,
+} from "node:fs/promises";
+import { syncBuiltinESMExports } from "node:module";
+import { join } from "node:path";
+import { DatabaseSync } from "node:sqlite";
+import { mock, test } from "node:test";
+import { activate } from "../src/cli/releases";
+import { maintenance } from "../src/cli/state";
+import {
+ type EngineAdapter,
+ SimulatedPowerLoss,
+ UpdateEngine,
+} from "../src/updater/engine";
+import { enrollmentFiles } from "../src/updater/enrollment";
+import { readGate } from "../src/updater/gate";
+import { parseOffer } from "../src/updater/releases";
+
+async function fixture(
+ run: (
+ root: string,
+ adapter: EngineAdapter,
+ control: { fail: boolean; busy: boolean; running: boolean; stops: number },
+ ) => Promise,
+) {
+ const root = await mkdtemp("/tmp/roost-engine-");
+ await mkdir(join(root, "data"), { mode: 0o700 });
+ await mkdir(join(root, "releases", "0.1.40"), { recursive: true });
+ await mkdir(join(root, "releases", "0.2.0"));
+ await activate(root, join(root, "releases", "0.1.40"));
+ await writeFile(join(root, "config.json"), JSON.stringify({ root }), {
+ mode: 0o600,
+ });
+ for (const name of ["roost.sqlite", "auth.sqlite"]) {
+ const db = new DatabaseSync(join(root, "data", name));
+ db.exec(
+ "CREATE TABLE data(value TEXT); INSERT INTO data VALUES('old'); CREATE TABLE runtime_control(id INTEGER, maintenance INTEGER); INSERT INTO runtime_control VALUES(1,0)",
+ );
+ db.close();
+ await chmod(join(root, "data", name), 0o600);
+ }
+ await writeFile(join(root, "data", "secret"), "original", { mode: 0o600 });
+ const control = { fail: false, busy: false, running: true, stops: 0 };
+ const adapter: EngineAdapter = {
+ running: async () => control.running,
+ stage: async (_offer, _id, signal, progress) => {
+ signal.throwIfAborted();
+ progress(123);
+ },
+ preflight: async () => {},
+ admission: async (blocked) => maintenance(root, blocked),
+ blockers: async () => (control.busy ? ["uncertain worker"] : []),
+ stop: async () => {
+ control.stops++;
+ control.running = false;
+ },
+ start: async () => {
+ control.running = true;
+ },
+ probe: async (version, id, token) => {
+ const db = new DatabaseSync(join(root, "data", "roost.sqlite"), {
+ readOnly: true,
+ });
+ assert.equal(
+ db.prepare("SELECT maintenance FROM runtime_control WHERE id=1").get()
+ ?.maintenance,
+ 1,
+ );
+ db.close();
+ assert.equal(readGate(root).mode, "verify");
+ assert.equal(readGate(root).token, token);
+ assert.equal(readGate(root).operation, id);
+ if (version === "0.2.0") {
+ await writeFile(join(root, "data", "secret"), "candidate");
+ if (control.fail) throw new Error("bad startup");
+ }
+ },
+ };
+ try {
+ await run(root, adapter, control);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+}
+function request() {
+ const offer = parseOffer(
+ {
+ id: 1,
+ tag_name: "v0.2.0",
+ draft: false,
+ prerelease: false,
+ assets: [
+ {
+ id: 2,
+ name: "roost-linux-x64.tar.gz",
+ digest: `sha256:${"a".repeat(64)}`,
+ size: 123,
+ url: "https://api.github.com/repos/srctl/roost/releases/assets/2",
+ },
+ ],
+ },
+ "srctl/roost",
+ Date.now(),
+ );
+ return {
+ actor: "b".repeat(64),
+ key: randomUUID(),
+ offer,
+ confirmedVersion: offer.version,
+ };
+}
+
+test("full engine commits once, preserves candidate data, and keeps prior stopped state", async () => {
+ for (const running of [true, false])
+ await fixture(async (root, adapter, c) => {
+ c.running = running;
+ const engine = new UpdateEngine(root, adapter);
+ const input = request();
+ const accepted = await engine.accept(input);
+ await engine.settled();
+ assert.equal((await engine.status())?.phase, "succeeded");
+ assert.equal(
+ await realpath(join(root, "current")),
+ join(root, "releases", "0.2.0"),
+ );
+ assert.equal(
+ await readFile(join(root, "data", "secret"), "utf8"),
+ "candidate",
+ );
+ assert.equal(c.running, running);
+ assert.equal(readGate(root).mode, "open");
+ assert.equal((await engine.accept(input)).id, accepted.id);
+ await assert.rejects(
+ engine.accept({ ...input, confirmedVersion: "0.3.0" }),
+ );
+ });
+});
+test("startup failure restores matching data/release and preserves failed evidence", async () =>
+ fixture(async (root, adapter, c) => {
+ c.fail = true;
+ const engine = new UpdateEngine(root, adapter);
+ const accepted = await engine.accept(request());
+ await engine.settled();
+ assert.equal((await engine.status())?.phase, "rolled-back");
+ const diagnostic = JSON.parse(
+ await readFile(
+ join(root, "updates", accepted.id, "diagnostic.json"),
+ "utf8",
+ ),
+ );
+ assert.equal(diagnostic.message, "bad startup");
+ assert.doesNotMatch((await engine.status())!.error!, /bad startup/);
+ assert.equal(
+ await realpath(join(root, "current")),
+ join(root, "releases", "0.1.40"),
+ );
+ assert.equal(
+ await readFile(join(root, "data", "secret"), "utf8"),
+ "original",
+ );
+ assert.equal(
+ await readFile(
+ join(root, "updates", accepted.id, "failed-data", "secret"),
+ "utf8",
+ ),
+ "candidate",
+ );
+ assert.equal(readGate(root).mode, "open");
+ }));
+test("busy/uncertain work defers without stopping or replay; cancellation is durable", async () =>
+ fixture(async (root, adapter, c) => {
+ c.busy = true;
+ const engine = new UpdateEngine(root, adapter, 10);
+ await engine.accept(request());
+ await engine.settled();
+ assert.equal((await engine.status())?.phase, "deferred");
+ assert.equal(c.stops, 0);
+ assert.equal(readGate(root).mode, "open");
+ const next = new UpdateEngine(root, adapter, 5000);
+ const op = await next.accept(request());
+ await Promise.all([next.cancel(op.id), next.cancel(op.id)]);
+ await assert.rejects(next.cancel(randomUUID()), /Unknown/);
+ await next.settled();
+ assert.equal((await next.status())?.phase, "cancelled");
+ assert.equal(c.stops, 0);
+ assert.ok(await readFile(join(root, "updates", op.id, "cancel.json")));
+ }));
+test("simulated power loss at intent and rename boundaries recovers deterministically", async () => {
+ for (const phase of [
+ "accepted",
+ "staged",
+ "draining",
+ "stopping",
+ "service-stopped",
+ "snapshot-complete",
+ "activating",
+ "pointer-renamed-before-sync",
+ "pointer-renamed",
+ "verifying",
+ "service-started",
+ "committed",
+ "admission-open",
+ "before-journal-staged",
+ "before-journal-draining",
+ "before-journal-stopping",
+ "before-journal-snapshot-complete",
+ "before-journal-activating",
+ "before-journal-verifying",
+ "before-journal-committed",
+ "before-journal-succeeded",
+ ]) {
+ await fixture(async (root, adapter, _c) => {
+ let tripped = false;
+ const engine = new UpdateEngine(root, adapter, 1000, async (point) => {
+ if (!tripped && point === phase) {
+ tripped = true;
+ throw new SimulatedPowerLoss();
+ }
+ });
+ await engine.accept(request());
+ await engine.settled();
+ assert.ok(tripped, phase);
+ const recovery = new UpdateEngine(root, adapter);
+ await recovery.recover();
+ const committed = [
+ "committed",
+ "admission-open",
+ "before-journal-succeeded",
+ ].includes(phase);
+ assert.equal(
+ await realpath(join(root, "current")),
+ join(root, "releases", committed ? "0.2.0" : "0.1.40"),
+ phase,
+ );
+ assert.equal(readGate(root).mode, "open", phase);
+ assert.equal(
+ await readFile(join(root, "data", "secret"), "utf8"),
+ committed ? "candidate" : "original",
+ phase,
+ );
+ });
+ }
+});
+test("interrupted rollback resumes rename boundaries without overwriting failed data", async () => {
+ for (const phase of [
+ "failed-data-renamed-before-sync",
+ "failed-data-renamed",
+ "restored-data-renamed-before-sync",
+ "restored-data-renamed",
+ "rollback-pointer-renamed-before-sync",
+ "rollback-pointer-renamed",
+ "rollback-service-started",
+ ]) {
+ await fixture(async (root, adapter, c) => {
+ c.fail = true;
+ let tripped = false;
+ const engine = new UpdateEngine(root, adapter, 1000, async (point) => {
+ if (point === phase && !tripped) {
+ tripped = true;
+ throw new SimulatedPowerLoss();
+ }
+ });
+ const op = await engine.accept(request());
+ await engine.settled();
+ assert.ok(tripped);
+ await new UpdateEngine(root, adapter).recover();
+ assert.equal(
+ await readFile(join(root, "data", "secret"), "utf8"),
+ "original",
+ );
+ assert.equal(
+ await readFile(
+ join(root, "updates", op.id, "failed-data", "secret"),
+ "utf8",
+ ),
+ "candidate",
+ );
+ });
+ }
+});
+test("corrupt complete snapshot fails closed and never pairs old code with candidate data", async () =>
+ fixture(async (root, adapter, _c) => {
+ const engine = new UpdateEngine(root, adapter, 1000, async (phase) => {
+ if (phase === "verifying") throw new SimulatedPowerLoss();
+ });
+ const op = await engine.accept(request());
+ await engine.settled();
+ await writeFile(
+ join(root, "updates", op.id, "snapshot", "secret"),
+ "corrupt",
+ );
+ await new UpdateEngine(root, adapter).recover();
+ assert.equal(readGate(root).mode, "manual");
+ assert.equal(
+ await realpath(join(root, "current")),
+ join(root, "releases", "0.2.0"),
+ );
+ }));
+test("enrollment policy grants only fixed-unit start/stop and pins helper outside current", () => {
+ const files = enrollmentFiles(
+ {
+ root: "/home/roost/install",
+ home: "/home/roost",
+ user: "roost",
+ uid: 1000,
+ port: 3000,
+ },
+ "0.2.0",
+ );
+ assert.match(
+ files.policy,
+ /NOPASSWD: \/usr\/bin\/systemctl start roost-1000.service, \/usr\/bin\/systemctl stop roost-1000.service/,
+ );
+ assert.doesNotMatch(files.policy, /\*/);
+ assert.match(files.unit, /releases\/0.2.0\/runtime\/node/);
+ assert.doesNotMatch(files.unit, /current/);
+ assert.match(files.dropin, /After=roost-1000-updater.service/);
+ assert.doesNotMatch(files.dropin, /EnvironmentFile|start.env/);
+});
+
+test("post-commit failure never restores data and repair only resumes the committed candidate", async () =>
+ fixture(async (root, adapter, _c) => {
+ let interrupted = false;
+ const engine = new UpdateEngine(root, adapter, 1000, async (phase) => {
+ if (phase === "admission-open" && !interrupted) {
+ interrupted = true;
+ await writeFile(join(root, "data", "secret"), "new work after commit");
+ throw new Error("lost commit acknowledgement");
+ }
+ });
+ const op = await engine.accept(request());
+ await engine.settled();
+ assert.equal((await engine.status())?.phase, "manual-recovery");
+ assert.equal((await engine.status())?.committed, true);
+ await assert.rejects(engine.repair(op.id, "restore", "0.1.40"));
+ adapter.probe = async () => {};
+ await engine.repair(op.id, "resume", "0.2.0");
+ assert.equal((await engine.status())?.phase, "succeeded");
+ assert.equal((await engine.status())?.error, undefined);
+ assert.equal(
+ await readFile(join(root, "data", "secret"), "utf8"),
+ "new work after commit",
+ );
+ }));
+
+test("concurrent UI/CLI engine owners cannot both accept or stop work", async () =>
+ fixture(async (root, adapter, control) => {
+ control.busy = true;
+ const one = new UpdateEngine(root, adapter, 10000);
+ const two = new UpdateEngine(root, adapter, 10000);
+ const first = request(),
+ second = request();
+ const results = await Promise.allSettled([
+ one.accept(first),
+ two.accept(second),
+ ]);
+ assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
+ const winner = results[0]!.status === "fulfilled" ? one : two;
+ const op = await winner.status();
+ await winner.cancel(op!.id);
+ await winner.settled();
+ assert.equal(control.stops, 0);
+ }));
+
+test("storage failures preserve a recoverable pair and never claim a commit", async () => {
+ for (const fault of [
+ "bytes",
+ "inodes",
+ "snapshot-copy",
+ "snapshot-fsync",
+ "restore-copy",
+ "restore-fsync",
+ ])
+ await fixture(async (root, adapter, control) => {
+ let injected = 0;
+ control.fail = fault.startsWith("restore");
+ const cp = fs.cp;
+ const open = fs.open;
+ const statfs = fs.statfs;
+ if (fault === "bytes" || fault === "inodes")
+ mock.method(
+ fs,
+ "statfs",
+ async (...args: Parameters) => {
+ const result = await statfs(...args);
+ injected++;
+ return {
+ ...result,
+ ...(fault === "bytes" ? { bavail: 0n } : { ffree: 0n }),
+ };
+ },
+ );
+ if (fault.endsWith("copy"))
+ mock.method(fs, "cp", async (...args: Parameters) => {
+ if (
+ String(args[1]).includes(
+ fault.startsWith("restore") ? "/restore" : "/snapshot/",
+ )
+ ) {
+ injected++;
+ throw Object.assign(new Error("Injected copy I/O failure"), {
+ code: "EIO",
+ });
+ }
+ return cp(...args);
+ });
+ if (fault.endsWith("fsync"))
+ mock.method(fs, "open", async (...args: Parameters) => {
+ const handle = await open(...args);
+ if (
+ String(args[0]).includes(
+ fault.startsWith("restore") ? "/restore/" : "/snapshot/",
+ )
+ )
+ handle.sync = async () => {
+ injected++;
+ throw Object.assign(new Error("Injected fsync I/O failure"), {
+ code: "EIO",
+ });
+ };
+ return handle;
+ });
+ syncBuiltinESMExports();
+ const engine = new UpdateEngine(root, adapter);
+ try {
+ await engine.accept(request());
+ await engine.settled();
+ assert.ok(injected > 0, `Fault must actually be reached: ${fault}`);
+ assert.equal((await engine.status())?.committed, false);
+ if (fault.startsWith("restore")) {
+ assert.equal((await engine.status())?.phase, "manual-recovery");
+ assert.equal(readGate(root).mode, "manual");
+ const operation = (await engine.status())!;
+ assert.equal(
+ await readFile(
+ join(root, "updates", operation.id, "snapshot", "secret"),
+ "utf8",
+ ),
+ "original",
+ );
+ } else {
+ assert.equal(
+ await realpath(join(root, "current")),
+ join(root, "releases", "0.1.40"),
+ );
+ assert.equal(
+ await readFile(join(root, "data", "secret"), "utf8"),
+ "original",
+ );
+ }
+ } finally {
+ mock.restoreAll();
+ syncBuiltinESMExports();
+ }
+ if (fault.startsWith("restore")) {
+ const operation = (await engine.status())!;
+ await engine.repair(operation.id, "restore", "0.1.40");
+ assert.equal((await engine.status())?.phase, "rolled-back");
+ assert.equal(
+ await readFile(join(root, "data", "secret"), "utf8"),
+ "original",
+ );
+ }
+ });
+});
+
+test("failed rollback startup holds the restored pair for explicit repair", async () =>
+ fixture(async (root, adapter) => {
+ const probe = adapter.probe;
+ adapter.probe = async () => {
+ throw new Error("Both releases fail their startup probes");
+ };
+ const engine = new UpdateEngine(root, adapter);
+ const operation = await engine.accept(request());
+ await engine.settled();
+ assert.equal((await engine.status())?.phase, "manual-recovery");
+ assert.equal(readGate(root).mode, "manual");
+ assert.equal(
+ await realpath(join(root, "current")),
+ join(root, "releases", "0.1.40"),
+ );
+ assert.equal(
+ await readFile(join(root, "data", "secret"), "utf8"),
+ "original",
+ );
+ adapter.probe = probe;
+ await engine.repair(operation.id, "restore", "0.1.40");
+ assert.equal((await engine.status())?.phase, "rolled-back");
+ }));
+
+test("interruption before durable acceptance does not acknowledge or invent an operation", async () =>
+ fixture(async (root, adapter, control) => {
+ let reached = false;
+ const engine = new UpdateEngine(root, adapter, 1000, async (phase) => {
+ if (phase === "before-journal-accepted") {
+ reached = true;
+ throw new SimulatedPowerLoss();
+ }
+ });
+ await assert.rejects(engine.accept(request()), SimulatedPowerLoss);
+ await engine.settled();
+ assert.equal(reached, true);
+ assert.equal((await engine.records()).length, 0);
+ await new UpdateEngine(root, adapter).recover();
+ assert.equal(control.stops, 0);
+ assert.equal(readGate(root).mode, "open");
+ }));
+
+test("acceptance fsync failure after rename is not acknowledged or left falsely running", async () =>
+ fixture(async (root, adapter, control) => {
+ const open = fs.open;
+ let injected = false;
+ mock.method(fs, "open", async (...args: Parameters) => {
+ const handle = await open(...args);
+ const path = String(args[0]);
+ if (!injected && /\/updates\/[a-f0-9-]{36}$/.test(path)) {
+ const sync = handle.sync.bind(handle);
+ handle.sync = async () => {
+ if (!injected) {
+ injected = true;
+ throw Object.assign(new Error("Injected directory fsync failure"), {
+ code: "EIO",
+ });
+ }
+ return sync();
+ };
+ }
+ return handle;
+ });
+ syncBuiltinESMExports();
+ try {
+ const engine = new UpdateEngine(root, adapter);
+ await assert.rejects(engine.accept(request()), /fsync failure/);
+ await engine.settled();
+ assert.equal(injected, true);
+ assert.equal((await engine.status())?.phase, "failed");
+ assert.equal(control.stops, 0);
+ assert.equal(
+ await realpath(join(root, "current")),
+ join(root, "releases", "0.1.40"),
+ );
+ } finally {
+ mock.restoreAll();
+ syncBuiltinESMExports();
+ }
+ }));
+
+test("work becoming uncertain during shutdown defers before snapshot or activation", async () =>
+ fixture(async (root, adapter, control) => {
+ const stop = adapter.stop;
+ adapter.stop = async () => {
+ await stop();
+ control.busy = true;
+ };
+ const engine = new UpdateEngine(root, adapter);
+ const operation = await engine.accept(request());
+ await engine.settled();
+ assert.equal((await engine.status())?.phase, "deferred");
+ assert.equal(control.running, true);
+ assert.equal((await engine.status())?.committed, false);
+ await assert.rejects(
+ readFile(join(root, "updates", operation.id, "snapshot.json")),
+ );
+ assert.equal(
+ await realpath(join(root, "current")),
+ join(root, "releases", "0.1.40"),
+ );
+ assert.equal(readGate(root).mode, "open");
+ }));
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 });
+ }
+});
diff --git a/tests/update-http.test.ts b/tests/update-http.test.ts
new file mode 100644
index 0000000..f7262dc
--- /dev/null
+++ b/tests/update-http.test.ts
@@ -0,0 +1,87 @@
+import assert from "node:assert/strict";
+import { mkdtemp, rm } from "node:fs/promises";
+import { test } from "node:test";
+import { AuthStore, digest } from "../src/server/auth/store.server";
+import { updatesRequest } from "../src/server/updates.server";
+
+test("HTTP update authorization rechecks a native session after reading a slow request body", async () => {
+ const directory = await mkdtemp("/tmp/roost-update-http-");
+ const previous = process.env.ROOST_DATA_DIR;
+ process.env.ROOST_DATA_DIR = directory;
+ const store = new AuthStore(directory);
+ try {
+ const origin = "https://roost.example";
+ store.setup(origin);
+ const secret = store.createSession("test-credential");
+ const cookie = `__Host-roost-session=${secret}`;
+ const status = await (
+ await updatesRequest(
+ new Request(`${origin}/api/updates`, { headers: { cookie } }),
+ )
+ ).json();
+ assert.ok(status.csrf);
+ for (const query of [
+ "?key=short",
+ `?key=${"x".repeat(101)}`,
+ "?key=abcdefghijklmnop&key=abcdefghijklmnop",
+ "?path=/etc/passwd",
+ ]) {
+ assert.equal(
+ (
+ await updatesRequest(
+ new Request(`${origin}/api/updates${query}`, {
+ headers: { cookie },
+ }),
+ )
+ ).status,
+ 400,
+ );
+ }
+ assert.equal(
+ (
+ await updatesRequest(
+ new Request(`${origin}/api/updates?key=abcdefghijklmnop`, {
+ headers: { cookie },
+ }),
+ )
+ ).status,
+ 200,
+ );
+ let controller!: ReadableStreamDefaultController;
+ const body = new ReadableStream({
+ start(c) {
+ controller = c;
+ },
+ });
+ const request = new Request(`${origin}/api/updates`, {
+ method: "POST",
+ headers: {
+ cookie,
+ origin,
+ "Content-Type": "application/json",
+ "X-Roost-CSRF": status.csrf,
+ },
+ body,
+ duplex: "half",
+ } as RequestInit);
+ const pending = updatesRequest(request);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ store.revokeSession(digest(secret));
+ controller.enqueue(new TextEncoder().encode("{}"));
+ controller.close();
+ assert.equal((await pending).status, 403);
+ assert.equal(
+ (
+ await updatesRequest(
+ new Request(`${origin}/api/updates`, { headers: { cookie } }),
+ )
+ ).status,
+ 401,
+ );
+ } finally {
+ store.close();
+ if (previous === undefined) delete process.env.ROOST_DATA_DIR;
+ else process.env.ROOST_DATA_DIR = previous;
+ await rm(directory, { recursive: true, force: true });
+ }
+});
diff --git a/tests/update-socket.test.ts b/tests/update-socket.test.ts
new file mode 100644
index 0000000..bebfe5c
--- /dev/null
+++ b/tests/update-socket.test.ts
@@ -0,0 +1,82 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import { mkdtemp, rm, stat } from "node:fs/promises";
+import { join } from "node:path";
+import { createInterface } from "node:readline";
+import { test } from "node:test";
+import { setTimeout as delay } from "node:timers/promises";
+import { updaterRequest } from "../src/updater/client";
+
+test("real private Unix transport bounds requests and routes responses through the credential-checking bridge", async () => {
+ const root = await mkdtemp("/tmp/roost-socket-");
+ const directory = join(root, "updates");
+ const { mkdir } = await import("node:fs/promises");
+ await mkdir(directory, { mode: 0o700 });
+ const child = spawn(
+ "/usr/bin/python3",
+ ["src/updater/peer-broker.py", join(directory, "helper.sock")],
+ { stdio: ["pipe", "pipe", "pipe"] },
+ );
+ const reader = createInterface({ input: child.stdout });
+ let ready!: () => void;
+ const started = new Promise((resolve) => {
+ ready = resolve;
+ });
+ reader.on("line", (line) => {
+ const frame = JSON.parse(line);
+ if (frame.ready) {
+ ready();
+ return;
+ }
+ const reply = () =>
+ child.stdin.write(
+ `${JSON.stringify({
+ id: frame.id,
+ result: {
+ value: {
+ seen: frame.message.action,
+ protocol: frame.message.protocol,
+ },
+ },
+ })}\n`,
+ );
+ if (frame.message.action === "repair") setTimeout(reply, 21000);
+ else reply();
+ });
+ try {
+ await started;
+ assert.equal(
+ (await stat(join(directory, "helper.sock"))).mode & 0o777,
+ 0o600,
+ );
+ assert.deepEqual(await updaterRequest(root, { action: "status" }), {
+ seen: "status",
+ protocol: 1,
+ });
+ await assert.rejects(
+ updaterRequest(root, { action: "status", payload: "x".repeat(9000) }),
+ /too large/,
+ );
+ // Exceeds both former 18-second broker and 20-second client limits. Status
+ // must still be observable while the same connection waits for repair.
+ const repair = updaterRequest(root, { action: "repair" });
+ await delay(100);
+ const observed = await Promise.race([
+ updaterRequest(root, { action: "status" }),
+ delay(5000).then(() => {
+ throw new Error("Status was blocked by the pending repair.");
+ }),
+ ]);
+ assert.deepEqual(observed, {
+ seen: "status",
+ protocol: 1,
+ });
+ assert.deepEqual(await repair, { seen: "repair", protocol: 1 });
+ } finally {
+ child.kill("SIGTERM");
+ await once(child, "exit");
+ reader.close();
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/tests/update-work.test.ts b/tests/update-work.test.ts
new file mode 100644
index 0000000..60f0586
--- /dev/null
+++ b/tests/update-work.test.ts
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import { mkdtemp, rm } from "node:fs/promises";
+import { test } from "node:test";
+import { codingWorkUncertain, systemdAdapter } from "../src/updater/systemd";
+
+test("coding quiescence requires fresh verified idle identity; labels never substitute", () => {
+ const now = 100000;
+ const idle = {
+ status: "review",
+ observedWorking: 1,
+ lastWorkerState: "idle",
+ sessionIdentity: "owner",
+ nativeSessionId: "session",
+ lastCheckedAt: now,
+ cancelRequested: 0,
+ };
+ assert.equal(codingWorkUncertain(idle, now), false);
+ assert.equal(
+ codingWorkUncertain({ ...idle, lastWorkerState: "done" }, now),
+ false,
+ );
+ for (const patch of [
+ { status: "blocked" },
+ { status: "running" },
+ { observedWorking: 0 },
+ { lastWorkerState: "working" },
+ { lastWorkerState: "missing" },
+ { lastWorkerState: "unknown" },
+ { lastWorkerState: null },
+ { lastCheckedAt: now - 15001 },
+ { lastCheckedAt: undefined },
+ { sessionIdentity: null },
+ { nativeSessionId: null },
+ { cancelRequested: 1 },
+ { error: "identity changed" },
+ ])
+ assert.equal(
+ codingWorkUncertain({ ...idle, ...patch }, now),
+ true,
+ JSON.stringify(patch),
+ );
+ assert.equal(
+ codingWorkUncertain(
+ { status: "blocked", lastWorkerState: "not_started" },
+ now,
+ ),
+ false,
+ );
+ assert.equal(
+ codingWorkUncertain(
+ { ...idle, status: "blocked", lastWorkerState: "not_started" },
+ now,
+ ),
+ true,
+ );
+});
+
+test("unavailable work storage defers instead of granting quiescence", async () => {
+ const root = await mkdtemp("/tmp/roost-work-unavailable-");
+ try {
+ const adapter = systemdAdapter(
+ { root, user: "test", uid: 1000, home: root, port: 12345 },
+ root,
+ );
+ assert.deepEqual(await adapter.blockers(false), [
+ "Work cannot be verified because its store is unavailable.",
+ ]);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});