From 976bb751d51377424e3ad1186c437461252c85a5 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 9 Sep 2026 01:23:32 +0000 Subject: [PATCH] Split UI updates 1/6: primitives --- src/cli/state.ts | 26 ++- src/updater/contract.ts | 127 +++++++++++ src/updater/journal.ts | 169 +++++++++++++++ src/updater/lock.ts | 68 ++++++ src/updater/process.ts | 42 ++++ src/updater/releases.ts | 217 +++++++++++++++++++ src/updater/security.ts | 52 +++++ src/updater/snapshot.ts | 245 +++++++++++++++++++++ tests/releases.test.ts | 2 +- tests/ui-updates.test.ts | 450 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 1392 insertions(+), 6 deletions(-) create mode 100644 src/updater/contract.ts create mode 100644 src/updater/journal.ts create mode 100644 src/updater/lock.ts create mode 100644 src/updater/process.ts create mode 100644 src/updater/releases.ts create mode 100644 src/updater/security.ts create mode 100644 src/updater/snapshot.ts create mode 100644 tests/ui-updates.test.ts diff --git a/src/cli/state.ts b/src/cli/state.ts index c9cc89e..958d454 100644 --- a/src/cli/state.ts +++ b/src/cli/state.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { mkdir, open, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { withKernelLock } from "../updater/lock"; export function maintenance(root: string, enabled: boolean) { const path = join(root, "data/roost.sqlite"); @@ -26,11 +27,13 @@ export function activeRuns(root: string): number { const runs = Number( db - .prepare("SELECT count(*) AS count FROM runs WHERE status='running'") + .prepare( + "SELECT count(*) AS count FROM runs WHERE status IN ('running','steering')", + ) .get()?.count ?? 0, ); - // Older installations predate coding jobs. A detached Herdr server still - // belongs to Roost's systemd cgroup and will be killed when Roost stops. + // Older installations predate coding jobs. Missing and unknown workers are + // not evidence of quiescence; preserve them for operator inspection. const hasCodingJobs = db .prepare( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='coding_jobs'", @@ -40,7 +43,7 @@ export function activeRuns(root: string): number { ? Number( db .prepare( - "SELECT count(*) AS count FROM coding_jobs WHERE status IN ('starting','running') OR (status IN ('blocked','review') AND lastWorkerState NOT IN ('not_started','missing'))", + "SELECT count(*) AS count FROM coding_jobs WHERE status IN ('starting','running') OR (status IN ('blocked','review') AND (lastWorkerState IS NULL OR lastWorkerState != 'not_started'))", ) .get()?.count ?? 0, ) @@ -51,7 +54,7 @@ export function activeRuns(root: string): number { } } -export async function withLock( +async function withLegacyLock( root: string, action: () => Promise, ): Promise { @@ -79,3 +82,16 @@ export async function withLock( export async function readJson(path: string): Promise { return JSON.parse(await readFile(path, "utf8")) as T; } + +// Keep the legacy exclusion file as well: older binaries know nothing about flock. +// Enrollment must retain a permanent sentinel before routing clients to a helper. +export async function withLock( + root: string, + action: () => Promise, +): Promise { + if (existsSync(join(root, "updater.json"))) + throw new Error( + "This installation is enrolled. Use the supervised updater; setup and legacy direct operations are fenced.", + ); + return withKernelLock(root, () => withLegacyLock(root, action)); +} diff --git a/src/updater/contract.ts b/src/updater/contract.ts new file mode 100644 index 0000000..db8dcf1 --- /dev/null +++ b/src/updater/contract.ts @@ -0,0 +1,127 @@ +/** This protocol is independent of the application release/bundle schema. */ +export const updaterProtocol = 1; +export const stableVersion = + /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})(?![\s\S])/; + +export function compareVersions(a: string, b: string) { + if (!stableVersion.test(a) || !stableVersion.test(b)) + throw new Error("Invalid stable version."); + const left = a.split(".").map(Number); + const right = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (left[i] !== right[i]) return left[i]! > right[i]! ? 1 : -1; + } + return 0; +} + +export type Compatibility = { + protocol: 1; + app: { min: number; max: number; output: number }; + auth: { min: number; max: number; output: number }; + data: "complete-snapshot-v1"; + externalState: "unchanged"; + codex: string; +}; + +export function compatibility(value: unknown): Compatibility { + const c = value as Compatibility; + const range = (r: Compatibility["app"]) => + r && + [r.min, r.max, r.output].every( + (n) => Number.isSafeInteger(n) && n >= 0 && n <= 10000, + ) && + r.min <= r.max && + r.output >= r.max; + if ( + !c || + c.protocol !== updaterProtocol || + !range(c.app) || + !range(c.auth) || + c.data !== "complete-snapshot-v1" || + c.externalState !== "unchanged" || + !stableVersion.test(c.codex) + ) + throw new Error("Release has no supported update compatibility contract."); + return c; +} + +export function assertCompatible( + value: unknown, + installed: { app: number; auth: number; codex: string }, +) { + const c = compatibility(value); + if ( + installed.app < c.app.min || + installed.app > c.app.max || + installed.auth < c.auth.min || + installed.auth > c.auth.max || + installed.codex !== c.codex + ) + throw new Error( + "Database or bundled Codex compatibility requires terminal maintenance.", + ); + return c; +} + +export type Capability = { + code: + | "externally-managed" + | "unsupported-platform" + | "unsupported-service-manager" + | "unsupported-distribution" + | "unsupported-storage" + | "setup-required" + | "qualification-required"; + reason: string; + canActivate: false; +}; + +export function capability(facts: { + packaged: boolean; + platform: string; + arch: string; + systemd: boolean; + distribution?: string; + filesystem?: string; +}): Capability { + if (!facts.packaged) + return { + code: "externally-managed", + reason: + "This source or orchestrated installation is externally managed. Update it through its deployment workflow.", + canActivate: false, + }; + if (facts.platform !== "linux" || facts.arch !== "x64") + return { + code: "unsupported-platform", + reason: "UI updates initially require a packaged Linux x64 installation.", + canActivate: false, + }; + if (!facts.systemd) + return { + code: "unsupported-service-manager", + reason: + "This installation does not have the required systemd supervisor.", + canActivate: false, + }; + if (facts.distribution && facts.distribution !== "ubuntu:24.04") + return { + code: "unsupported-distribution", + reason: + "Initial UI update qualification is limited to Ubuntu 24.04. Use operator-managed updates on this distribution.", + canActivate: false, + }; + if (facts.filesystem && facts.filesystem !== "ext4") + return { + code: "unsupported-storage", + reason: + "Initial UI updates require persistent local ext4 installation storage. This storage layout requires operator-managed updates.", + canActivate: false, + }; + return { + code: "setup-required", + reason: + "Explicit operator enrollment is required. Run roost updates enroll from the packaged terminal CLI; activation also requires a qualified helper build.", + canActivate: false, + }; +} diff --git a/src/updater/journal.ts b/src/updater/journal.ts new file mode 100644 index 0000000..7becdd3 --- /dev/null +++ b/src/updater/journal.ts @@ -0,0 +1,169 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, rename, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export const phases = [ + "accepted", + "staged", + "draining", + "stopping", + "snapshot-complete", + "activating", + "verifying", + "committed", + "restoring", + "rolled-back", + "succeeded", + "cancelled", + "deferred", + "failed", + "manual-recovery", +] as const; +export type Phase = (typeof phases)[number]; +export type Journal = { + protocol: 1; + id: string; + sequence: number; + phase: Phase; + previous: string; + candidate: string; + wasRunning: boolean; + snapshotDigest?: string; + updatedAt: number; +}; +export const operationId = + /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}(?![\s\S])/; +const version = + /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})(?![\s\S])/; + +export async function syncDirectory(path: string) { + const fd = await open(path, constants.O_RDONLY | constants.O_DIRECTORY); + try { + await fd.sync(); + } finally { + await fd.close(); + } +} + +export async function durableJson(path: string, value: unknown) { + const temporary = `${path}.${randomUUID()}.tmp`; + const fd = await open(temporary, "wx", 0o600); + try { + await fd.writeFile(`${JSON.stringify(value)}\n`); + await fd.sync(); + } finally { + await fd.close(); + } + try { + await rename(temporary, path); + await syncDirectory(dirname(path)); + } finally { + await rm(temporary, { force: true }); + } +} + +export function validateJournal(value: unknown): Journal { + const j = value as Journal; + if ( + j?.protocol !== 1 || + !operationId.test(j.id) || + !Number.isSafeInteger(j.sequence) || + j.sequence < 1 || + !phases.includes(j.phase) || + !version.test(j.previous) || + !version.test(j.candidate) || + j.previous === j.candidate || + typeof j.wasRunning !== "boolean" || + !Number.isSafeInteger(j.updatedAt) || + (j.snapshotDigest !== undefined && !/^[a-f0-9]{64}$/.test(j.snapshotDigest)) + ) + throw new Error("Invalid update journal; manual recovery required."); + if ( + [ + "snapshot-complete", + "activating", + "verifying", + "committed", + "restoring", + "rolled-back", + "succeeded", + ].includes(j.phase) && + !j.snapshotDigest + ) + throw new Error("Update journal has no complete snapshot evidence."); + return j; +} + +export async function readJournal(root: string, id: string) { + if (!operationId.test(id)) throw new Error("Invalid operation ID."); + const fd = await open( + join(root, "updates", id, "journal.json"), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + const stat = await fd.stat(); + if (!stat.isFile() || stat.size > 16384 || (stat.mode & 0o077) !== 0) + throw new Error("Unsafe update journal; manual recovery required."); + const journal = validateJournal(JSON.parse(await fd.readFile("utf8"))); + if (journal.id !== id) throw new Error("Update journal identity mismatch."); + return journal; + } finally { + await fd.close(); + } +} + +export async function writeJournal(root: string, journal: Journal) { + validateJournal(journal); + const directory = join(root, "updates", journal.id); + await mkdir(dirname(directory), { recursive: true, mode: 0o700 }); + await syncDirectory(root); + try { + await mkdir(directory, { mode: 0o700 }); + if (journal.sequence !== 1) + throw new Error("Initial journal sequence must be one."); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const previous = await readJournal(root, journal.id); + if ( + journal.sequence !== previous.sequence + 1 || + journal.previous !== previous.previous || + journal.candidate !== previous.candidate || + journal.wasRunning !== previous.wasRunning || + (previous.snapshotDigest && + journal.snapshotDigest !== previous.snapshotDigest) || + (["committed", "succeeded"].includes(previous.phase) && + !["committed", "succeeded", "manual-recovery"].includes(journal.phase)) + ) + throw new Error("Conflicting update journal transition."); + } + await syncDirectory(dirname(directory)); + await durableJson(join(directory, "journal.json"), journal); +} + +/** A recovery plan is deliberately conservative and never infers commit from a + * symlink or from an app answering health requests. Execute under kernel lock. */ +export function recoveryPlan(journal: Journal) { + validateJournal(journal); + switch (journal.phase) { + case "accepted": + case "staged": + case "draining": + return "preserve-old"; + case "stopping": + return "probe-old-behind-gate"; + case "snapshot-complete": + case "activating": + case "verifying": + case "restoring": + return "restore-matching-pair"; + case "committed": + return "finish-commit-never-restore"; + case "rolled-back": + return "finish-rollback"; + case "manual-recovery": + return "manual-recovery"; + default: + return "terminal"; + } +} diff --git a/src/updater/lock.ts b/src/updater/lock.ts new file mode 100644 index 0000000..cbfb084 --- /dev/null +++ b/src/updater/lock.ts @@ -0,0 +1,68 @@ +import { spawn } from "node:child_process"; +import { constants } from "node:fs"; +import { mkdir, open, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { durableJson } from "./journal"; + +/** flock locks the inherited open file description; the parent's fd owns it + * after flock exits. Closing that fd (including process death) releases it. + * Never unlink this file: doing so would create two independent lock domains. */ +export async function withKernelLock( + root: string, + action: () => Promise, + scope: "installation" | "supervisor" = "installation", +) { + await mkdir(root, { recursive: true, mode: 0o700 }); + const fd = await open( + join( + root, + scope === "installation" ? "updater.lock" : "updater-supervisor.lock", + ), + constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, + 0o600, + ); + try { + const info = await fd.stat(); + if ( + !info.isFile() || + info.nlink !== 1 || + info.uid !== process.getuid?.() || + (info.mode & 0o077) !== 0 + ) + throw new Error("Unsafe installation lock."); + await new Promise((resolve, reject) => { + const child = spawn("flock", ["--exclusive", "--nonblock", "3"], { + stdio: ["ignore", "ignore", "ignore", fd.fd], + }); + child.once("error", reject); + child.once("exit", (code) => + code === 0 + ? resolve() + : reject( + new Error("Another Roost installation operation is active."), + ), + ); + }); + const stat = await readFile(`/proc/${process.pid}/stat`, "utf8"); + await durableJson( + join( + root, + scope === "installation" + ? "updater-owner.json" + : "updater-supervisor-owner.json", + ), + { + pid: process.pid, + boot: ( + await readFile("/proc/sys/kernel/random/boot_id", "utf8") + ).trim(), + startTicks: stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19], + acquiredAt: Date.now(), + }, + ); + // Advisory diagnostics may remain after exit; only the kernel lock grants ownership. + return await action(); + } finally { + await fd.close(); + } +} diff --git a/src/updater/process.ts b/src/updater/process.ts new file mode 100644 index 0000000..9ba916e --- /dev/null +++ b/src/updater/process.ts @@ -0,0 +1,42 @@ +import { spawn } from "node:child_process"; +/** Fixed program/arguments supplied only by trusted adapters, never HTTP fields. */ +export function execute( + program: string, + args: string[], + timeout = 120000, + input?: string, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(program, args, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, LC_ALL: "C" }, + }); + let output = ""; + let exceeded = false; + const timer = setTimeout(() => { + exceeded = true; + child.kill("SIGKILL"); + }, timeout); + child.stdout.on("data", (chunk) => { + output += chunk; + if (output.length > 1024 * 1024) { + exceeded = true; + child.kill("SIGKILL"); + } + }); + // Never echo stderr: private download/service configuration may appear there. + child.stderr.resume(); + child.stdin.on("error", () => {}); + child.stdin.end(input); + child.once("error", (e) => { + clearTimeout(timer); + reject(e); + }); + child.once("exit", (code) => { + clearTimeout(timer); + code === 0 && !exceeded + ? resolve(output.trim()) + : reject(new Error("Updater command failed or exceeded its deadline.")); + }); + }); +} diff --git a/src/updater/releases.ts b/src/updater/releases.ts new file mode 100644 index 0000000..6e2a4ac --- /dev/null +++ b/src/updater/releases.ts @@ -0,0 +1,217 @@ +import { createHash } from "node:crypto"; +import { compareVersions, stableVersion } from "./contract"; + +export type Offer = { + id: string; + repository: string; + releaseId: number; + assetId: number; + version: string; + digest: string; + size: number; + notes: string; + checkedAt: number; + expiresAt: number; +}; +const repositoryPattern = + /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}(?![\s\S])/; +export const maxArchiveBytes = 512 * 1024 * 1024; + +export function validateRepository(repository: string) { + if ( + !repositoryPattern.test(repository) || + [".", ".."].includes(repository.split("/")[1]!) + ) + throw new Error("Invalid configured release repository."); +} + +export async function boundedBytes(response: Response, maximum: number) { + if (!response.body) throw new Error("Empty release response."); + const advertised = response.headers.get("content-length"); + if ( + advertised && + (!/^\d+$/.test(advertised) || Number(advertised) > maximum) + ) { + await response.body.cancel(); + throw new Error("Release response exceeds its size limit."); + } + const reader = response.body.getReader(); + let expired = false; + const timeout = setTimeout(() => { + expired = true; + void reader.cancel("Response read deadline exceeded.").catch(() => {}); + }, 15000); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (expired) throw new Error("Response read deadline exceeded."); + if (done) break; + size += value.length; + if (size > maximum) + throw new Error("Release response exceeds its size limit."); + chunks.push(value); + } + return Buffer.concat(chunks, size); + } finally { + clearTimeout(timeout); + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export function parseOffer( + value: unknown, + repository: string, + now: number, +): Offer { + validateRepository(repository); + const release = value as { + id: number; + tag_name: string; + draft: boolean; + prerelease: boolean; + body?: string; + assets: { + id: number; + name: string; + size: number; + digest: string; + url: string; + }[]; + }; + const version = + typeof release?.tag_name === "string" ? release.tag_name.slice(1) : ""; + if ( + release?.draft !== false || + release.prerelease !== false || + !Number.isSafeInteger(release.id) || + release.id <= 0 || + release.tag_name !== `v${version}` || + !stableVersion.test(version) || + !Array.isArray(release.assets) || + release.assets.length > 100 + ) + throw new Error("Release is not a published stable version."); + const matches = release.assets.filter( + (a) => a?.name === "roost-linux-x64.tar.gz", + ); + const asset = matches[0]; + if ( + matches.length !== 1 || + !asset || + !Number.isSafeInteger(asset.id) || + asset.id <= 0 || + !Number.isSafeInteger(asset.size) || + asset.size <= 0 || + asset.size > maxArchiveBytes || + !/^sha256:[a-f0-9]{64}$/.test(asset.digest) || + asset.url !== + `https://api.github.com/repos/${repository}/releases/assets/${asset.id}` + ) + throw new Error( + "Release has no unambiguous bounded Linux x64 artifact with a SHA-256 digest.", + ); + const identity = { + repository, + releaseId: release.id, + assetId: asset.id, + version, + digest: asset.digest, + size: asset.size, + }; + const expiresAt = now + 15 * 60_000; + return { + ...identity, + id: createHash("sha256") + .update(JSON.stringify({ ...identity, expiresAt })) + .digest("hex"), + notes: typeof release.body === "string" ? release.body.slice(0, 8000) : "", + checkedAt: now, + expiresAt, + }; +} + +/** One bounded cache per configured installation, no browser-controlled sources. + * ETags are sent only to the GitHub API; authorization never follows redirects. */ +export class ReleaseChecker { + private cache?: { offer: Offer; etag: string | null }; + private nextCheck = 0; + private pending?: Promise; + constructor( + readonly repository: string, + private readonly fetcher: typeof fetch = fetch, + private readonly token?: string, + private readonly clock = Date.now, + ) { + validateRepository(repository); + } + + get cached() { + return this.cache?.offer; + } + + check(): Promise { + if (this.pending) return this.pending; + if (this.clock() < this.nextCheck) + return Promise.reject(new Error("Wait a minute before checking again.")); + this.nextCheck = this.clock() + 60_000; + this.pending = this.lookup().finally(() => { + this.pending = undefined; + }); + return this.pending; + } + + private async lookup() { + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), + ...(this.cache?.etag && this.cache.offer.expiresAt > this.clock() + ? { "If-None-Match": this.cache.etag } + : {}), + }; + const response = await this.fetcher( + `https://api.github.com/repos/${this.repository}/releases/latest`, + { + headers, + redirect: "error", + signal: AbortSignal.timeout(15000), + }, + ); + if (response.status === 304 && this.cache) { + // Retain the pinned offer identity/expiry; a 304 does not renew authorization. + this.cache.offer = { ...this.cache.offer, checkedAt: this.clock() }; + return this.cache.offer; + } + if (!response.ok) { + await response.body?.cancel(); + throw new Error( + `Release check failed (HTTP ${response.status}). Check network and configured repository access.`, + ); + } + const value: unknown = JSON.parse( + (await boundedBytes(response, 2 * 1024 * 1024)).toString("utf8"), + ); + const offer = parseOffer(value, this.repository, this.clock()); + this.cache = { offer, etag: response.headers.get("etag") }; + return offer; + } +} + +export function assertOffer( + offer: Offer, + approvedId: string, + installedVersion: string, + now = Date.now(), +) { + if ( + offer.id !== approvedId || + offer.expiresAt <= now || + compareVersions(offer.version, installedVersion) <= 0 + ) + throw new Error( + "Offer expired, changed, or is not a newer version. Check again.", + ); +} diff --git a/src/updater/security.ts b/src/updater/security.ts new file mode 100644 index 0000000..a635a16 --- /dev/null +++ b/src/updater/security.ts @@ -0,0 +1,52 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { boundedBytes } from "./releases"; + +export function csrfToken(secret: string, session: string) { + return createHmac("sha256", secret) + .update(`roost-update-v1:${session}`) + .digest("hex"); +} + +export function authorizeMutation( + request: Request, + context: { + origin: string; + session: { id: string; created: number } | undefined; + secret: string; + now?: number; + }, +) { + const { session, origin, secret, now = Date.now() } = context; + if (!session || session.created < now - 5 * 60_000 || session.created > now) + throw new Error("Recent native passkey authentication required."); + if ( + request.method !== "POST" || + new URL(request.url).host !== new URL(origin).host || + request.headers.get("origin") !== origin || + request.headers.get("sec-fetch-site") === "cross-site" || + request.headers.get("content-type")?.split(";")[0] !== "application/json" + ) + throw new Error("Invalid update request origin or content type."); + const supplied = request.headers.get("x-roost-csrf") ?? ""; + const expected = csrfToken(secret, session.id); + if ( + !/^[a-f0-9]{64}$/.test(supplied) || + !timingSafeEqual(Buffer.from(supplied), Buffer.from(expected)) + ) + throw new Error("Invalid update CSRF token."); +} + +export async function updateBody(request: Request, allowed: readonly string[]) { + const response = new Response(request.body, { headers: request.headers }); + const value: unknown = JSON.parse( + (await boundedBytes(response, 2048)).toString("utf8"), + ); + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).some((key) => !allowed.includes(key)) + ) + throw new Error("Unexpected update fields."); + return value as Record; +} diff --git a/src/updater/snapshot.ts b/src/updater/snapshot.ts new file mode 100644 index 0000000..3d64bdf --- /dev/null +++ b/src/updater/snapshot.ts @@ -0,0 +1,245 @@ +import { createHash } from "node:crypto"; +import { + cpSync, + createReadStream, + existsSync, + mkdtempSync, + rmSync, +} from "node:fs"; +import { + chmod, + cp, + lstat, + mkdir, + open, + readdir, + readFile, + realpath, + statfs, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { durableJson, operationId, syncDirectory } from "./journal"; + +type Entry = { + path: string; + kind: "file" | "directory"; + size: number; + mode: number; + digest?: string; +}; +export type Snapshot = { + schema: 1; + entries: Entry[]; + digest: string; + bytes: number; +}; +const maximumFiles = 250000; + +async function hashFile(path: string) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} +const manifestDigest = (entries: Entry[]) => + createHash("sha256").update(JSON.stringify(entries)).digest("hex"); + +/** Complete managed data only. Initial contract refuses symlinks, hard links, + * device files, and mounts, instead of silently excluding mutable state. The + * supervisor must first prove all writers stopped; a scan is not that proof. */ +async function scan(directory: string): Promise { + const root = await realpath(directory); + if (root !== resolve(directory)) + throw new Error("Snapshot data path must be canonical."); + const rootStat = await lstat(root); + if (!rootStat.isDirectory()) + throw new Error("Snapshot data is not a directory."); + const mounts = await readFile("/proc/self/mountinfo", "utf8"); + const decode = (path: string) => + path.replace(/\\([0-7]{3})/g, (_, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)), + ); + for (const line of mounts.trim().split("\n")) { + const mount = decode(line.split(" ")[4] ?? ""); + if (mount === root || mount.startsWith(`${root}/`)) + throw new Error("Mounted data requires operator-managed backup."); + } + const entries: Entry[] = []; + let bytes = 0; + async function visit(path: string) { + const info = await lstat(path); + if ( + info.dev !== rootStat.dev || + info.isSymbolicLink() || + (!info.isDirectory() && !info.isFile()) || + (info.isFile() && info.nlink !== 1) + ) + throw new Error( + "Unsupported link, mount, or special file in managed data.", + ); + if (entries.length >= maximumFiles) + throw new Error("Snapshot file count limit exceeded."); + const entry: Entry = { + path: relative(root, path), + kind: info.isDirectory() ? "directory" : "file", + size: info.isFile() ? info.size : 0, + mode: info.mode & 0o700, + }; + if (info.isFile()) { + entry.digest = await hashFile(path); + bytes += info.size; + } + entries.push(entry); + if (info.isDirectory()) + for (const child of (await readdir(path)).sort()) + await visit(join(path, child)); + } + await visit(root); + return { schema: 1, entries, digest: manifestDigest(entries), bytes }; +} + +export async function requireHeadroom( + root: string, + bytes: number, + files: number, + staging = 0, + stagingFiles = 0, +) { + const free = await statfs(root, { bigint: true }); + if ( + ![bytes, files, staging, stagingFiles].every( + (value) => Number.isSafeInteger(value) && value >= 0, + ) + ) + throw new Error("Invalid capacity estimate."); + if ( + free.bavail * free.bsize < + BigInt(bytes) * 3n + BigInt(staging) + 128n * 1024n * 1024n || + free.ffree < BigInt(files) * 3n + BigInt(stagingFiles) + 1024n + ) + throw new Error( + "Insufficient free space or inodes for snapshot and recovery.", + ); +} + +function integrity(directory: string) { + // SQLite can alter SHM even for a read-only connection. Inspect a private copy + // so verification never changes the immutable recovery pair or its sidecars. + const temporary = mkdtempSync(join(tmpdir(), "roost-snapshot-check-")); + try { + for (const name of ["roost.sqlite", "auth.sqlite"]) { + for (const suffix of ["", "-wal", "-shm", "-journal"]) { + const source = join(directory, name + suffix); + if (existsSync(source)) cpSync(source, join(temporary, name + suffix)); + } + const path = join(temporary, name); + // Both stores are required for the native-auth update contract. + const db = new DatabaseSync(path, { readOnly: true }); + try { + if ( + db + .prepare("PRAGMA integrity_check") + .all() + .some((row) => row.integrity_check !== "ok") + ) + throw new Error("Snapshot database integrity check failed."); + } finally { + db.close(); + } + } + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} + +export async function createSnapshot(root: string, id: string) { + if (!operationId.test(id)) throw new Error("Invalid operation ID."); + const source = join(root, "data"); + const before = await scan(source); + await requireHeadroom(root, before.bytes, before.entries.length); + const directory = join(root, "updates", id); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const snapshot = join(directory, "snapshot"); + await mkdir(snapshot, { mode: 0o700 }); // Existing/partial snapshot is never reused. + for (const child of await readdir(source)) { + await cp(join(source, child), join(snapshot, child), { + recursive: true, + dereference: false, + preserveTimestamps: true, + force: false, + errorOnExist: true, + }); + } + // Copy permissions are deliberately reduced to owner-only access. Original + // owner executable bits remain, but secrets never become group/world readable. + for (const entry of before.entries) { + const target = join(snapshot, entry.path); + await chmod(target, entry.mode); + const fd = await open(target, "r"); + try { + await fd.sync(); + } finally { + await fd.close(); + } + } + integrity(snapshot); + const copied = await scan(snapshot); + const after = await scan(source); + if (copied.digest !== before.digest || after.digest !== before.digest) + throw new Error( + "Managed data changed during snapshot; writers are not quiescent.", + ); + await durableJson(join(directory, "snapshot.json"), copied); + await syncDirectory(directory); + await syncDirectory(dirname(directory)); + return copied.digest; +} + +export async function verifySnapshot( + root: string, + id: string, + expected: string, +) { + if (!operationId.test(id) || !/^[a-f0-9]{64}$/.test(expected)) + throw new Error("Invalid snapshot identity."); + const directory = join(root, "updates", id); + const fd = await open(join(directory, "snapshot.json"), "r"); + let value: Snapshot; + try { + if ((await fd.stat()).size > 64 * 1024 * 1024) + throw new Error("Snapshot manifest too large."); + value = JSON.parse(await fd.readFile("utf8")) as Snapshot; + } finally { + await fd.close(); + } + if ( + value.schema !== 1 || + value.digest !== expected || + !Array.isArray(value.entries) || + manifestDigest(value.entries) !== expected + ) + throw new Error("Snapshot manifest does not match the journal."); + integrity(join(directory, "snapshot")); + const actual = await scan(join(directory, "snapshot")); + if (actual.digest !== expected) + throw new Error("Snapshot data failed digest verification."); + return actual; +} + +export async function verifyData(directory: string, expected: string) { + integrity(directory); + if ((await scan(directory)).digest !== expected) + throw new Error("Restored data does not match complete snapshot."); +} +export async function syncTree(directory: string) { + for (const entry of (await scan(directory)).entries.reverse()) { + const fd = await open(join(directory, entry.path), "r"); + try { + await fd.sync(); + } finally { + await fd.close(); + } + } +} +export const inspectData = scan; diff --git a/tests/releases.test.ts b/tests/releases.test.ts index d51dc08..e88a055 100644 --- a/tests/releases.test.ts +++ b/tests/releases.test.ts @@ -54,7 +54,7 @@ test("update work detection includes resumable coding jobs and supports older da ["review", "idle", 1], ["queued", "unknown", 0], ["blocked", "not_started", 0], - ["blocked", "missing", 0], + ["blocked", "missing", 1], ["completed", "idle", 0], ["cancelled", "idle", 0], ["failed", "unknown", 0], diff --git a/tests/ui-updates.test.ts b/tests/ui-updates.test.ts new file mode 100644 index 0000000..b2adf6b --- /dev/null +++ b/tests/ui-updates.test.ts @@ -0,0 +1,450 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { test } from "node:test"; +import { activeRuns, withLock } from "../src/cli/state"; +import { + assertCompatible, + capability, + compareVersions, +} from "../src/updater/contract"; +import { + type Journal, + phases, + readJournal, + recoveryPlan, + writeJournal, +} from "../src/updater/journal"; +import { withKernelLock } from "../src/updater/lock"; +import { + assertOffer, + boundedBytes, + parseOffer, + ReleaseChecker, +} from "../src/updater/releases"; +import { + authorizeMutation, + csrfToken, + updateBody, +} from "../src/updater/security"; +import { + createSnapshot, + requireHeadroom, + verifySnapshot, +} from "../src/updater/snapshot"; + +const metadata = () => ({ + id: 2, + tag_name: "v0.2.0", + draft: false, + prerelease: false, + body: "", + 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 }); + } +}); +