From ee0eaabde93398eaebe3429ed1895bab28c677e5 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 14 Aug 2026 13:18:31 -0400 Subject: [PATCH 1/5] feat(media): durable versioned output history and a safe cross-platform opener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aether output open 101` could resolve to any of dozens of artifacts. The v1 index wrote `index: entries.length + 1`, and retention trims the array to 100, so the 101st generation and every one after it all claimed index 101. A parse failure returned `[]`, making a corrupt history indistinguishable from "no generations yet". The whole file was rewritten with a bare writeFileSync, so an interrupted write could lose every entry, and two Agent turns writing at once would silently drop one side. Schema v2 gives every artifact a UUID that never changes plus a persistent monotonic `sequence` alias that survives trimming, so the convenient numeric reference stays convenient without ever being reused. The counter is stored, not derived from the retained window. Writes now run one locked transaction: acquire an owner-stamped cross-process lock, read the best valid generation, allocate under the lock, validate, write a same-directory temp, fsync, stage a backup of the previous generation, rename atomically, then re-read and confirm the committed generation before reporting success. A crash at any phase leaves either the old or the new generation intact. Reads recover in order — primary, backup, rebuild from the output directory — and every degraded outcome returns a visible state (`recovered-backup`, `rebuilt`, `degraded`) with a warning the CLI prints above the results. An unreadable index is preserved as `.corrupt.` instead of being overwritten, and a document written by a newer Aether is left strictly alone. Opening moves to one argument-array implementation shared by media, `auth login` and `github connect`. The two old paths each built a shell string — vision.ts ran execSync(`${cmd} "${filepath}"`) and browser.ts spawned `cmd /c start "" ` — so a filename containing a quote, `&` or a backtick was a command-injection primitive. Targets are validated first (http/https only, no embedded credentials, files must exist), then handed to explorer.exe/open/xdg-open with shell disabled. - v1 indexes migrate on read; duplicate 101s are repaired deterministically before the document is committed, so the resolver never picks a first match - ambiguous references now report their candidates instead of guessing - 53 new tests: identity, retention, migration, four-writer cross-process concurrency, crash injection at every persistence transition, future-schema refusal, and a filename-injection regression --- src/commands/media.ts | 12 +- src/commands/output.ts | 84 ++++-- src/commands/slash_media.ts | 72 ++++-- src/core/browser.ts | 24 +- src/core/durable_store.ts | 329 ++++++++++++++++++++++++ src/core/media_history.ts | 428 +++++++++++++++++++++++++++++++ src/core/media_history_store.ts | 235 +++++++++++++++++ src/core/opener.ts | 164 ++++++++++++ src/core/vision.ts | 159 ++++++++---- test/durable_store.test.ts | 215 ++++++++++++++++ test/media_history.test.ts | 315 +++++++++++++++++++++++ test/media_history_store.test.ts | 291 +++++++++++++++++++++ test/opener.test.ts | 188 ++++++++++++++ 13 files changed, 2418 insertions(+), 98 deletions(-) create mode 100644 src/core/durable_store.ts create mode 100644 src/core/media_history.ts create mode 100644 src/core/media_history_store.ts create mode 100644 src/core/opener.ts create mode 100644 test/durable_store.test.ts create mode 100644 test/media_history.test.ts create mode 100644 test/media_history_store.test.ts create mode 100644 test/opener.test.ts diff --git a/src/commands/media.ts b/src/commands/media.ts index bbf5616..3daa5ff 100644 --- a/src/commands/media.ts +++ b/src/commands/media.ts @@ -197,16 +197,22 @@ async function mediaGenerate(ctx: AppContext, prompt: string, kind: MediaKind, f model: modelKey, prompt: vp, kind, filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags, }; - const entry = recordOutput(result); + const { entry, warning } = recordOutput(result); + if (warning) process.stderr.write(theme.dim(` ⚠ ${warning.message}\n`)); // resp.media_url and resp.text/response below are server-controlled // (an LLM generation response) — sanitized before hitting the // terminal, same as every other server-supplied string in this flow // (see sanitizeServerText's own doc comment in transport.ts). process.stdout.write( - ` ${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n` + + ` ${theme.iceBlue("↓")} #${entry.sequence} ${entry.filename}\n` + ` ${theme.dim(`url: ${sanitizeTerm(resp.media_url)}`)}\n\n` ); - if (flags.open) openOutput(entry); + if (flags.open) { + const outcome = openOutput(entry); + if (outcome.status !== "spawned") { + process.stderr.write(theme.dim(` could not open: ${outcome.detail}\n`)); + } + } } else { process.stdout.write(theme.dim(" no media URL in response\n")); process.stdout.write(theme.dim(` ${sanitizeTerm(text.slice(0, 200))}\n`)); diff --git a/src/commands/output.ts b/src/commands/output.ts index 9c6c5dd..f3888c8 100644 --- a/src/commands/output.ts +++ b/src/commands/output.ts @@ -1,21 +1,43 @@ // `aether output` — show recent 10 generations -// `aether output open ` — open generation #n +// `aether output open ` — open by sequence, artifact ID, or ID prefix // `aether output clean` — clear log (preserves files) import type { AppContext } from "../core/context.js"; -import { listOutput, findOutput, openOutput, clearOutput } from "../core/vision.js"; +import { listOutput, findOutput, openOutput, clearOutput, type OutputEntry } from "../core/vision.js"; +import { shortId } from "../core/media_history_store.js"; +import type { HistoryWarning } from "../core/media_history.js"; import { theme } from "../ui/theme.js"; import { fail } from "../core/errors.js"; export async function cmdOutput(_ctx: AppContext, argv: string[]): Promise { const sub = (argv[0] ?? "").toLowerCase(); - if (sub === "open" || sub === "o") return outputOpen(argv[1]); + if (sub === "open" || sub === "o") return outputOpen(argv.slice(1).join(" ")); if (sub === "clean" || sub === "clear") return outputClean(); return outputList(); } +/** + * A recovered or degraded read is printed before the results, not swallowed. + * The whole point of the v2 index is that a lost history looks different from + * an empty one. + */ +function writeWarning(warning: HistoryWarning | undefined): void { + if (!warning) return; + process.stderr.write(`\n ${theme.dim("⚠")} ${warning.message}\n`); + if (warning.preservedCorruptPath) { + process.stderr.write(theme.dim(` unreadable copy kept at ${warning.preservedCorruptPath}\n`)); + } + process.stderr.write("\n"); +} + +function locationOf(entry: OutputEntry): string { + if (entry.filepath) return entry.filepath; + return entry.url ? "remote only" : "location unknown"; +} + async function outputList(): Promise { - const entries = listOutput(10); + const { entries, warning } = listOutput(10); + writeWarning(warning); if (entries.length === 0) { process.stdout.write(theme.dim(" (no generations yet — run aether image/video first)\n\n")); return 0; @@ -24,30 +46,56 @@ async function outputList(): Promise { for (const e of entries) { const icon = e.kind === "video" ? "🎬" : e.kind === "3d" ? "🧊" : "🖼"; const size = (e.size_bytes / 1024 / 1024).toFixed(1); - const sp = e.prompt.length > 50 ? e.prompt.slice(0, 47) + "..." : e.prompt; + const detail = e.source === "recovered" + ? "recovered from disk — prompt and model unknown" + : `${e.model} ${size}MB ${e.prompt.length > 50 ? e.prompt.slice(0, 47) + "..." : e.prompt}`; process.stdout.write( - ` ${theme.iceBlue("#" + e.index)} ${icon} ${e.filename}\n` + - ` ${theme.dim(`${e.model} ${size}MB ${sp}`)}\n` + ` ${theme.iceBlue("#" + e.sequence)} ${icon} ${e.filename} ${theme.dim(shortId(e.artifactId))}\n` + + ` ${theme.dim(detail)}\n` + + ` ${theme.dim(locationOf(e))}\n` ); } process.stdout.write(theme.dim("\n aether output open — open in default viewer\n\n")); return 0; } -async function outputOpen(ref?: string): Promise { - if (!ref) { process.stderr.write("usage: aether output open \n"); return 2; } - const entry = findOutput(ref); - if (!entry) { process.stderr.write(`no output matching "${ref}"\n`); return 1; } +async function outputOpen(ref: string): Promise { + if (!ref.trim()) { + process.stderr.write("usage: aether output open \n"); + return 2; + } + const lookup = findOutput(ref); + writeWarning(lookup.warning); + + if (lookup.status === "ambiguous") { + process.stderr.write(`"${ref}" matches ${lookup.candidates.length} artifacts:\n`); + for (const c of lookup.candidates) { + process.stderr.write(` #${c.sequence} ${shortId(c.artifactId)} ${c.filename}\n`); + } + process.stderr.write("re-run with a sequence number or a longer artifact ID\n"); + return 1; + } + if (lookup.status === "not-found") { + process.stderr.write(`no output matching "${ref}"\n`); + return 1; + } + try { - openOutput(entry); - process.stdout.write(theme.iceBlue("→") + ` opened ${entry.filename}\n`); - return 0; + const outcome = openOutput(lookup.entry); + if (outcome.status === "spawned") { + process.stdout.write(theme.iceBlue("→") + ` opened ${lookup.entry.filename}\n`); + return 0; + } + process.stderr.write(`could not open ${lookup.entry.filename}: ${outcome.detail}\n`); + if (lookup.entry.url) process.stderr.write(theme.dim(` ${lookup.entry.url}\n`)); + return 1; } catch (err) { return fail(err); } } async function outputClean(): Promise { - const count = clearOutput(); - process.stdout.write(theme.dim(` cleared ${count} generation log entries (files preserved)\n\n`)); - return 0; + try { + const count = clearOutput(); + process.stdout.write(theme.dim(` cleared ${count} generation log entries (files preserved)\n\n`)); + return 0; + } catch (err) { return fail(err); } } - diff --git a/src/commands/slash_media.ts b/src/commands/slash_media.ts index c2883fb..ab05b5d 100644 --- a/src/commands/slash_media.ts +++ b/src/commands/slash_media.ts @@ -19,6 +19,7 @@ import { parseStoryboard, saveStoryboard, loadStoryboard, listStoryboards, type MediaKind, type GenFlags, type GenResult, type Storyboard, } from "../core/vision.js"; +import { shortId } from "../core/media_history_store.js"; // Media pipeline state — persists across turns in the same REPL session. // Cleared on REPL restart. Used by /re-frame and /re-cut. @@ -99,8 +100,9 @@ export async function photogenSlash(ctx: AppContext, out: Writable, arg: string, model: modelKey, prompt: vp, kind, filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags, }; - const entry = recordOutput(result); - out.write(` ${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); + const { entry, warning } = recordOutput(result); + if (warning) out.write(SF(` ⚠ ${warning.message}\n`)); + out.write(` ${theme.iceBlue("↓")} #${entry.sequence} ${entry.filename}\n`); out.write(` ${SF(resp.media_url)}\n\n`); _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaKind = kind; } else { @@ -120,8 +122,9 @@ export async function reframeSlash(ctx: AppContext, out: Writable, arg: string): const resp = await dispatchGeneration(ctx.api, editPrompt, "vision_gpt_image2", flags); if (resp.media_url) { const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_gpt_image2", "image", resp.filename); - const entry = recordOutput({ model: "vision_gpt_image2", prompt: editPrompt, kind: "image", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags }); - out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); + const { entry, warning } = recordOutput({ model: "vision_gpt_image2", prompt: editPrompt, kind: "image", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags }); + if (warning) out.write(SF(` ⚠ ${warning.message}\n`)); + out.write(`${theme.iceBlue("↓")} #${entry.sequence} ${entry.filename}\n`); _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_gpt_image2"; } } catch (err) { writeErr(out, err); } @@ -140,8 +143,9 @@ export async function videogenSlash(ctx: AppContext, out: Writable, arg: string, if (resp.media_url) { out.write(SF("downloading video...\n")); const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, kind, resp.filename); - const entry = recordOutput({ model: modelKey, prompt: fullPrompt, kind, filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags }); - out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n ${SF(resp.media_url)}\n\n`); + const { entry, warning } = recordOutput({ model: modelKey, prompt: fullPrompt, kind, filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags }); + if (warning) out.write(SF(` ⚠ ${warning.message}\n`)); + out.write(`${theme.iceBlue("↓")} #${entry.sequence} ${entry.filename}\n ${SF(resp.media_url)}\n\n`); _lastMediaUrl = resp.media_url; _lastMediaModel = modelKey; _lastMediaKind = kind; } } catch (err) { writeErr(out, err); } @@ -154,9 +158,17 @@ export async function animateSlash(ctx: AppContext, out: Writable, arg: string): if (!ref) { out.write("usage: /animate [motion description]\n"); return; } let refUrl = ref; if (/^#?\d+$/.test(ref)) { - const entry = findOutput(ref.replace("#", "")); - if (entry) refUrl = entry.url; - else { out.write(SF(` no output matching "${ref}"\n`)); return; } + const lookup = findOutput(ref.replace("#", "")); + if (lookup.warning) out.write(SF(` ⚠ ${lookup.warning.message}\n`)); + if (lookup.status !== "found") { + out.write(SF(` no single output matching "${ref}"\n`)); + return; + } + if (!lookup.entry.url) { + out.write(SF(` #${lookup.entry.sequence} has no remote URL to animate from\n`)); + return; + } + refUrl = lookup.entry.url; } const prompt = `Animate this image into a fluid video sequence${extraPrompt ? ": " + extraPrompt : ""}`; out.write(SF(`animating from ${refUrl.slice(0, 50)}...\n\n`)); @@ -164,8 +176,9 @@ export async function animateSlash(ctx: AppContext, out: Writable, arg: string): const resp = await dispatchGeneration(ctx.api, prompt, "vision_seedance", { model: "seedance", ref: refUrl, duration: 5 }); if (resp.media_url) { const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), "vision_seedance", "video", resp.filename); - const entry = recordOutput({ model: "vision_seedance", prompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: { model: "seedance", ref: refUrl, duration: 5 } }); - out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); + const { entry, warning } = recordOutput({ model: "vision_seedance", prompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: { model: "seedance", ref: refUrl, duration: 5 } }); + if (warning) out.write(SF(` ⚠ ${warning.message}\n`)); + out.write(`${theme.iceBlue("↓")} #${entry.sequence} ${entry.filename}\n`); _lastMediaUrl = resp.media_url; _lastMediaModel = "vision_seedance"; _lastMediaKind = "video"; } } catch (err) { writeErr(out, err); } @@ -181,8 +194,9 @@ export async function recutSlash(ctx: AppContext, out: Writable, arg: string): P const resp = await dispatchGeneration(ctx.api, editPrompt, modelKey, { model: modelKey, ref: _lastMediaUrl }); if (resp.media_url) { const filepath = await downloadMediaFile(ctx.api, resp.media_url, ensureOutputDir(), modelKey, "video", resp.filename); - const entry = recordOutput({ model: modelKey, prompt: editPrompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} }); - out.write(`${theme.iceBlue("↓")} #${entry.index} ${entry.filename}\n`); + const { entry, warning } = recordOutput({ model: modelKey, prompt: editPrompt, kind: "video", filepath, filename: basename(filepath), url: resp.media_url, timestamp: new Date().toISOString(), flags: {} }); + if (warning) out.write(SF(` ⚠ ${warning.message}\n`)); + out.write(`${theme.iceBlue("↓")} #${entry.sequence} ${entry.filename}\n`); _lastMediaUrl = resp.media_url; } } catch (err) { writeErr(out, err); } @@ -193,21 +207,37 @@ export async function outputSlash(_ctx: AppContext, out: Writable, arg: string): const sub = parts[0]?.toLowerCase(); const ref = parts.slice(1).join(" "); if (sub === "open" || sub === "o") { - if (!ref) { out.write("usage: /output open \n"); return; } - const entry = findOutput(ref); - if (!entry) { out.write(SF(` no output matching "${ref}"\n`)); return; } - try { openOutput(entry); out.write(`${theme.iceBlue("→")} opened ${entry.filename}\n`); } + if (!ref) { out.write("usage: /output open \n"); return; } + const lookup = findOutput(ref); + if (lookup.warning) out.write(SF(` ⚠ ${lookup.warning.message}\n`)); + if (lookup.status === "ambiguous") { + out.write(SF(` "${ref}" matches ${lookup.candidates.length} artifacts — use a sequence number or a longer artifact ID\n`)); + for (const c of lookup.candidates) out.write(SF(` #${c.sequence} ${shortId(c.artifactId)} ${c.filename}\n`)); + return; + } + if (lookup.status === "not-found") { out.write(SF(` no output matching "${ref}"\n`)); return; } + try { + const outcome = openOutput(lookup.entry); + if (outcome.status === "spawned") out.write(`${theme.iceBlue("→")} opened ${lookup.entry.filename}\n`); + else out.write(SF(` could not open ${lookup.entry.filename}: ${outcome.detail}\n`)); + } catch (err) { writeErr(out, err); } + return; + } + if (sub === "clean" || sub === "clear") { + try { out.write(SF(` cleared ${clearOutput()} entries\n`)); } catch (err) { writeErr(out, err); } return; } - if (sub === "clean" || sub === "clear") { out.write(SF(` cleared ${clearOutput()} entries\n`)); return; } - const entries = listOutput(10); + const { entries, warning } = listOutput(10); + if (warning) out.write(SF(` ⚠ ${warning.message}\n`)); if (!entries.length) { out.write(SF(" (no generations yet — use /photogen or /videogen)\n")); return; } out.write(`${theme.iceBlue("📦")} RECENT GENERATIONS\n\n`); for (const e of entries) { const icon = e.kind === "video" ? "🎬" : e.kind === "3d" ? "🧊" : "🖼"; - const sp = e.prompt.length > 50 ? e.prompt.slice(0, 47) + "..." : e.prompt; - out.write(` ${theme.iceBlue("#" + e.index)} ${icon} ${e.filename}\n ${SF(`${e.model} ${(e.size_bytes/1024/1024).toFixed(1)}MB ${sp}`)}\n`); + const detail = e.source === "recovered" + ? "recovered from disk — prompt and model unknown" + : `${e.model} ${(e.size_bytes/1024/1024).toFixed(1)}MB ${e.prompt.length > 50 ? e.prompt.slice(0, 47) + "..." : e.prompt}`; + out.write(` ${theme.iceBlue("#" + e.sequence)} ${icon} ${e.filename} ${SF(shortId(e.artifactId))}\n ${SF(detail)}\n`); } out.write(SF("\n /output open — open in default viewer\n\n")); } diff --git a/src/core/browser.ts b/src/core/browser.ts index f433ce0..02f56e8 100644 --- a/src/core/browser.ts +++ b/src/core/browser.ts @@ -1,19 +1,21 @@ // Open a URL in the system default browser, cross-platform. Best-effort and // non-fatal: headless boxes have no browser, so callers always print the URL // too. Shared by `auth login` and `github connect` (same web-canonical flow). +// +// The launch itself lives in opener.ts, which every URL and file open in this +// CLI now goes through — one argument-array implementation, no shell string, +// and the same code path `doctor --live` proves. This used to spawn +// `cmd /c start "" ` on Windows, which handed the URL to the command +// interpreter as a token. -import { spawn } from "node:child_process"; +import { openTarget, type OpenOutcome } from "./opener.js"; /** Open `url` in the default browser. Never throws. */ export function openBrowser(url: string): void { - try { - const cmd = - process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open"; - const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; - const child = spawn(cmd, args, { stdio: "ignore", detached: true }); - child.on("error", () => {}); - child.unref(); - } catch { - // Headless / no browser — the caller already printed the URL. - } + openTarget(url); +} + +/** Same launch, but with the outcome so a caller can report a refusal. */ +export function openBrowserChecked(url: string): OpenOutcome { + return openTarget(url); } diff --git a/src/core/durable_store.ts b/src/core/durable_store.ts new file mode 100644 index 0000000..3cb0440 --- /dev/null +++ b/src/core/durable_store.ts @@ -0,0 +1,329 @@ +// src/core/durable_store.ts — crash-safe, cross-process-safe local JSON state. +// +// The repo already writes JSON state with a same-directory `.tmp` plus +// renameSync (config.ts, goals.ts, history_store.ts, mcp_store.ts). That is +// atomic against a torn write but NOT against a lost update: two writers that +// each read generation N and each write N+1 silently drop one side, and a +// crash between "truncate the only copy" and "rename" can leave no valid +// generation at all. This module supplies the missing pieces so any index that +// concurrent Agent turns can touch gets the full transaction: +// +// lock -> read best valid -> mutate -> validate -> temp write -> flush -> +// refresh backup -> atomic rename -> readback -> unlock +// +// Nothing here is media-specific; media_history.ts is the first caller. + +import { + closeSync, + copyFileSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { hostname } from "node:os"; + +/** Owner stamp written inside the lock file so a stale lock is diagnosable. */ +export interface LockOwner { + pid: number; + host: string; + startedAt: string; + label: string; +} + +export interface LockOptions { + /** Total time to wait for a contended lock before giving up. */ + timeoutMs?: number; + /** A lock older than this whose owner is provably gone may be stolen. */ + staleMs?: number; + now?: () => number; +} + +const DEFAULT_LOCK_TIMEOUT_MS = 5_000; +const DEFAULT_LOCK_STALE_MS = 60_000; +const LOCK_POLL_MS = 25; + +/** + * Crash points a test can interrupt a transaction at. Production never sets + * these; `__setDurableFaults` is the only way in and tests always reset it. + */ +export type FaultPoint = + | "before-temp-flush" + | "after-temp-flush" + | "before-backup" + | "after-backup" + | "before-rename" + | "after-rename"; + +let faultHook: ((point: FaultPoint) => void) | null = null; + +/** Test-only. Throwing from the hook simulates a crash at that point. */ +export function __setDurableFaults(hook: ((point: FaultPoint) => void) | null): void { + faultHook = hook; +} + +function fault(point: FaultPoint): void { + if (faultHook) faultHook(point); +} + +function sleepSync(ms: number): void { + // Synchronous by design: the whole transaction is sync so a crash can never + // interleave an await between "backup refreshed" and "primary renamed". + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function processAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + // EPERM means the process exists but belongs to another user. + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function readOwner(path: string): LockOwner | null { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (typeof parsed.pid !== "number" || typeof parsed.host !== "string") return null; + return { + pid: parsed.pid, + host: parsed.host, + startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : "", + label: typeof parsed.label === "string" ? parsed.label : "", + }; + } catch { + return null; + } +} + +/** + * True when the lock is provably abandoned: same host and a dead PID, or older + * than `staleMs` regardless of host. An unparseable owner stamp is only + * stealable once it is that old — it can then only have come from a crash + * partway through writing the stamp. + */ +export function isLockStale( + owner: LockOwner | null, + ageMs: number, + staleMs = DEFAULT_LOCK_STALE_MS, +): boolean { + if (ageMs >= staleMs) return true; + if (!owner) return false; + if (owner.host !== hostname()) return false; + return !processAlive(owner.pid); +} + +/** + * Run `fn` holding an exclusive cross-process lock on `lockPath`. Always + * releases, including on throw. Throws if the lock cannot be acquired inside + * the timeout rather than proceeding unsynchronised. + */ +export function withFileLock( + lockPath: string, + label: string, + fn: () => T, + options: LockOptions = {}, +): T { + const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS); + const staleMs = Math.max(1_000, options.staleMs ?? DEFAULT_LOCK_STALE_MS); + const clock = options.now ?? ((): number => Date.now()); + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + + const deadline = clock() + timeoutMs; + let fd: number | null = null; + for (;;) { + try { + fd = openSync(lockPath, "wx", 0o600); + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + let ageMs = 0; + try { + ageMs = Math.max(0, clock() - statSync(lockPath).mtimeMs); + } catch { + // The holder released between open and stat — retry immediately. + continue; + } + if (isLockStale(readOwner(lockPath), ageMs, staleMs)) { + try { + rmSync(lockPath, { force: true }); + } catch { + // Another waiter won the steal; fall through and retry. + } + continue; + } + if (clock() >= deadline) { + const owner = readOwner(lockPath); + throw new Error( + `could not lock ${lockPath} within ${timeoutMs}ms` + + (owner ? ` (held by pid ${owner.pid} on ${owner.host})` : ""), + ); + } + sleepSync(LOCK_POLL_MS); + } + } + + try { + const owner: LockOwner = { + pid: process.pid, + host: hostname(), + startedAt: new Date(clock()).toISOString(), + label, + }; + writeSync(fd, JSON.stringify(owner)); + return fn(); + } finally { + try { + if (fd !== null) closeSync(fd); + } catch { + // Descriptor already gone; the unlink below is what matters. + } + try { + rmSync(lockPath, { force: true }); + } catch { + // A lock left behind is recoverable via isLockStale(). + } + } +} + +function fsyncPath(path: string, flags: string): void { + let fd: number | null = null; + try { + fd = openSync(path, flags); + fsyncSync(fd); + } catch { + // Directory fsync is unsupported on Windows and on some network mounts. + // The rename is still atomic there; only the ordering guarantee is weaker. + } finally { + if (fd !== null) { + try { + closeSync(fd); + } catch { + // Nothing recoverable. + } + } + } +} + +export interface AtomicWriteOptions { + /** Keep the current contents here before replacing the primary. */ + backupPath?: string | null; + mode?: number; +} + +let tmpCounter = 0; + +function baseName(path: string): string { + const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return cut < 0 ? path : path.slice(cut + 1); +} + +/** + * Replace `path` with `bytes` atomically, preserving the previous contents as + * a recoverable backup. The temp file is always a sibling of the target so the + * rename stays within one volume — a temp on another filesystem turns rename + * into copy+delete, which is not atomic. + */ +export function atomicWriteFile( + path: string, + bytes: string, + options: AtomicWriteOptions = {}, +): void { + const dir = dirname(path); + const mode = options.mode ?? 0o600; + mkdirSync(dir, { recursive: true, mode: 0o700 }); + tmpCounter += 1; + const tmp = join(dir, `.${baseName(path)}.${process.pid}.${tmpCounter}.tmp`); + + let fd: number | null = null; + try { + fd = openSync(tmp, "wx", mode); + writeSync(fd, bytes); + fault("before-temp-flush"); + fsyncSync(fd); + fault("after-temp-flush"); + } finally { + if (fd !== null) { + try { + closeSync(fd); + } catch { + // A close failure surfaces as a readback mismatch in the caller. + } + } + } + + try { + if (options.backupPath && existsSync(path)) { + fault("before-backup"); + // Stage the backup too: copying straight onto the backup path would, if + // interrupted, destroy the last known good copy while the primary is + // still the generation we are about to replace. + const backupTmp = options.backupPath + ".tmp"; + copyFileSync(path, backupTmp); + fsyncPath(backupTmp, "r+"); + renameSync(backupTmp, options.backupPath); + fault("after-backup"); + } + fault("before-rename"); + renameSync(tmp, path); + fault("after-rename"); + fsyncPath(dir, "r"); + } catch (err) { + try { + rmSync(tmp, { force: true }); + } catch { + // Leaving our own temp behind is cleaned by `doctor --fix`. + } + throw err; + } +} + +export type JsonReadFailure = "missing" | "unreadable" | "corrupt"; + +export type JsonReadResult = + | { ok: true; value: T; raw: string } + | { ok: false; reason: JsonReadFailure; detail: string }; + +/** + * Read and parse a JSON document, distinguishing "not there yet" from "there + * but broken" so callers never render a corrupt file as an empty one. + */ +export function readJsonFile(path: string): JsonReadResult { + if (!existsSync(path)) return { ok: false, reason: "missing", detail: "file does not exist" }; + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch { + return { ok: false, reason: "unreadable", detail: "file cannot be read" }; + } + if (!raw.trim()) return { ok: false, reason: "corrupt", detail: "file is empty" }; + try { + return { ok: true, value: JSON.parse(raw) as T, raw }; + } catch { + return { ok: false, reason: "corrupt", detail: "file is not valid JSON" }; + } +} + +/** + * Move an unusable file aside as `.corrupt.` so the evidence + * survives the repair. Returns the preserved path, or null when there was + * nothing to preserve. + */ +export function preserveCorrupt(path: string, now = new Date().toISOString()): string | null { + if (!existsSync(path)) return null; + const preserved = `${path}.corrupt.${now.replace(/[:.]/g, "-")}`; + try { + copyFileSync(path, preserved); + return preserved; + } catch { + return null; + } +} diff --git a/src/core/media_history.ts b/src/core/media_history.ts new file mode 100644 index 0000000..cbb559e --- /dev/null +++ b/src/core/media_history.ts @@ -0,0 +1,428 @@ +// src/core/media_history.ts — durable identity and custody for generated media. +// +// The v1 log was a bare JSON array whose `index` was `entries.length + 1`. +// Retention trims to 100, so the 101st generation, the 102nd, and every one +// after all claimed index 101 — `output open 101` resolved to whichever +// duplicate came first. A parse failure returned `[]`, so a corrupt log was +// indistinguishable from "no generations yet", and the whole file was rewritten +// with a bare writeFileSync, so an interrupted write could lose every entry. +// +// v2 gives each artifact a UUID that never changes, a persistent monotonic +// `sequence` alias that survives trimming (so `output open 285` stays +// convenient), and a visible recovery state so history can never silently +// vanish. Writes go through durable_store's locked, fsync'd, backed-up +// transaction. + +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { preserveCorrupt, readJsonFile } from "./durable_store.js"; + +export const MEDIA_HISTORY_SCHEMA_VERSION = 2; +export const MEDIA_RETENTION = 100; + +export type MediaEntryKind = "image" | "video" | "3d"; +export type MediaEntrySource = "agent-media" | "recovered"; + +export interface MediaEntry { + artifactId: string; + /** Decimal string. Monotonic, persisted, never derived from entries.length. */ + sequence: string; + createdAt: string; + kind: MediaEntryKind; + displayName: string; + filePath: string; + url: string; + model: string; + prompt: string; + sizeBytes: number; + source: MediaEntrySource; + metadata?: Record; +} + +export interface MediaHistoryDoc { + schemaVersion: number; + generation: number; + nextSequence: string; + updatedAt: string; + entries: MediaEntry[]; +} + +export type HistoryState = "ok" | "migrated" | "recovered-backup" | "rebuilt" | "degraded"; + +export interface HistoryWarning { + code: string; + message: string; + recoveredAt: string; + preservedCorruptPath?: string; +} + +export interface HistoryLoad { + doc: MediaHistoryDoc; + state: HistoryState; + warning?: HistoryWarning; +} + +export interface HistoryPaths { + outputDir: string; + primary: string; + backup: string; + lock: string; +} + +export function historyPaths(outputDir: string): HistoryPaths { + const primary = join(outputDir, ".genlog.json"); + return { outputDir, primary, backup: primary + ".bak", lock: primary + ".lock" }; +} + +// ═════════════════════════════════════════════════════════════════════ +// Validation +// ═════════════════════════════════════════════════════════════════════ + +const KINDS = new Set(["image", "video", "3d"]); + +function isDecimalString(value: unknown): value is string { + return typeof value === "string" && /^[1-9][0-9]*$/.test(value); +} + +/** Runtime shape check. Compile-time types say nothing about a file on disk. */ +export function isMediaEntry(value: unknown): value is MediaEntry { + if (value == null || typeof value !== "object" || Array.isArray(value)) return false; + const e = value as Record; + if (typeof e["artifactId"] !== "string" || !e["artifactId"]) return false; + if (!isDecimalString(e["sequence"])) return false; + if (typeof e["createdAt"] !== "string") return false; + if (!KINDS.has(e["kind"] as MediaEntryKind)) return false; + if (typeof e["displayName"] !== "string") return false; + const filePath = typeof e["filePath"] === "string" ? e["filePath"] : ""; + const url = typeof e["url"] === "string" ? e["url"] : ""; + // An entry with neither a local path nor a URL is unresolvable — it can + // never be opened, so it is not a valid record of an artifact. + if (!filePath && !url) return false; + return typeof e["sizeBytes"] === "number" && Number.isFinite(e["sizeBytes"]); +} + +/** Validate a parsed root document. Returns null when the shape is wrong. */ +export function validateDoc(value: unknown): MediaHistoryDoc | null { + if (value == null || typeof value !== "object" || Array.isArray(value)) return null; + const doc = value as Record; + if (typeof doc["schemaVersion"] !== "number") return null; + if (typeof doc["generation"] !== "number" || !Number.isFinite(doc["generation"])) return null; + if (!isDecimalString(doc["nextSequence"])) return null; + if (typeof doc["updatedAt"] !== "string") return null; + if (!Array.isArray(doc["entries"]) || !doc["entries"].every(isMediaEntry)) return null; + return doc as unknown as MediaHistoryDoc; +} + +export function emptyDoc(now: string): MediaHistoryDoc { + return { + schemaVersion: MEDIA_HISTORY_SCHEMA_VERSION, + generation: 0, + nextSequence: "1", + updatedAt: now, + entries: [], + }; +} + +export function maxSequenceOf(entries: readonly MediaEntry[]): bigint { + return entries.reduce((max, e) => { + const value = BigInt(e.sequence); + return value > max ? value : max; + }, 0n); +} + +// ═════════════════════════════════════════════════════════════════════ +// v1 migration +// ═════════════════════════════════════════════════════════════════════ + +interface LegacyEntry { + index?: unknown; + filename?: unknown; + filepath?: unknown; + model?: unknown; + prompt?: unknown; + kind?: unknown; + url?: unknown; + timestamp?: unknown; + size_bytes?: unknown; +} + +function str(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function legacyKind(value: unknown): MediaEntryKind { + return KINDS.has(value as MediaEntryKind) ? (value as MediaEntryKind) : "image"; +} + +function legacyIndexOf(row: LegacyEntry): number | null { + const index = row.index; + return typeof index === "number" && Number.isInteger(index) && index > 0 ? index : null; +} + +/** + * Rebuild a v2 document from the legacy array. Duplicate, missing, and invalid + * legacy indexes are repaired here — before the document is committed — so the + * runtime resolver never has to "pick the first match". + * + * Ordering is canonical: sort by `timestamp` when present, falling back to the + * stored array order (v1 pushed newest last) for entries without one. The sort + * is stable, so repeated migrations of the same input produce the same order + * and the same sequences. + */ +export function migrateLegacy( + legacy: readonly unknown[], + now: string, + newId: () => string = randomUUID, +): MediaHistoryDoc { + const rows = legacy + .filter((row): row is LegacyEntry => + row != null && typeof row === "object" && !Array.isArray(row)) + .map((row, position) => ({ row, position })); + + const ordered = [...rows].sort((a, b) => { + const ta = str(a.row.timestamp); + const tb = str(b.row.timestamp); + if (ta && tb && ta !== tb) return ta < tb ? -1 : 1; + return a.position - b.position; + }); + + // Only an index that is a positive integer AND unique across the whole legacy + // set may be preserved. Everything else is reallocated above the survivors, + // which is what repairs the run of duplicate 101s. + const counts = new Map(); + for (const { row } of ordered) { + const index = legacyIndexOf(row); + if (index !== null) counts.set(index, (counts.get(index) ?? 0) + 1); + } + const preserved = new Set( + [...counts.entries()].filter(([, count]) => count === 1).map(([index]) => index), + ); + let cursor = preserved.size ? BigInt(Math.max(...preserved)) : 0n; + + const entries = ordered + .map(({ row }): MediaEntry => { + const index = legacyIndexOf(row); + let sequence: string; + if (index !== null && preserved.has(index)) { + sequence = String(index); + } else { + cursor += 1n; + sequence = cursor.toString(); + } + const size = + typeof row.size_bytes === "number" && Number.isFinite(row.size_bytes) ? row.size_bytes : 0; + return { + artifactId: newId(), + sequence, + createdAt: str(row.timestamp, now), + kind: legacyKind(row.kind), + displayName: str(row.filename), + filePath: str(row.filepath), + url: str(row.url), + model: str(row.model), + prompt: str(row.prompt), + sizeBytes: size, + source: "agent-media", + metadata: index === null ? {} : { legacyIndex: index }, + }; + }) + .filter(isMediaEntry); + + return { + schemaVersion: MEDIA_HISTORY_SCHEMA_VERSION, + generation: 1, + nextSequence: (maxSequenceOf(entries) + 1n).toString(), + updatedAt: now, + entries, + }; +} + +// ═════════════════════════════════════════════════════════════════════ +// Directory rebuild (last-resort recovery) +// ═════════════════════════════════════════════════════════════════════ + +const MEDIA_EXTENSIONS = new Map([ + [".png", "image"], [".jpg", "image"], [".jpeg", "image"], [".webp", "image"], + [".svg", "image"], [".gif", "image"], + [".mp4", "video"], [".webm", "video"], [".mov", "video"], + [".glb", "3d"], [".gltf", "3d"], +]); + +/** + * Derive a stable ID from immutable file facts so re-running a rebuild over the + * same directory yields the same artifacts instead of a fresh duplicate set. + * Formatted as a UUIDv8 so every consumer can treat IDs uniformly. + */ +export function recoveryId(name: string, sizeBytes: number, mtimeMs: number): string { + const digest = createHash("sha256") + .update(`${name} ${sizeBytes} ${Math.trunc(mtimeMs)}`) + .digest("hex"); + const variant = ((parseInt(digest.slice(16, 17), 16) & 0x3) | 0x8).toString(16); + return [ + digest.slice(0, 8), + digest.slice(8, 12), + "8" + digest.slice(13, 16), + variant + digest.slice(17, 20), + digest.slice(20, 32), + ].join("-"); +} + +/** + * Recover what the filesystem still proves. Prompt and model are unknowable + * from a file on disk, so they stay empty and `source` records the uncertainty + * rather than inventing plausible values. + */ +export function rebuildFromDirectory(outputDir: string, now: string): MediaEntry[] { + if (!existsSync(outputDir)) return []; + let names: string[]; + try { + names = readdirSync(outputDir); + } catch { + return []; + } + const found: Array<{ entry: MediaEntry; sortKey: number }> = []; + for (const name of names) { + const dot = name.lastIndexOf("."); + const kind = dot < 0 ? undefined : MEDIA_EXTENSIONS.get(name.slice(dot).toLowerCase()); + if (!kind) continue; + const full = join(outputDir, name); + let stat; + try { + stat = statSync(full); + } catch { + continue; + } + if (!stat.isFile()) continue; + found.push({ + sortKey: stat.mtimeMs, + entry: { + artifactId: recoveryId(name, stat.size, stat.mtimeMs), + sequence: "1", + createdAt: new Date(stat.mtimeMs).toISOString(), + kind, + displayName: name, + filePath: full, + url: "", + model: "", + prompt: "", + sizeBytes: stat.size, + source: "recovered", + metadata: { rebuiltAt: now }, + }, + }); + } + found.sort((a, b) => + a.sortKey === b.sortKey + ? a.entry.displayName.localeCompare(b.entry.displayName) + : a.sortKey - b.sortKey, + ); + return found.map(({ entry }, i) => ({ ...entry, sequence: String(i + 1) })); +} + +// ═════════════════════════════════════════════════════════════════════ +// Load with recovery +// ═════════════════════════════════════════════════════════════════════ + +function warning( + code: string, + message: string, + now: string, + preservedCorruptPath?: string, +): HistoryWarning { + return preservedCorruptPath + ? { code, message, recoveredAt: now, preservedCorruptPath } + : { code, message, recoveredAt: now }; +} + +function docFrom(entries: MediaEntry[], now: string): MediaHistoryDoc { + return { + schemaVersion: MEDIA_HISTORY_SCHEMA_VERSION, + generation: 1, + nextSequence: (maxSequenceOf(entries) + 1n).toString(), + updatedAt: now, + entries, + }; +} + +/** + * Read the best usable generation: primary, then backup, then a rebuild from + * the output directory. Every degraded outcome carries a warning, so a caller + * can never mistake a failure for an empty history. + */ +export function loadHistory(paths: HistoryPaths, now = new Date().toISOString()): HistoryLoad { + const primary = readJsonFile(paths.primary); + + if (primary.ok) { + if (Array.isArray(primary.value)) { + return { doc: migrateLegacy(primary.value, now), state: "migrated" }; + } + const version = (primary.value as Record)["schemaVersion"]; + if (typeof version === "number" && version > MEDIA_HISTORY_SCHEMA_VERSION) { + // A newer Aether wrote this. Downgrading it to v2 would destroy fields + // this binary cannot even name, so refuse and stay read-only. + return { + doc: emptyDoc(now), + state: "degraded", + warning: warning( + "schema-too-new", + `media history schema v${version} was written by a newer Aether; this build reads up to v${MEDIA_HISTORY_SCHEMA_VERSION} and will not overwrite it`, + now, + ), + }; + } + const valid = validateDoc(primary.value); + if (valid) return { doc: valid, state: "ok" }; + } + + // Nothing written yet, and nothing to fall back to: a genuinely empty history. + if (!primary.ok && primary.reason === "missing" && !existsSync(paths.backup)) { + return { doc: emptyDoc(now), state: "ok" }; + } + + const preserved = preserveCorrupt(paths.primary, now) ?? undefined; + + const backup = readJsonFile(paths.backup); + if (backup.ok) { + const fromBackup = Array.isArray(backup.value) + ? migrateLegacy(backup.value, now) + : validateDoc(backup.value); + if (fromBackup) { + return { + doc: fromBackup, + state: "recovered-backup", + warning: warning( + "recovered-from-backup", + "media history primary was unusable; recovered the previous known-good generation", + now, + preserved, + ), + }; + } + } + + const rebuilt = rebuildFromDirectory(paths.outputDir, now); + if (rebuilt.length) { + return { + doc: docFrom(rebuilt, now), + state: "rebuilt", + warning: warning( + "rebuilt-from-files", + `media history was rebuilt from ${rebuilt.length} file(s) on disk; prompt and model could not be recovered`, + now, + preserved, + ), + }; + } + + return { + doc: emptyDoc(now), + state: "degraded", + warning: warning( + "history-lost", + "media history could not be read or rebuilt; this is not the same as an empty history", + now, + preserved, + ), + }; +} diff --git a/src/core/media_history_store.ts b/src/core/media_history_store.ts new file mode 100644 index 0000000..32202fd --- /dev/null +++ b/src/core/media_history_store.ts @@ -0,0 +1,235 @@ +// src/core/media_history_store.ts — the only writer of the media index. +// +// Everything that mutates history runs inside one locked transaction so two +// Agent turns can never allocate the same sequence or drop each other's entry. +// The transaction is deliberately synchronous end to end: an await between +// "backup refreshed" and "primary renamed" would open a window where a crash +// leaves neither file authoritative. + +import { randomUUID } from "node:crypto"; +import { atomicWriteFile, readJsonFile, withFileLock } from "./durable_store.js"; +import { + loadHistory, + MEDIA_HISTORY_SCHEMA_VERSION, + MEDIA_RETENTION, + validateDoc, + type HistoryLoad, + type HistoryPaths, + type HistoryState, + type HistoryWarning, + type MediaEntry, + type MediaEntryKind, + type MediaHistoryDoc, +} from "./media_history.js"; + +export interface AppendInput { + kind: MediaEntryKind; + displayName: string; + filePath: string; + url: string; + model: string; + prompt: string; + sizeBytes: number; + metadata?: Record; +} + +export interface AppendResult { + entry: MediaEntry; + /** Recovery state observed while reading the generation we appended to. */ + state: HistoryState; + warning?: HistoryWarning; +} + +export interface StoreOptions { + now?: string; + newId?: () => string; + lockTimeoutMs?: number; +} + +/** + * A read that returned a document this build must not overwrite. Callers get a + * thrown error rather than a silent no-op so the failure reaches the user. + */ +function assertWritable(load: HistoryLoad): void { + if (load.state === "degraded" && load.warning?.code === "schema-too-new") { + throw new Error(load.warning.message); + } +} + +function commit(paths: HistoryPaths, doc: MediaHistoryDoc): void { + const validated = validateDoc(doc); + if (!validated) throw new Error("refusing to write an invalid media history document"); + atomicWriteFile(paths.primary, JSON.stringify(validated, null, 2) + "\n", { + backupPath: paths.backup, + }); + // Readback: prove the bytes that landed parse and carry our generation + // before telling the caller the artifact was recorded. + const readback = readJsonFile(paths.primary); + const committed = readback.ok ? validateDoc(readback.value) : null; + if (!committed || committed.generation !== validated.generation) { + throw new Error("media history did not commit; the index on disk is not the generation written"); + } +} + +/** + * Record one generated artifact. Allocation and commit both happen under the + * lock, so the sequence a caller receives is the sequence that persisted. + */ +export function appendEntry( + paths: HistoryPaths, + input: AppendInput, + options: StoreOptions = {}, +): AppendResult { + const now = options.now ?? new Date().toISOString(); + const newId = options.newId ?? randomUUID; + return withFileLock( + paths.lock, + "media-history", + () => { + const load = loadHistory(paths, now); + assertWritable(load); + + const sequence = BigInt(load.doc.nextSequence); + const entry: MediaEntry = { + artifactId: newId(), + sequence: sequence.toString(), + createdAt: now, + kind: input.kind, + displayName: input.displayName, + filePath: input.filePath, + url: input.url, + model: input.model, + prompt: input.prompt, + sizeBytes: input.sizeBytes, + source: "agent-media", + ...(input.metadata ? { metadata: input.metadata } : {}), + }; + + // Trim only after allocating: retention bounds what is retained, never + // what the next reference will be. + const appended = [...load.doc.entries, entry]; + const retained = + appended.length > MEDIA_RETENTION + ? appended.slice(appended.length - MEDIA_RETENTION) + : appended; + + commit(paths, { + schemaVersion: MEDIA_HISTORY_SCHEMA_VERSION, + generation: load.doc.generation + 1, + nextSequence: (sequence + 1n).toString(), + updatedAt: now, + entries: retained, + }); + + return load.warning + ? { entry, state: load.state, warning: load.warning } + : { entry, state: load.state }; + }, + { timeoutMs: options.lockTimeoutMs }, + ); +} + +/** + * Persist a document read back in a degraded state, so the recovered + * generation becomes the primary again and the warning stops repeating. + */ +export function repersist(paths: HistoryPaths, options: StoreOptions = {}): HistoryLoad { + const now = options.now ?? new Date().toISOString(); + return withFileLock( + paths.lock, + "media-history", + () => { + const load = loadHistory(paths, now); + assertWritable(load); + commit(paths, { + ...load.doc, + schemaVersion: MEDIA_HISTORY_SCHEMA_VERSION, + generation: load.doc.generation + 1, + updatedAt: now, + }); + return load; + }, + { timeoutMs: options.lockTimeoutMs }, + ); +} + +/** Empty the index without touching the artifacts it points at. */ +export function clearHistory(paths: HistoryPaths, options: StoreOptions = {}): number { + const now = options.now ?? new Date().toISOString(); + return withFileLock( + paths.lock, + "media-history", + () => { + const load = loadHistory(paths, now); + assertWritable(load); + const cleared = load.doc.entries.length; + commit(paths, { + schemaVersion: MEDIA_HISTORY_SCHEMA_VERSION, + generation: load.doc.generation + 1, + // Clearing the list is not a licence to reissue references that + // already named a file on disk. + nextSequence: load.doc.nextSequence, + updatedAt: now, + entries: [], + }); + return cleared; + }, + { timeoutMs: options.lockTimeoutMs }, + ); +} + +// ═════════════════════════════════════════════════════════════════════ +// Reference resolution +// ═════════════════════════════════════════════════════════════════════ + +export type ResolveResult = + | { status: "found"; entry: MediaEntry } + | { status: "not-found" } + | { status: "ambiguous"; candidates: readonly MediaEntry[] }; + +const MIN_ID_PREFIX = 4; + +/** + * Resolve a user-supplied reference to exactly one artifact, or say why it + * cannot. Order is most-specific first: sequence, full artifact ID, unique ID + * prefix, then unique display name. A filename is never canonical — duplicate + * names are expected — so it only resolves when it happens to be unambiguous. + */ +export function resolveRef(entries: readonly MediaEntry[], ref: string): ResolveResult { + const needle = ref.trim(); + if (!needle) return { status: "not-found" }; + + if (/^[1-9][0-9]*$/.test(needle)) { + const bySequence = entries.filter((e) => e.sequence === needle); + if (bySequence.length === 1) return { status: "found", entry: bySequence[0]! }; + if (bySequence.length > 1) return { status: "ambiguous", candidates: bySequence }; + return { status: "not-found" }; + } + + const lower = needle.toLowerCase(); + const exactId = entries.filter((e) => e.artifactId.toLowerCase() === lower); + if (exactId.length === 1) return { status: "found", entry: exactId[0]! }; + + if (/^[0-9a-f-]+$/.test(lower) && lower.length >= MIN_ID_PREFIX) { + const byPrefix = entries.filter((e) => e.artifactId.toLowerCase().startsWith(lower)); + if (byPrefix.length === 1) return { status: "found", entry: byPrefix[0]! }; + if (byPrefix.length > 1) return { status: "ambiguous", candidates: byPrefix }; + } + + const byName = entries.filter((e) => e.displayName === needle || e.filePath === needle); + if (byName.length === 1) return { status: "found", entry: byName[0]! }; + if (byName.length > 1) return { status: "ambiguous", candidates: byName }; + + return { status: "not-found" }; +} + +/** Newest first, capped. Mirrors the v1 `listOutput` contract. */ +export function listEntries(entries: readonly MediaEntry[], limit = 10): MediaEntry[] { + const size = Math.max(0, Math.trunc(limit)); + return entries.slice(Math.max(0, entries.length - size)).reverse(); +} + +/** Short, stable display form of an artifact ID for list output. */ +export function shortId(artifactId: string): string { + return artifactId.replace(/-/g, "").slice(0, 8); +} diff --git a/src/core/opener.ts b/src/core/opener.ts new file mode 100644 index 0000000..73a36c6 --- /dev/null +++ b/src/core/opener.ts @@ -0,0 +1,164 @@ +// src/core/opener.ts — the one way this CLI hands a URL or a file to the OS. +// +// Two older call sites each built a shell string: browser.ts spawned +// `cmd /c start "" ` on Windows (url parsed as a cmd token) and +// vision.ts ran execSync(`${cmd} "${filepath}"`) — a filename containing a +// quote, `&`, or a backtick was a command-injection primitive on every +// platform. Everything here uses an executable plus an argument array with no +// shell, so the target is never re-parsed. +// +// Targets are validated before launch: URLs must be http/https, files must +// exist and be a regular file or a directory. Paths are resolved to absolute +// first, which also removes the leading-dash case (`-rf.png` would otherwise +// reach `xdg-open` as a flag). + +import { spawn } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; + +export type OpenStatus = "spawned" | "rejected" | "unavailable" | "spawn-error"; + +export interface OpenOutcome { + status: OpenStatus; + /** Present when the target passed validation and a command was selected. */ + executable?: string; + args?: readonly string[]; + detail: string; +} + +export interface OpenCommand { + executable: string; + args: readonly string[]; +} + +export interface OpenOptions { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + /** Injected in tests so no real process is launched. */ + spawnFn?: typeof spawn; +} + +const ALLOWED_URL_PROTOCOLS = new Set(["http:", "https:"]); + +/** True when `raw` is a URL this CLI is willing to hand to a browser. */ +export function isAllowedUrl(raw: string): boolean { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + if (!ALLOWED_URL_PROTOCOLS.has(parsed.protocol)) return false; + // Embedded credentials would be handed to the browser and land in history. + return !parsed.username && !parsed.password; +} + +/** + * True when `raw` is shaped like an absolute URL. The scheme-plus-slashes test + * matters on Windows: `new URL("C:\\out\\x.png")` parses happily with protocol + * "c:", so a bare drive path would otherwise be classified as a URL. + */ +export function looksLikeUrl(raw: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(raw); +} + +/** + * The executable and argument array for `target` on `platform`. Split out from + * openTarget so every platform's choice is unit-testable without spawning. + */ +export function resolveOpenCommand(target: string, platform: NodeJS.Platform): OpenCommand { + if (platform === "win32") { + // explorer.exe routes both URLs and file paths to their default handler. + // It exits non-zero on success, which is why openTarget never waits on it. + return { executable: "explorer.exe", args: [target] }; + } + if (platform === "darwin") return { executable: "open", args: [target] }; + return { executable: "xdg-open", args: [target] }; +} + +function headlessReason(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string | null { + if (platform !== "linux") return null; + if (env["DISPLAY"] || env["WAYLAND_DISPLAY"]) return null; + return "no DISPLAY or WAYLAND_DISPLAY; this session has no desktop to open into"; +} + +/** + * Validate `target` and describe how it would be opened, without launching + * anything. `doctor` uses this to report opener configuration in read-only + * mode, and openTarget uses it as its own precondition. + */ +export function planOpen(target: string, options: OpenOptions = {}): OpenOutcome { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + + if (!target || !target.trim()) { + return { status: "rejected", detail: "empty target" }; + } + + let launchTarget = target; + if (looksLikeUrl(target)) { + if (!isAllowedUrl(target)) { + return { + status: "rejected", + detail: "only http and https URLs without embedded credentials can be opened", + }; + } + } else { + const absolute = isAbsolute(target) ? target : resolve(target); + if (!existsSync(absolute)) { + return { status: "rejected", detail: "local target does not exist" }; + } + let stat; + try { + stat = statSync(absolute); + } catch { + return { status: "rejected", detail: "local target cannot be inspected" }; + } + if (!stat.isFile() && !stat.isDirectory()) { + return { status: "rejected", detail: "local target is not a regular file or directory" }; + } + launchTarget = absolute; + } + + const headless = headlessReason(platform, env); + if (headless) return { status: "unavailable", detail: headless }; + + const command = resolveOpenCommand(launchTarget, platform); + return { + status: "spawned", + executable: command.executable, + args: command.args, + detail: "ready to open", + }; +} + +/** + * Open `target` in the OS default handler. Never throws: callers always have a + * fallback (print the URL, print the path). Returns as soon as the child is + * detached — the opener outliving this process is the point, so a later + * non-zero exit is not this function's result. + */ +export function openTarget(target: string, options: OpenOptions = {}): OpenOutcome { + const plan = planOpen(target, options); + if (plan.status !== "spawned") return plan; + + const launcher = options.spawnFn ?? spawn; + try { + const child = launcher(plan.executable!, [...plan.args!], { + stdio: "ignore", + detached: true, + shell: false, + }); + // ENOENT (no xdg-open on a minimal container) arrives asynchronously. + child.on("error", () => {}); + child.unref(); + return plan; + } catch (err) { + return { + status: "spawn-error", + executable: plan.executable, + args: plan.args, + detail: err instanceof Error ? err.message : "opener could not be launched", + }; + } +} diff --git a/src/core/vision.ts b/src/core/vision.ts index 8408be7..d83f664 100644 --- a/src/core/vision.ts +++ b/src/core/vision.ts @@ -9,7 +9,21 @@ import type { CatalogItem } from "../types.js"; import { createWriteStream, mkdirSync, readdirSync, statSync, existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { pipeline } from "node:stream/promises"; -import { execSync } from "node:child_process"; +import { openTarget, type OpenOutcome } from "./opener.js"; +import { + historyPaths, + loadHistory, + type HistoryState, + type HistoryWarning, + type MediaEntry, + type MediaEntrySource, +} from "./media_history.js"; +import { + appendEntry, + clearHistory, + listEntries, + resolveRef, +} from "./media_history_store.js"; // ═════════════════════════════════════════════════════════════════════ // Types @@ -65,7 +79,10 @@ export interface GenResult { } export interface OutputEntry { - index: number; + /** Canonical identity. Never reused, never re-derived. */ + artifactId: string; + /** Human-friendly monotonic alias — what `output open ` takes. */ + sequence: string; filename: string; filepath: string; model: string; @@ -74,8 +91,27 @@ export interface OutputEntry { url: string; timestamp: string; size_bytes: number; + /** "recovered" marks an entry rebuilt from disk, with unknown prompt/model. */ + source: MediaEntrySource; +} + +export interface RecordedOutput { + entry: OutputEntry; + /** Set when the generation this appended to had to be recovered. */ + warning?: HistoryWarning; +} + +export interface OutputListing { + entries: OutputEntry[]; + state: HistoryState; + warning?: HistoryWarning; } +export type OutputLookup = + | { status: "found"; entry: OutputEntry; warning?: HistoryWarning } + | { status: "not-found"; warning?: HistoryWarning } + | { status: "ambiguous"; candidates: OutputEntry[]; warning?: HistoryWarning }; + export interface ChatGenResponse { response?: string; text?: string; media_url?: string; filename?: string; @@ -247,55 +283,88 @@ export async function downloadMediaFile( // Output manager // ═════════════════════════════════════════════════════════════════════ -const OUTPUT_LOG = join(OUTPUT_DIR, ".genlog.json"); - -function readLog(): OutputEntry[] { - if (!existsSync(OUTPUT_LOG)) return []; - try { return JSON.parse(readFileSync(OUTPUT_LOG, "utf-8")); } catch { return []; } -} - -function saveLog(entries: OutputEntry[]): void { - mkdirSync(OUTPUT_DIR, { recursive: true }); - writeFileSync(OUTPUT_LOG, JSON.stringify(entries, null, 2)); -} - -export function recordOutput(result: GenResult): OutputEntry { - const entries = readLog(); - const stat = statSync(result.filepath); - const entry: OutputEntry = { - index: entries.length + 1, - filename: result.filename, filepath: result.filepath, - model: result.model, prompt: result.prompt, kind: result.kind, - url: result.url, timestamp: result.timestamp, size_bytes: stat.size, +// Persistence, identity, recovery and locking all live in media_history*.ts. +// This section is the presentation adapter the media commands already speak. + +function paths(): ReturnType { + return historyPaths(OUTPUT_DIR); +} + +function toOutputEntry(entry: MediaEntry): OutputEntry { + return { + artifactId: entry.artifactId, + sequence: entry.sequence, + filename: entry.displayName, + filepath: entry.filePath, + model: entry.model, + prompt: entry.prompt, + kind: entry.kind, + url: entry.url, + timestamp: entry.createdAt, + size_bytes: entry.sizeBytes, + source: entry.source, }; - entries.push(entry); - if (entries.length > 100) entries.splice(0, entries.length - 100); - saveLog(entries); - return entry; -} - -export function listOutput(limit = 10): OutputEntry[] { - return readLog().slice(-limit).reverse(); -} - -export function findOutput(ref: string): OutputEntry | null { - const entries = readLog(); - const n = parseInt(ref, 10); - if (!isNaN(n)) return entries.find(e => e.index === n) ?? null; - return entries.find(e => e.filename === ref || e.filepath === ref) ?? null; } -export function openOutput(entry: OutputEntry): void { - const cmd = process.platform === "darwin" ? "open" : - process.platform === "win32" ? "start" : "xdg-open"; - execSync(`${cmd} "${entry.filepath}"`, { stdio: "ignore" }); +export function recordOutput(result: GenResult): RecordedOutput { + // A missing file is not a reason to lose the record — the URL still + // resolves the artifact, and 0 bytes reads as "size unknown". + let size = 0; + try { + size = statSync(result.filepath).size; + } catch { + size = 0; + } + const appended = appendEntry(paths(), { + kind: result.kind, + displayName: result.filename, + filePath: result.filepath, + url: result.url, + model: result.model, + prompt: result.prompt, + sizeBytes: size, + }, { now: result.timestamp }); + const entry = toOutputEntry(appended.entry); + return appended.warning ? { entry, warning: appended.warning } : { entry }; +} + +export function listOutput(limit = 10): OutputListing { + const load = loadHistory(paths()); + const entries = listEntries(load.doc.entries, limit).map(toOutputEntry); + return load.warning + ? { entries, state: load.state, warning: load.warning } + : { entries, state: load.state }; +} + +export function findOutput(ref: string): OutputLookup { + const load = loadHistory(paths()); + const resolved = resolveRef(load.doc.entries, ref); + const warning = load.warning ? { warning: load.warning } : {}; + if (resolved.status === "found") { + return { status: "found", entry: toOutputEntry(resolved.entry), ...warning }; + } + if (resolved.status === "ambiguous") { + return { status: "ambiguous", candidates: resolved.candidates.map(toOutputEntry), ...warning }; + } + return { status: "not-found", ...warning }; +} + +/** + * Hand the artifact to the OS default handler. Prefers the local file and + * falls back to the remote URL when the download is gone. Never throws — the + * outcome is the return value so callers can report a refusal precisely. + */ +export function openOutput(entry: OutputEntry): OpenOutcome { + if (entry.filepath) { + const local = openTarget(entry.filepath); + if (local.status !== "rejected") return local; + } + if (entry.url) return openTarget(entry.url); + return { status: "rejected", detail: "artifact has no local file and no URL" }; } export function clearOutput(): number { - const entries = readLog(); - const count = entries.length; - saveLog([]); - return count; + return clearHistory(paths()); } // ═════════════════════════════════════════════════════════════════════ diff --git a/test/durable_store.test.ts b/test/durable_store.test.ts new file mode 100644 index 0000000..47a191c --- /dev/null +++ b/test/durable_store.test.ts @@ -0,0 +1,215 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + __setDurableFaults, + atomicWriteFile, + isLockStale, + preserveCorrupt, + readJsonFile, + withFileLock, + type FaultPoint, +} from "../src/core/durable_store.js"; + +function sandbox(): string { + return mkdtempSync(join(tmpdir(), "aether-durable-")); +} + +function crashAt(point: FaultPoint): void { + __setDurableFaults((current) => { + if (current === point) throw new Error(`simulated crash at ${current}`); + }); +} + +test("readJsonFile separates missing from present-but-broken", () => { + const dir = sandbox(); + assert.deepEqual(readJsonFile(join(dir, "absent.json")), { + ok: false, + reason: "missing", + detail: "file does not exist", + }); + + const empty = join(dir, "empty.json"); + writeFileSync(empty, " "); + const emptyRead = readJsonFile(empty); + assert.equal(emptyRead.ok, false); + assert.equal(emptyRead.ok === false && emptyRead.reason, "corrupt"); + + const truncated = join(dir, "truncated.json"); + writeFileSync(truncated, '{"value": "synth'); + const truncatedRead = readJsonFile(truncated); + assert.equal(truncatedRead.ok === false && truncatedRead.reason, "corrupt"); + + const good = join(dir, "good.json"); + writeFileSync(good, JSON.stringify({ value: "synthetic-a" })); + const parsed = readJsonFile<{ value: string }>(good); + assert.equal(parsed.ok, true); + assert.equal(parsed.ok && parsed.value.value, "synthetic-a"); +}); + +test("atomicWriteFile keeps the previous generation recoverable", () => { + const dir = sandbox(); + const primary = join(dir, "index.json"); + const backup = primary + ".bak"; + + atomicWriteFile(primary, JSON.stringify({ value: "gen-1" }), { backupPath: backup }); + assert.equal(existsSync(backup), false, "nothing to back up on the first write"); + + atomicWriteFile(primary, JSON.stringify({ value: "gen-2" }), { backupPath: backup }); + assert.equal(JSON.parse(readFileSync(primary, "utf8")).value, "gen-2"); + assert.equal(JSON.parse(readFileSync(backup, "utf8")).value, "gen-1"); +}); + +test("a crash at any write phase leaves a valid generation behind", (t) => { + t.after(() => __setDurableFaults(null)); + + const points: FaultPoint[] = [ + "before-temp-flush", + "after-temp-flush", + "before-backup", + "after-backup", + "before-rename", + ]; + + for (const point of points) { + const dir = sandbox(); + const primary = join(dir, "index.json"); + const backup = primary + ".bak"; + __setDurableFaults(null); + atomicWriteFile(primary, JSON.stringify({ value: "gen-1" }), { backupPath: backup }); + + crashAt(point); + assert.throws( + () => atomicWriteFile(primary, JSON.stringify({ value: "gen-2" }), { backupPath: backup }), + /simulated crash/, + `expected the write to abort at ${point}`, + ); + __setDurableFaults(null); + + // Either generation may be current, but one of them must parse. + const recovered = readJsonFile<{ value: string }>(primary); + assert.equal(recovered.ok, true, `primary unreadable after crash at ${point}`); + assert.match(recovered.ok ? recovered.value.value : "", /^gen-[12]$/); + } +}); + +test("a crash after the rename still leaves the new generation committed", (t) => { + t.after(() => __setDurableFaults(null)); + const dir = sandbox(); + const primary = join(dir, "index.json"); + const backup = primary + ".bak"; + atomicWriteFile(primary, JSON.stringify({ value: "gen-1" }), { backupPath: backup }); + + crashAt("after-rename"); + assert.throws(() => + atomicWriteFile(primary, JSON.stringify({ value: "gen-2" }), { backupPath: backup }), + ); + __setDurableFaults(null); + + assert.equal(JSON.parse(readFileSync(primary, "utf8")).value, "gen-2"); + assert.equal(JSON.parse(readFileSync(backup, "utf8")).value, "gen-1"); +}); + +test("an aborted write does not leave its own temp file behind", (t) => { + t.after(() => __setDurableFaults(null)); + const dir = sandbox(); + const primary = join(dir, "index.json"); + atomicWriteFile(primary, JSON.stringify({ value: "gen-1" })); + + crashAt("before-rename"); + assert.throws(() => atomicWriteFile(primary, JSON.stringify({ value: "gen-2" }))); + __setDurableFaults(null); + + assert.deepEqual(readdirSync(dir).filter((name) => name.endsWith(".tmp")), []); +}); + +test("the temp file is always a sibling of the target", (t) => { + t.after(() => __setDurableFaults(null)); + // A temp on another volume turns rename into copy+delete, which is not + // atomic. Assert the invariant by watching the directory during the write. + const dir = sandbox(); + let seen: string[] = []; + __setDurableFaults((point) => { + if (point === "before-rename") seen = readdirSync(dir); + }); + atomicWriteFile(join(dir, "index.json"), JSON.stringify({ value: "gen-1" })); + __setDurableFaults(null); + assert.equal(seen.some((name) => name.endsWith(".tmp")), true); +}); + +test("withFileLock serialises callers and always releases", () => { + const dir = sandbox(); + const lock = join(dir, "index.json.lock"); + + assert.equal(withFileLock(lock, "test", () => "held"), "held"); + assert.equal(existsSync(lock), false, "lock released on success"); + + assert.throws(() => + withFileLock(lock, "test", () => { + throw new Error("body failed"); + }), + ); + assert.equal(existsSync(lock), false, "lock released on throw"); +}); + +test("a live lock is respected until the timeout, then reported with its owner", () => { + const dir = sandbox(); + const lock = join(dir, "index.json.lock"); + // A live owner: this very process. + writeFileSync( + lock, + JSON.stringify({ + pid: process.pid, + host: hostname(), + startedAt: "2020-01-01T00:00:00.000Z", + label: "test", + }), + ); + assert.throws( + () => withFileLock(lock, "test", () => "never", { timeoutMs: 60 }), + (err: Error) => + err.message.includes("could not lock") && err.message.includes(String(process.pid)), + ); +}); + +test("a lock owned by a dead process on this host is stealable", () => { + const dir = sandbox(); + const lock = join(dir, "index.json.lock"); + writeFileSync( + lock, + JSON.stringify({ + pid: 999999, + host: hostname(), + startedAt: "2020-01-01T00:00:00.000Z", + label: "test", + }), + ); + assert.equal(withFileLock(lock, "test", () => "stolen", { timeoutMs: 200 }), "stolen"); +}); + +test("isLockStale never steals a fresh lock held elsewhere", () => { + const foreign = { + pid: 1, + host: "SYNTHETIC-HOST", + startedAt: "2020-01-01T00:00:00.000Z", + label: "test", + }; + assert.equal(isLockStale(foreign, 1_000, 60_000), false); + assert.equal(isLockStale(foreign, 90_000, 60_000), true); + // An unparseable stamp is only stealable once it is provably old. + assert.equal(isLockStale(null, 1_000, 60_000), false); + assert.equal(isLockStale(null, 90_000, 60_000), true); +}); + +test("preserveCorrupt keeps the evidence instead of overwriting it", () => { + const dir = sandbox(); + const primary = join(dir, "index.json"); + writeFileSync(primary, '{"value": "synth'); + const preserved = preserveCorrupt(primary, "2026-08-14T19:42:08.194Z"); + assert.ok(preserved); + assert.match(preserved, /index\.json\.corrupt\.2026-08-14T19-42-08-194Z$/); + assert.equal(readFileSync(preserved, "utf8"), '{"value": "synth'); + assert.equal(preserveCorrupt(join(dir, "absent.json")), null); +}); diff --git a/test/media_history.test.ts b/test/media_history.test.ts new file mode 100644 index 0000000..9a7bf9b --- /dev/null +++ b/test/media_history.test.ts @@ -0,0 +1,315 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + emptyDoc, + historyPaths, + isMediaEntry, + loadHistory, + MEDIA_HISTORY_SCHEMA_VERSION, + migrateLegacy, + rebuildFromDirectory, + recoveryId, + validateDoc, + type MediaEntry, +} from "../src/core/media_history.js"; + +const NOW = "2026-08-14T19:42:08.194Z"; + +function sandbox(): ReturnType { + return historyPaths(mkdtempSync(join(tmpdir(), "aether-media-"))); +} + +/** Deterministic ID factory so migration assertions stay stable. */ +function counterIds(): () => string { + let n = 0; + return () => { + n += 1; + return `0198f4c2-0000-8000-8000-${String(n).padStart(12, "0")}`; + }; +} + +function legacyRow(over: Record = {}): Record { + return { + index: 1, + filename: "a.png", + filepath: "/synthetic/a.png", + model: "vision_nano_pro", + prompt: "synthetic", + kind: "image", + url: "https://example.invalid/a.png", + timestamp: "2026-08-14T19:41:57.002Z", + size_bytes: 1024, + ...over, + }; +} + +function entry(over: Partial = {}): MediaEntry { + return { + artifactId: "0198f4c2-0000-8000-8000-000000000001", + sequence: "1", + createdAt: "2026-08-14T19:41:57.002Z", + kind: "image", + displayName: "a.png", + filePath: "/synthetic/a.png", + url: "https://example.invalid/a.png", + model: "vision_nano_pro", + prompt: "synthetic", + sizeBytes: 1024, + source: "agent-media", + ...over, + }; +} + +// ── validation ────────────────────────────────────────────────────── + +test("an entry with neither a local path nor a URL is not a valid record", () => { + assert.equal(isMediaEntry(entry()), true); + assert.equal(isMediaEntry(entry({ filePath: "", url: "" })), false); + assert.equal(isMediaEntry(entry({ filePath: "", url: "https://example.invalid/a" })), true); +}); + +test("sequence must be a positive decimal string, not a number and not zero", () => { + assert.equal(isMediaEntry(entry({ sequence: 1 as unknown as string })), false); + assert.equal(isMediaEntry(entry({ sequence: "0" })), false); + assert.equal(isMediaEntry(entry({ sequence: "01" })), false); + assert.equal(isMediaEntry(entry({ sequence: "285" })), true); +}); + +test("validateDoc rejects a root that is missing the persistent counter", () => { + const good = { ...emptyDoc(NOW), entries: [entry()], nextSequence: "2" }; + assert.ok(validateDoc(good)); + assert.equal(validateDoc({ ...good, nextSequence: undefined }), null); + assert.equal(validateDoc({ ...good, generation: "1" }), null); + assert.equal(validateDoc([entry()]), null, "a bare array is v1, not a v2 root"); +}); + +// ── migration ─────────────────────────────────────────────────────── + +test("a run of duplicate 101s migrates to unique, deterministic sequences", () => { + // Exactly the shape the v1 bug produced: retention trimmed to 100, so every + // generation after the 100th was written with index = entries.length + 1. + const legacy = Array.from({ length: 5 }, (_unused, i) => + legacyRow({ + index: 101, + filename: `dup-${i}.png`, + timestamp: `2026-08-14T19:4${i}:00.000Z`, + }), + ); + + const first = migrateLegacy(legacy, NOW, counterIds()); + const sequences = first.entries.map((e) => e.sequence); + assert.equal(new Set(sequences).size, 5, "no two entries share a sequence"); + assert.deepEqual(sequences, ["1", "2", "3", "4", "5"]); + // Chronological order is preserved, so #1 is the oldest duplicate. + assert.deepEqual(first.entries.map((e) => e.displayName), [ + "dup-0.png", "dup-1.png", "dup-2.png", "dup-3.png", "dup-4.png", + ]); + assert.equal(first.nextSequence, "6"); + + const second = migrateLegacy(legacy, NOW, counterIds()); + assert.deepEqual( + second.entries.map((e) => [e.sequence, e.displayName]), + first.entries.map((e) => [e.sequence, e.displayName]), + "repeated migrations of the same input are deterministic", + ); +}); + +test("unique legacy indexes are preserved and duplicates are reallocated above them", () => { + const legacy = [ + legacyRow({ index: 7, filename: "keep-7.png", timestamp: "2026-08-14T19:40:00.000Z" }), + legacyRow({ index: 101, filename: "dup-a.png", timestamp: "2026-08-14T19:41:00.000Z" }), + legacyRow({ index: 101, filename: "dup-b.png", timestamp: "2026-08-14T19:42:00.000Z" }), + legacyRow({ index: 12, filename: "keep-12.png", timestamp: "2026-08-14T19:43:00.000Z" }), + ]; + const doc = migrateLegacy(legacy, NOW, counterIds()); + const bySequence = Object.fromEntries(doc.entries.map((e) => [e.displayName, e.sequence])); + assert.equal(bySequence["keep-7.png"], "7"); + assert.equal(bySequence["keep-12.png"], "12"); + // Reallocated above the highest preserved index (12), in chronological order. + assert.equal(bySequence["dup-a.png"], "13"); + assert.equal(bySequence["dup-b.png"], "14"); + assert.equal(doc.nextSequence, "15"); +}); + +test("missing, zero, negative and non-integer legacy indexes are reallocated", () => { + const legacy = [ + legacyRow({ index: undefined, filename: "none.png", timestamp: "2026-08-14T19:40:00.000Z" }), + legacyRow({ index: 0, filename: "zero.png", timestamp: "2026-08-14T19:41:00.000Z" }), + legacyRow({ index: -3, filename: "neg.png", timestamp: "2026-08-14T19:42:00.000Z" }), + legacyRow({ index: 1.5, filename: "frac.png", timestamp: "2026-08-14T19:43:00.000Z" }), + ]; + const doc = migrateLegacy(legacy, NOW, counterIds()); + assert.deepEqual(doc.entries.map((e) => e.sequence), ["1", "2", "3", "4"]); + assert.equal(new Set(doc.entries.map((e) => e.artifactId)).size, 4); +}); + +test("entries without a timestamp keep their stored order", () => { + const legacy = [ + legacyRow({ index: 101, filename: "first.png", timestamp: undefined }), + legacyRow({ index: 101, filename: "second.png", timestamp: undefined }), + ]; + const doc = migrateLegacy(legacy, NOW, counterIds()); + assert.deepEqual(doc.entries.map((e) => e.displayName), ["first.png", "second.png"]); +}); + +test("unresolvable legacy rows are dropped rather than migrated into broken entries", () => { + const legacy = [ + legacyRow({ index: 1, filepath: "", url: "" }), + legacyRow({ index: 2, filename: "ok.png" }), + "not an object", + null, + ]; + const doc = migrateLegacy(legacy, NOW, counterIds()); + assert.deepEqual(doc.entries.map((e) => e.displayName), ["ok.png"]); +}); + +// ── directory rebuild ─────────────────────────────────────────────── + +test("rebuildFromDirectory recovers media files and marks the uncertainty", () => { + const paths = sandbox(); + writeFileSync(join(paths.outputDir, "a.png"), "aa"); + writeFileSync(join(paths.outputDir, "b.mp4"), "bbb"); + writeFileSync(join(paths.outputDir, "notes.txt"), "ignored"); + mkdirSync(join(paths.outputDir, "storyboards")); + + const rebuilt = rebuildFromDirectory(paths.outputDir, NOW); + assert.deepEqual(rebuilt.map((e) => e.displayName).sort(), ["a.png", "b.mp4"]); + for (const e of rebuilt) { + assert.equal(e.source, "recovered"); + // Prompt and model are unknowable from a file on disk; they stay empty + // rather than being invented. + assert.equal(e.prompt, ""); + assert.equal(e.model, ""); + assert.equal(isMediaEntry(e), true); + } +}); + +test("rebuilding twice over the same files yields the same artifact IDs", () => { + const paths = sandbox(); + const file = join(paths.outputDir, "a.png"); + writeFileSync(file, "aa"); + // Pin mtime so the two runs see identical inputs. + const pinned = new Date("2026-08-14T19:40:00.000Z"); + utimesSync(file, pinned, pinned); + + const first = rebuildFromDirectory(paths.outputDir, NOW); + const second = rebuildFromDirectory(paths.outputDir, "2026-08-15T00:00:00.000Z"); + assert.deepEqual( + first.map((e) => e.artifactId), + second.map((e) => e.artifactId), + "a repeated rebuild must not mint a fresh duplicate set", + ); +}); + +test("recoveryId is a stable UUID-shaped value derived from file facts", () => { + const a = recoveryId("a.png", 1024, 1_700_000_000_000); + assert.equal(a, recoveryId("a.png", 1024, 1_700_000_000_000)); + assert.notEqual(a, recoveryId("a.png", 1025, 1_700_000_000_000)); + assert.match(a, /^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); +}); + +// ── load and recovery ─────────────────────────────────────────────── + +test("a missing index with no backup is a genuinely empty history", () => { + const load = loadHistory(sandbox(), NOW); + assert.equal(load.state, "ok"); + assert.equal(load.warning, undefined); + assert.deepEqual(load.doc.entries, []); + assert.equal(load.doc.nextSequence, "1"); +}); + +test("a v1 array on disk loads as migrated", () => { + const paths = sandbox(); + writeFileSync( + paths.primary, + JSON.stringify([legacyRow({ index: 101 }), legacyRow({ index: 101, filename: "b.png" })]), + ); + const load = loadHistory(paths, NOW); + assert.equal(load.state, "migrated"); + assert.equal(new Set(load.doc.entries.map((e) => e.sequence)).size, 2); +}); + +test("a corrupt primary recovers from the backup with a visible warning", () => { + const paths = sandbox(); + writeFileSync(paths.primary, '{"schemaVersion": 2, "entr'); + writeFileSync( + paths.backup, + JSON.stringify({ ...emptyDoc(NOW), entries: [entry()], nextSequence: "2", generation: 3 }), + ); + + const load = loadHistory(paths, NOW); + assert.equal(load.state, "recovered-backup"); + assert.equal(load.warning?.code, "recovered-from-backup"); + assert.match(String(load.warning?.preservedCorruptPath), /\.corrupt\./); + assert.equal(load.doc.entries.length, 1); +}); + +test("a corrupt primary and no usable backup falls back to a file rebuild", () => { + const paths = sandbox(); + writeFileSync(paths.primary, "not json at all"); + writeFileSync(paths.backup, "also not json"); + writeFileSync(join(paths.outputDir, "a.png"), "aa"); + + const load = loadHistory(paths, NOW); + assert.equal(load.state, "rebuilt"); + assert.equal(load.warning?.code, "rebuilt-from-files"); + assert.equal(load.doc.entries.length, 1); + assert.equal(load.doc.entries[0]?.source, "recovered"); +}); + +test("an unrecoverable history reports degraded, never a silent empty list", () => { + const paths = sandbox(); + // The v1 behaviour — `catch { return [] }` — made this indistinguishable + // from "no generations yet". + writeFileSync(paths.primary, "not json at all"); + + const load = loadHistory(paths, NOW); + assert.equal(load.state, "degraded"); + assert.equal(load.warning?.code, "history-lost"); + assert.deepEqual(load.doc.entries, []); + assert.match(String(load.warning?.message), /not the same as an empty history/); +}); + +test("valid JSON with an invalid v2 shape is treated as corrupt, not as data", () => { + const paths = sandbox(); + writeFileSync( + paths.primary, + JSON.stringify({ + schemaVersion: 2, + generation: 1, + nextSequence: "2", + updatedAt: NOW, + entries: [{ nope: true }], + }), + ); + const load = loadHistory(paths, NOW); + assert.equal(load.state, "degraded"); + assert.ok(load.warning?.preservedCorruptPath); +}); + +test("a future schema is preserved read-only and never rewritten", () => { + const paths = sandbox(); + writeFileSync( + paths.primary, + JSON.stringify({ + schemaVersion: MEDIA_HISTORY_SCHEMA_VERSION + 1, + generation: 9, + nextSequence: "500", + updatedAt: NOW, + entries: [], + unknownFutureField: "keep me", + }), + ); + + const load = loadHistory(paths, NOW); + assert.equal(load.state, "degraded"); + assert.equal(load.warning?.code, "schema-too-new"); + assert.equal( + load.warning?.preservedCorruptPath, + undefined, + "a newer file is not corrupt evidence", + ); +}); diff --git a/test/media_history_store.test.ts b/test/media_history_store.test.ts new file mode 100644 index 0000000..a7a8624 --- /dev/null +++ b/test/media_history_store.test.ts @@ -0,0 +1,291 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { __setDurableFaults, type FaultPoint } from "../src/core/durable_store.js"; +import { + historyPaths, + loadHistory, + MEDIA_HISTORY_SCHEMA_VERSION, + MEDIA_RETENTION, + validateDoc, + type MediaEntry, +} from "../src/core/media_history.js"; +import { + appendEntry, + clearHistory, + listEntries, + repersist, + resolveRef, + shortId, + type AppendInput, +} from "../src/core/media_history_store.js"; + +const NOW = "2026-08-14T19:42:08.194Z"; + +function sandbox(): ReturnType { + return historyPaths(mkdtempSync(join(tmpdir(), "aether-store-"))); +} + +function input(over: Partial = {}): AppendInput { + return { + kind: "image", + displayName: "a.png", + filePath: "/synthetic/a.png", + url: "https://example.invalid/a.png", + model: "vision_nano_pro", + prompt: "synthetic", + sizeBytes: 1024, + ...over, + }; +} + +function entryAt(sequence: string, over: Partial = {}): MediaEntry { + return { + artifactId: `0198f4c2-0000-8000-8000-${sequence.padStart(12, "0")}`, + sequence, + createdAt: NOW, + kind: "image", + displayName: `a-${sequence}.png`, + filePath: `/synthetic/a-${sequence}.png`, + url: "https://example.invalid/a.png", + model: "vision_nano_pro", + prompt: "synthetic", + sizeBytes: 1024, + source: "agent-media", + ...over, + }; +} + +function found(result: ReturnType): MediaEntry { + assert.equal(result.status, "found"); + return (result as { entry: MediaEntry }).entry; +} + +// ── identity and retention ────────────────────────────────────────── + +test("250 generations at retention 100 never reissue a reference", () => { + const paths = sandbox(); + const sequences: string[] = []; + const ids: string[] = []; + for (let i = 0; i < 250; i++) { + const { entry } = appendEntry(paths, input({ displayName: `a-${i}.png` }), { now: NOW }); + sequences.push(entry.sequence); + ids.push(entry.artifactId); + } + + assert.equal(new Set(sequences).size, 250, "every sequence issued was unique"); + assert.equal(new Set(ids).size, 250, "every artifact ID issued was unique"); + // The v1 bug: index = entries.length + 1, so #101 onwards all collided. + assert.equal(sequences[100], "101"); + assert.equal(sequences[101], "102"); + assert.equal(sequences[249], "250"); + + const load = loadHistory(paths, NOW); + assert.equal(load.state, "ok"); + assert.equal(load.doc.entries.length, MEDIA_RETENTION, "retention still bounds the window"); + assert.equal(load.doc.nextSequence, "251", "the counter survived every trim"); + assert.equal(load.doc.entries[0]?.sequence, "151"); +}); + +test("trimming never lowers the persistent counter", () => { + const paths = sandbox(); + for (let i = 0; i < 120; i++) appendEntry(paths, input(), { now: NOW }); + const before = loadHistory(paths, NOW).doc; + assert.equal(before.entries.length, MEDIA_RETENTION); + assert.equal(before.nextSequence, "121"); + + assert.equal(appendEntry(paths, input(), { now: NOW }).entry.sequence, "121"); +}); + +test("clearing the list does not reissue references that already named a file", () => { + const paths = sandbox(); + for (let i = 0; i < 5; i++) appendEntry(paths, input(), { now: NOW }); + assert.equal(clearHistory(paths, { now: NOW }), 5); + assert.equal(appendEntry(paths, input(), { now: NOW }).entry.sequence, "6"); +}); + +test("each commit advances the generation and the readback proves it landed", () => { + const paths = sandbox(); + appendEntry(paths, input(), { now: NOW }); + appendEntry(paths, input(), { now: NOW }); + const doc = validateDoc(JSON.parse(readFileSync(paths.primary, "utf8"))); + assert.ok(doc); + assert.equal(doc.generation, 2); + assert.equal(doc.schemaVersion, MEDIA_HISTORY_SCHEMA_VERSION); +}); + +test("the backup holds the previous generation after the second write", () => { + const paths = sandbox(); + appendEntry(paths, input({ displayName: "first.png" }), { now: NOW }); + appendEntry(paths, input({ displayName: "second.png" }), { now: NOW }); + + const backup = validateDoc(JSON.parse(readFileSync(paths.backup, "utf8"))); + assert.ok(backup); + assert.equal(backup.generation, 1); + assert.deepEqual(backup.entries.map((e) => e.displayName), ["first.png"]); +}); + +// ── concurrency ───────────────────────────────────────────────────── + +test("concurrent writer processes cannot duplicate or lose an entry", () => { + const paths = sandbox(); + const storeUrl = new URL("../src/core/media_history_store.js", import.meta.url).href; + const historyUrl = new URL("../src/core/media_history.js", import.meta.url).href; + // Only meaningful against the built output; the source tree has no .js. + if (!existsSync(fileURLToPath(storeUrl))) return; + + const script = ` + const { appendEntry } = await import(${JSON.stringify(storeUrl)}); + const { historyPaths } = await import(${JSON.stringify(historyUrl)}); + const paths = historyPaths(process.argv[1]); + const tag = process.argv[2]; + for (let i = 0; i < 8; i++) { + appendEntry(paths, { + kind: "image", displayName: tag + "-" + i, filePath: "/synthetic/" + tag + "-" + i, + url: "https://example.invalid/a.png", model: "vision_nano_pro", + prompt: "synthetic", sizeBytes: 1024, + }, { lockTimeoutMs: 30000 }); + } + `; + + const WRITERS = 4; + for (let i = 0; i < WRITERS; i++) { + execFileSync( + process.execPath, + ["--input-type=module", "-e", script, paths.outputDir, `w${i}`], + { encoding: "utf8", timeout: 60_000 }, + ); + } + + const load = loadHistory(paths, NOW); + assert.equal(load.state, "ok", "the index parses after concurrent writes"); + const total = WRITERS * 8; + assert.equal(load.doc.entries.length, Math.min(total, MEDIA_RETENTION)); + assert.equal(load.doc.nextSequence, String(total + 1), "no writer's allocation was lost"); + assert.equal( + new Set(load.doc.entries.map((e) => e.sequence)).size, + load.doc.entries.length, + "no two retained entries share a sequence", + ); + assert.equal(new Set(load.doc.entries.map((e) => e.artifactId)).size, load.doc.entries.length); + assert.deepEqual( + readdirSync(paths.outputDir).filter((n) => n.endsWith(".tmp") || n.endsWith(".lock")), + [], + "no lock or temp file survived the run", + ); +}); + +// ── fault injection ───────────────────────────────────────────────── + +test("a crash mid-commit leaves a recoverable index, not a lost one", (t) => { + t.after(() => __setDurableFaults(null)); + const points: FaultPoint[] = ["before-temp-flush", "before-backup", "before-rename"]; + + for (const point of points) { + const paths = sandbox(); + __setDurableFaults(null); + appendEntry(paths, input({ displayName: "committed.png" }), { now: NOW }); + + __setDurableFaults((current) => { + if (current === point) throw new Error("simulated crash"); + }); + assert.throws(() => appendEntry(paths, input({ displayName: "lost.png" }), { now: NOW })); + __setDurableFaults(null); + + const load = loadHistory(paths, NOW); + assert.notEqual(load.state, "degraded", `history was lost after a crash at ${point}`); + assert.ok( + load.doc.entries.some((e) => e.displayName === "committed.png"), + `the already-committed entry vanished after a crash at ${point}`, + ); + // The lock must be released even when the body throws, or the next append + // would block until the stale timeout. + assert.equal(existsSync(paths.lock), false); + } +}); + +test("a future schema is never overwritten by an append", () => { + const paths = sandbox(); + writeFileSync( + paths.primary, + JSON.stringify({ + schemaVersion: MEDIA_HISTORY_SCHEMA_VERSION + 1, + generation: 9, + nextSequence: "500", + updatedAt: NOW, + entries: [], + unknownFutureField: "keep me", + }), + ); + const before = readFileSync(paths.primary, "utf8"); + + assert.throws(() => appendEntry(paths, input(), { now: NOW }), /newer Aether/); + assert.throws(() => clearHistory(paths, { now: NOW }), /newer Aether/); + assert.equal(readFileSync(paths.primary, "utf8"), before, "the newer document is untouched"); +}); + +test("repersist promotes a recovered generation back to the primary", () => { + const paths = sandbox(); + appendEntry(paths, input({ displayName: "first.png" }), { now: NOW }); + appendEntry(paths, input({ displayName: "second.png" }), { now: NOW }); + writeFileSync(paths.primary, "not json at all"); + + assert.equal(repersist(paths, { now: NOW }).state, "recovered-backup"); + + const after = loadHistory(paths, NOW); + assert.equal(after.state, "ok", "the warning stops repeating once the primary is valid"); + assert.deepEqual(after.doc.entries.map((e) => e.displayName), ["first.png"]); +}); + +// ── resolution ────────────────────────────────────────────────────── + +test("a sequence, a full artifact ID and a unique prefix each resolve exactly one entry", () => { + const entries = [entryAt("101"), entryAt("102"), entryAt("285")]; + assert.equal(found(resolveRef(entries, "285")).displayName, "a-285.png"); + assert.equal(found(resolveRef(entries, entries[1]!.artifactId)).sequence, "102"); + assert.equal(resolveRef(entries, "0198f4c2").status, "ambiguous", "a shared prefix is ambiguous"); + assert.equal(resolveRef(entries, "999").status, "not-found"); + assert.equal(resolveRef(entries, " ").status, "not-found"); +}); + +test("a duplicate filename resolves as ambiguous instead of picking the first match", () => { + const entries = [ + entryAt("1", { displayName: "hero.png" }), + entryAt("2", { displayName: "hero.png" }), + ]; + const resolved = resolveRef(entries, "hero.png"); + assert.equal(resolved.status, "ambiguous"); + assert.equal(resolved.status === "ambiguous" && resolved.candidates.length, 2); +}); + +test("listEntries returns newest first, capped", () => { + const entries = [entryAt("1"), entryAt("2"), entryAt("3")]; + assert.deepEqual(listEntries(entries, 2).map((e) => e.sequence), ["3", "2"]); + assert.deepEqual(listEntries(entries, 0), []); + assert.deepEqual(listEntries(entries, 99).map((e) => e.sequence), ["3", "2", "1"]); +}); + +test("shortId is a stable eight-character handle", () => { + assert.equal(shortId("0198f4c2-0000-8000-8000-000000000001"), "0198f4c2"); +}); + +// ── injection ─────────────────────────────────────────────────────── + +test("a filename full of shell metacharacters round-trips through the index intact", () => { + const paths = sandbox(); + const hostile = `a";calc.exe & echo $(id) \`whoami\` '.png`; + const { entry } = appendEntry( + paths, + input({ displayName: hostile, filePath: `/synthetic/${hostile}` }), + { now: NOW }, + ); + + const load = loadHistory(paths, NOW); + assert.equal(load.state, "ok"); + assert.equal(load.doc.entries[0]?.displayName, hostile); + assert.equal(found(resolveRef(load.doc.entries, entry.sequence)).displayName, hostile); +}); diff --git a/test/opener.test.ts b/test/opener.test.ts new file mode 100644 index 0000000..c4cf004 --- /dev/null +++ b/test/opener.test.ts @@ -0,0 +1,188 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + isAllowedUrl, + looksLikeUrl, + openTarget, + planOpen, + resolveOpenCommand, +} from "../src/core/opener.js"; + +function sandbox(): string { + return mkdtempSync(join(tmpdir(), "aether-opener-")); +} + +/** A spawn stand-in that records the call instead of launching anything. */ +function recorder(): { + calls: Array<{ file: string; args: readonly string[]; options: Record }>; + fn: (file: string, args: readonly string[], options: Record) => unknown; +} { + const calls: Array<{ file: string; args: readonly string[]; options: Record }> = []; + return { + calls, + fn: (file, args, options) => { + calls.push({ file, args, options }); + return { on: (): void => {}, unref: (): void => {} }; + }, + }; +} + +const DESKTOP_ENV = { DISPLAY: ":0" } as NodeJS.ProcessEnv; + +test("each platform gets an executable plus an argument array, never a shell string", () => { + assert.deepEqual(resolveOpenCommand("/tmp/a.png", "darwin"), { + executable: "open", + args: ["/tmp/a.png"], + }); + assert.deepEqual(resolveOpenCommand("/tmp/a.png", "linux"), { + executable: "xdg-open", + args: ["/tmp/a.png"], + }); + // Never `cmd /c start "" ` — the old browser.ts path handed the + // target to the command interpreter as a token. + assert.deepEqual(resolveOpenCommand("C:\\out\\a.png", "win32"), { + executable: "explorer.exe", + args: ["C:\\out\\a.png"], + }); +}); + +test("only http and https URLs without embedded credentials are openable", () => { + assert.equal(isAllowedUrl("https://example.invalid/a.png"), true); + assert.equal(isAllowedUrl("http://127.0.0.1:8080/a.png"), true); + assert.equal(isAllowedUrl("file:///etc/passwd"), false); + assert.equal(isAllowedUrl("javascript:alert(1)"), false); + assert.equal(isAllowedUrl("data:text/html,`, + ); + }); + + const listening = server; + await new Promise((resolve, reject) => { + listening.once("error", reject); + listening.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (listening.address() as AddressInfo).port; + const url = `http://127.0.0.1:${port}/?nonce=${nonce}`; + + const outcome = openTarget(url, options.openerOptions ?? {}); + if (outcome.status !== "spawned") { + const unavailable = outcome.status === "unavailable"; + return check(OPENER_PROBE, { + configured: axis(unavailable ? "unknown" : "no", { evidence: outcome.detail }), + reachable: axis("no", { evidence: outcome.detail }), + verified: axis("no"), + severity: unavailable ? "warning" : "error", + }); + } + + const answered = await Promise.race([ + called, + new Promise((resolve) => { + setTimeout(() => resolve(false), timeoutMs).unref(); + }), + ]); + + return check(OPENER_PROBE, { + configured: axis("yes", { evidence: `${outcome.executable} (argument array, no shell)` }), + reachable: axis(answered ? "yes" : "no"), + verified: axis(answered ? "yes" : "no", { + checkedAt: new Date().toISOString(), + latencyMs: since(started), + evidence: answered + ? "the opened page called back on loopback with the run nonce" + : `no loopback callback within ${timeoutMs}ms; the process spawned but nothing rendered`, + }), + severity: answered ? "info" : "warning", + }); + } catch (err) { + return check(OPENER_PROBE, { + verified: axis("no", { evidence: message(err) }), + severity: "error", + }); + } finally { + try { + server?.close(); + } catch { + // The socket is released when the process exits regardless. + } + } +} + +// ═════════════════════════════════════════════════════════════════════ +// GitHub identity and branch freshness +// ═════════════════════════════════════════════════════════════════════ + +export function githubProbe(runner: Runner): HealthCheck { + const probe = { id: "github.connection", category: "github", title: "GitHub identity" }; + const started = Date.now(); + const status = ghAuthStatus(runner); + return check(probe, { + configured: axis(status.authed ? "yes" : "no"), + reachable: axis(status.authed ? "yes" : "no", { + checkedAt: new Date().toISOString(), + latencyMs: since(started), + }), + verified: axis(status.authed ? "yes" : "no", { + checkedAt: new Date().toISOString(), + // The login is the user's own identity, not a credential. + evidence: status.authed + ? `local gh session${status.user ? ` as ${status.user}` : ""}${status.host ? ` on ${status.host}` : ""}` + : "gh is not installed or not logged in", + }), + severity: status.authed ? "info" : "warning", + ...(status.authed ? {} : { remediation: "run: gh auth login" }), + }); +} + +export type BranchState = + | "current" + | "ahead" + | "behind" + | "diverged" + | "unpublished" + | "detached" + | "unknown"; + +export interface BranchFreshness { + state: BranchState; + branch: string; + local: string; + remote: string; + detail: string; +} + +/** + * Compare local HEAD to the remote branch tip WITHOUT fetching. `ls-remote` + * reads the remote ref; everything else is answered from the local object + * store. When the remote commit is not present locally we say so rather than + * fetching to find out — a health command must not mutate the repository. + */ +export function branchFreshness(runner: Runner, cwd: string): BranchFreshness { + const head = runner("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], cwd); + if (head.status !== 0) { + return { branch: "", local: "", remote: "", state: "unknown", detail: "not a git checkout" }; + } + const branch = head.stdout.trim(); + if (!branch || branch === "HEAD") { + return { + branch: "", + local: "", + remote: "", + state: "detached", + detail: "HEAD is detached; no branch to compare", + }; + } + const local = runner("git", ["-C", cwd, "rev-parse", "HEAD"], cwd).stdout.trim(); + + const remote = runner("git", ["-C", cwd, "ls-remote", "--heads", "origin", branch], cwd); + if (remote.status !== 0) { + return { branch, local, remote: "", state: "unknown", detail: "origin could not be reached" }; + } + const remoteSha = remote.stdout.trim().split(/\s+/)[0] ?? ""; + if (!remoteSha) { + return { + branch, + local, + remote: "", + state: "unpublished", + detail: "no matching branch on origin", + }; + } + if (remoteSha === local) { + return { branch, local, remote: remoteSha, state: "current", detail: "local and origin agree" }; + } + + const known = runner("git", ["-C", cwd, "cat-file", "-e", `${remoteSha}^{commit}`], cwd); + if (known.status !== 0) { + return { + branch, + local, + remote: remoteSha, + state: "unknown", + detail: "origin has a commit this checkout has never seen; fetch to compare", + }; + } + const remoteIsAncestor = + runner("git", ["-C", cwd, "merge-base", "--is-ancestor", remoteSha, local], cwd).status === 0; + const localIsAncestor = + runner("git", ["-C", cwd, "merge-base", "--is-ancestor", local, remoteSha], cwd).status === 0; + + if (remoteIsAncestor) { + return { branch, local, remote: remoteSha, state: "ahead", detail: "local is ahead of origin" }; + } + if (localIsAncestor) { + return { branch, local, remote: remoteSha, state: "behind", detail: "origin is ahead of local" }; + } + return { + branch, + local, + remote: remoteSha, + state: "diverged", + detail: "local and origin have diverged", + }; +} + +export function branchProbe(runner: Runner, cwd: string): HealthCheck { + const probe = { id: "workspace.git", category: "workspace", title: "Git checkout" }; + const started = Date.now(); + const freshness = branchFreshness(runner, cwd); + const healthy = freshness.state === "current" || freshness.state === "ahead"; + const unknown = freshness.state === "unknown" || freshness.state === "detached"; + return check(probe, { + configured: axis(freshness.branch ? "yes" : "unknown", { + evidence: freshness.branch || "no branch", + }), + reachable: axis(freshness.remote ? "yes" : unknown ? "unknown" : "no", { + checkedAt: new Date().toISOString(), + latencyMs: since(started), + }), + verified: axis(healthy ? "yes" : unknown ? "unknown" : "no", { + checkedAt: new Date().toISOString(), + // Read-only throughout: ls-remote plus local object queries. No fetch, + // no pull, no ref was written. + evidence: `${freshness.state}: ${freshness.detail} (compared without fetching)`, + }), + severity: healthy || unknown ? "info" : "warning", + }); +} + +// ═════════════════════════════════════════════════════════════════════ +// MCP broker +// ═════════════════════════════════════════════════════════════════════ + +export interface DoctorSafeTool { + name: string; + description?: string; + readOnly?: boolean; + doctorSafe?: boolean; +} + +/** + * A tool is only safe for doctor to call when it says so itself. The broker's + * ToolDescriptor carries no readOnly/doctorSafe annotation today, so this looks + * for one and reports honestly when none exists rather than guessing at a name. + */ +export function pickDoctorSafeTool(tools: readonly DoctorSafeTool[]): string | null { + const safe = tools.find((tool) => tool.readOnly === true && tool.doctorSafe === true); + return safe ? safe.name : null; +} + +async function mcpProbe( + client: McpClient, + store: LocalMcpStore, + timeoutMs: number, +): Promise { + const probe = { id: "mcp.broker", category: "mcp", title: "MCP broker" }; + const registered = store.inspect().servers.length; + const started = Date.now(); + try { + // Two calls, because a provider being *known* is not the same as it being + // *connected*: listProviders is the catalogue, listConnections is the + // subset this account has actually linked. + const [providers, connections] = await Promise.all([ + bounded(client.listProviders(), timeoutMs), + bounded(client.listConnections(), timeoutMs), + ]); + const linked = new Set(connections.map((c) => c.provider_id)); + const connected = providers.filter((p) => linked.has(p.provider_id)); + const reachable = axis("yes", { + checkedAt: new Date().toISOString(), + latencyMs: since(started), + }); + + if (!connected.length) { + return check(probe, { + configured: axis(registered ? "yes" : "unknown", { + evidence: `${registered} server(s) registered`, + }), + reachable, + verified: notChecked( + "broker answered but no provider is connected, so no tool could be called", + ), + severity: "info", + }); + } + + const tools = (await bounded( + client.listTools(connected[0]!.provider_id), + timeoutMs, + )) as DoctorSafeTool[]; + const safe = pickDoctorSafeTool(tools); + if (!safe) { + return check(probe, { + configured: axis("yes", { evidence: `${registered} server(s) registered` }), + reachable, + verified: notChecked( + `broker reachable and ${tools.length} tool(s) listed, but none is declared ` + + "readOnly + doctorSafe; guessing which tool is harmless is not a proof", + ), + severity: "info", + }); + } + return check(probe, { + configured: axis("yes"), + reachable, + verified: axis("yes", { + checkedAt: new Date().toISOString(), + evidence: `invoked declared readOnly + doctorSafe tool ${safe}`, + }), + severity: "info", + }); + } catch (err) { + return check(probe, { + configured: axis(registered ? "yes" : "unknown"), + reachable: axis("no", { checkedAt: new Date().toISOString(), evidence: message(err) }), + verified: axis("no"), + severity: "warning", + }); + } +} + +// ═════════════════════════════════════════════════════════════════════ +// Protocol-C receipt round trip +// ═════════════════════════════════════════════════════════════════════ + +/** + * Persist, read back, verify, and prove replay de-duplication — through the + * production appendCustody/readCustodyLog path, but pointed at a doctor-owned + * sandbox so the user's real receipt log is never written to. + */ +export function custodyProbe(sandbox: string, runId: string): HealthCheck { + const probe = { + id: "custody.receipts", + category: "custody", + title: "Protocol-C receipt persistence", + }; + const logPath = join(sandbox, "custody.jsonl"); + const orderId = `doctor-${runId}`; + const commitmentHash = runId.replace(/-/g, "").slice(0, 16); + const record = { + protocol: "protocol-c", + order_id: orderId, + commitment: { hash: commitmentHash }, + }; + try { + appendCustody(record, logPath); + const found = readCustodyLog(10, logPath).find((entry) => entry.order_id === orderId); + const commitmentOk = + found != null && + (found["commitment"] as { hash?: string } | undefined)?.hash === commitmentHash; + + // Idempotence is part of the contract: the same order_id must not append + // a second row. + appendCustody(record, logPath); + const deduped = readCustodyLog(10, logPath).filter((e) => e.order_id === orderId).length === 1; + + const ok = commitmentOk && deduped; + return check(probe, { + configured: axis("yes"), + reachable: notApplicable("local log"), + verified: axis(ok ? "yes" : "no", { + checkedAt: new Date().toISOString(), + evidence: ok + ? "receipt persisted, read back, commitment matched, and a replay was de-duplicated" + : `persisted=${found != null}, commitment matched=${commitmentOk}, de-duplicated=${deduped}`, + }), + severity: ok ? "info" : "error", + }); + } catch (err) { + return check(probe, { verified: axis("no", { evidence: message(err) }), severity: "error" }); + } +} + +// ═════════════════════════════════════════════════════════════════════ +// Report +// ═════════════════════════════════════════════════════════════════════ + +export function spendCheck(ledger: SpendLedger): HealthCheck { + const clean = !ledger.spent && ledger.orphaned === 0; + return check({ id: "spend.none", category: "billing", title: "No spend, no orphans" }, { + configured: axis("yes"), + reachable: notApplicable("accounting over this run"), + verified: axis(clean ? "yes" : "no", { + checkedAt: new Date().toISOString(), + evidence: ledger.spent + ? `this run was billed — ${ledger.summary()}` + : ledger.orphaned > 0 + ? `${ledger.orphaned} session(s) left open — ${ledger.summary()}` + : ledger.summary(), + }), + severity: clean ? "info" : "error", + }); +} + +function automationChecks(): HealthCheck[] { + const na = (id: string, title: string, why: string): HealthCheck => + check({ id, category: "automation", title }, { + configured: notApplicable(why), + reachable: notApplicable(why), + verified: notApplicable(why), + severity: "info", + }); + return [ + na("actions.dispatch", "GitHub Actions dispatch", "this build has no workflow-dispatch surface"), + na("predator.readiness", "Predator readiness", "this build has no Predator client"), + ]; +} + +/** + * Run the live proof. Independent read-only probes run concurrently; the agent + * session sequence stays serial so a missing acknowledgement cannot be confused + * with a racing one. + */ +export async function liveReport(ctx: AppContext, options: LiveOptions = {}): Promise { + const now = options.now ?? ((): string => new Date().toISOString()); + const runId = options.runId ?? randomUUID(); + const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const runner = options.runner ?? defaultRunner(); + const cwd = options.cwd ?? ctx.flags.cwd; + const client = options.mcpClient ?? new McpClient(ctx.api); + const store = options.mcpStore ?? new LocalMcpStore(); + const ledger = new SpendLedger(); + + const sandbox = mkdtempSync(join(tmpdir(), `aether-doctor-${runId.slice(0, 8)}-`)); + try { + const authed = Boolean(await ctx.tokens.get()); + const authCheck = check({ id: "auth.credential", category: "auth", title: "Authentication" }, { + configured: axis(authed ? "yes" : "no"), + reachable: notApplicable("the stored credential is local"), + // Deliberately not refreshed: a health command must not rotate a token. + verified: axis(authed ? "yes" : "no", { + checkedAt: now(), + evidence: authed ? "credential present; not refreshed" : "signed out", + }), + severity: authed ? "info" : "warning", + ...(authed ? {} : { remediation: "run: aether auth login" }), + }); + + const independent = await Promise.all([ + catalogProbe(ctx, timeoutMs), + openerProbe(options), + Promise.resolve(githubProbe(runner)), + Promise.resolve(branchProbe(runner, cwd)), + mcpProbe(client, store, timeoutMs), + Promise.resolve(custodyProbe(sandbox, runId)), + ]); + + const agent = authed + ? await agentProbes(ctx, ledger, { ...options, runId, timeoutMs }, sandbox) + : agentUnproven("signed out; the agent loop cannot be exercised"); + + return buildReport( + "live", + [authCheck, ...agent, ...independent, ...automationChecks(), spendCheck(ledger)], + now(), + ); + } finally { + // Every temp file this run created lives under `sandbox`, so one removal + // is the whole cleanup. + try { + rmSync(sandbox, { recursive: true, force: true }); + } catch { + // A leftover doctor sandbox is swept by `doctor --fix`. + } + } +} diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index 3120c9c..92eae6b 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -216,15 +216,27 @@ test("a hanging backend cannot stall the fast report", async () => { assert.equal(report.mode, "fast"); }); -test("--live refuses rather than reporting fast-mode results as verified", async () => { +test("--live renders the live report, never the fast one relabelled", async () => { const { ctx, roots, store, client } = setup(); const captured = sink(); - const code = await cmdDoctor(ctx, ["--live"], { + await cmdDoctor(ctx, ["--live", "--no-ui"], { out: captured.out, dependencies: { memoryRoots: roots, mcpStore: store, mcpClient: client }, + liveOptions: { timeoutMs: 500, mcpStore: store, mcpClient: client, skipOpenerProbe: true }, }); - assert.equal(code, 2); - assert.match(captured.text(), /--live is not available in this build/); + const text = captured.text(); + assert.match(text, /Aether doctor v2 \(live\)/); + // The fast-mode footer must not appear on a live run. + assert.equal(text.includes("Remote reachability is not checked in fast mode"), false); + assert.match(text, /spend\.none/); + assert.equal(text.includes(SECRET), false); +}); + +test("--live and --fix are refused together rather than half-applied", async () => { + const { ctx } = setup(); + const captured = sink(); + assert.equal(await cmdDoctor(ctx, ["--live", "--fix"], { out: captured.out }), 2); + assert.match(captured.text(), /separate modes/); }); test("doctor command supports JSON and rejects unknown arguments", async () => { diff --git a/test/doctor_live.test.ts b/test/doctor_live.test.ts new file mode 100644 index 0000000..897d889 --- /dev/null +++ b/test/doctor_live.test.ts @@ -0,0 +1,493 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AppContext } from "../src/core/context.js"; +import type { McpClient } from "../src/core/mcp.js"; +import { LocalMcpStore } from "../src/core/mcp_store.js"; +import type { RunResult, Runner } from "../src/core/worktree.js"; +import type { StreamFrame } from "../src/core/stream.js"; +import type { HealthCheck, HealthReport } from "../src/core/health.js"; +import { + agentUnproven, + branchFreshness, + branchProbe, + custodyProbe, + githubProbe, + liveReport, + openerProbe, + pickDoctorSafeTool, + seqIsMonotonic, + spendCheck, + SpendLedger, + type LiveOptions, +} from "../src/core/doctor_live.js"; + +const RUN_ID = "0198f4c2-0000-4000-8000-000000000001"; +const LOCAL = "a".repeat(40); +const REMOTE = "b".repeat(40); + +function sandbox(): string { + return mkdtempSync(join(tmpdir(), "aether-live-")); +} + +function find(report: HealthReport, id: string): HealthCheck { + const check = report.checks.find((entry) => entry.id === id); + assert.ok(check, `no check with id ${id}`); + return check; +} + +/** A Runner that answers from a table keyed by the argv it receives. */ +function fakeRunner(table: Record>): Runner { + return (cmd, args) => { + const key = [cmd, ...args].join(" "); + for (const [pattern, result] of Object.entries(table)) { + if (key.includes(pattern)) return { status: 0, stdout: "", stderr: "", ...result }; + } + return { status: 1, stdout: "", stderr: "no match" }; + }; +} + +function sseBytes(frames: readonly StreamFrame[]): AsyncIterable { + const encoder = new TextEncoder(); + return { + async *[Symbol.asyncIterator]() { + for (const frame of frames) { + yield encoder.encode(`data: ${JSON.stringify(frame)}\n\n`); + } + }, + }; +} + +interface FakeServer { + created?: Record; + frames?: StreamFrame[]; + controlFails?: boolean; + closeFails?: boolean; +} + +interface Recorded { + ctx: AppContext; + posts: Array<{ path: string; body: unknown }>; + deletes: string[]; +} + +function fakeCtx(server: FakeServer = {}, cwd = process.cwd()): Recorded { + const posts: Array<{ path: string; body: unknown }> = []; + const deletes: string[] = []; + const ctx = { + cfg: { baseUrl: "https://api.example.invalid" }, + flags: { cwd, json: false, audit: false, yes: false }, + tokens: { get: async (): Promise => "SYNTHETIC-TOKEN" }, + api: { + async getJson(): Promise { + return [{ id: "sonnet", kind: "model", available: true }]; + }, + async postJson(path: string, body: unknown): Promise { + posts.push({ path, body }); + if (path.endsWith("/control")) { + if (server.controlFails) throw new Error("control refused"); + return { ok: true }; + } + if (path.endsWith("/tool-results")) return { ok: true }; + return server.created ?? { session_id: "sess-0198f4c2" }; + }, + async deleteJson(path: string): Promise { + deletes.push(path); + if (server.closeFails) throw new Error("close refused"); + return {}; + }, + async stream(): Promise> { + return sseBytes(server.frames ?? []); + }, + }, + confirm: async (): Promise => false, + } as unknown as AppContext; + return { ctx, posts, deletes }; +} + +const HAPPY_FRAMES: StreamFrame[] = [ + { type: "session", seq: 1, sessionId: "sess-0198f4c2", protocolVersion: 1 }, + { type: "tool_call", seq: 2, toolCallId: "tc-1", name: "read_file", args: {} }, + { type: "tool_result_ack", seq: 3, toolCallId: "tc-1" }, + { type: "done", seq: 4, uvt: 0, cents: 0, ok: true }, +]; + +function emptyStore(): LocalMcpStore { + return new LocalMcpStore(join(mkdtempSync(join(tmpdir(), "aether-live-mcp-")), "mcp.json")); +} + +function deadClient(): McpClient { + const fail = async (): Promise => { + throw new Error("broker unavailable"); + }; + return { listProviders: fail, listConnections: fail, listTools: fail } as unknown as McpClient; +} + +function liveOpts(over: Partial = {}): LiveOptions { + return { + runId: RUN_ID, + timeoutMs: 2_000, + skipOpenerProbe: true, + mcpClient: deadClient(), + mcpStore: emptyStore(), + runner: fakeRunner({}), + ...over, + }; +} + +// ── pure helpers ──────────────────────────────────────────────────── + +test("frame sequence must be strictly increasing", () => { + assert.equal(seqIsMonotonic([1, 2, 3]), true); + assert.equal(seqIsMonotonic([1]), true); + assert.equal(seqIsMonotonic([]), true); + assert.equal(seqIsMonotonic([1, 1]), false, "a replayed seq is not progress"); + assert.equal(seqIsMonotonic([3, 2]), false); +}); + +test("only a tool that declares itself readOnly AND doctorSafe may be called", () => { + assert.equal(pickDoctorSafeTool([{ name: "search" }]), null); + assert.equal(pickDoctorSafeTool([{ name: "search", readOnly: true }]), null); + assert.equal(pickDoctorSafeTool([{ name: "wipe", doctorSafe: true }]), null); + assert.equal(pickDoctorSafeTool([{ name: "ping", readOnly: true, doctorSafe: true }]), "ping"); +}); + +test("every agent probe reports not-checked when the loop cannot be proven", () => { + const checks = agentUnproven("server said no"); + assert.equal(checks.length, 5); + for (const check of checks) { + assert.equal(check.verified.state, "not-checked", `${check.id} must not claim a pass`); + assert.equal(check.reachable.state, "not-checked"); + assert.match(String(check.verified.evidence), /server said no/); + assert.match(String(check.remediation), /non-billable doctor session/); + } +}); + +// ── branch freshness (no fetch) ───────────────────────────────────── + +test("branch freshness classifies every state without fetching", () => { + const base = { + "rev-parse --abbrev-ref HEAD": { stdout: "main\n" }, + "rev-parse HEAD": { stdout: `${LOCAL}\n` }, + }; + const at = (over: Record>): string => + branchFreshness(fakeRunner({ ...base, ...over }), ".").state; + + assert.equal(at({ "ls-remote": { stdout: `${LOCAL}\trefs/heads/main\n` } }), "current"); + assert.equal(at({ "ls-remote": { stdout: "" } }), "unpublished"); + assert.equal(at({ "ls-remote": { status: 1 } }), "unknown"); + // origin's tip is not in the local object store — say so, never fetch to find out. + assert.equal( + at({ "ls-remote": { stdout: `${REMOTE}\trefs/heads/main\n` }, "cat-file": { status: 1 } }), + "unknown", + ); + assert.equal( + at({ + "ls-remote": { stdout: `${REMOTE}\trefs/heads/main\n` }, + "cat-file": { status: 0 }, + [`merge-base --is-ancestor ${REMOTE} ${LOCAL}`]: { status: 0 }, + }), + "ahead", + ); + assert.equal( + at({ + "ls-remote": { stdout: `${REMOTE}\trefs/heads/main\n` }, + "cat-file": { status: 0 }, + [`merge-base --is-ancestor ${LOCAL} ${REMOTE}`]: { status: 0 }, + }), + "behind", + ); + assert.equal( + at({ "ls-remote": { stdout: `${REMOTE}\trefs/heads/main\n` }, "cat-file": { status: 0 } }), + "diverged", + ); + assert.equal( + branchFreshness(fakeRunner({ "rev-parse --abbrev-ref HEAD": { stdout: "HEAD\n" } }), ".").state, + "detached", + ); + assert.equal(branchFreshness(fakeRunner({}), ".").state, "unknown"); +}); + +test("the branch comparison never runs a mutating git command", () => { + const seen: string[] = []; + const runner: Runner = (cmd, args) => { + seen.push([cmd, ...args].join(" ")); + if (args.includes("--abbrev-ref")) return { status: 0, stdout: "main\n", stderr: "" }; + if (args.includes("ls-remote")) { + return { status: 0, stdout: `${LOCAL}\trefs/heads/main\n`, stderr: "" }; + } + return { status: 0, stdout: `${LOCAL}\n`, stderr: "" }; + }; + branchProbe(runner, "."); + const forbidden = /\b(fetch|pull|push|merge|rebase|reset|checkout|clean|commit)\b/; + for (const command of seen) { + assert.equal(forbidden.test(command), false, `mutating git command: ${command}`); + } +}); + +// ── gh identity ───────────────────────────────────────────────────── + +test("github identity reports the login, and a missing gh is a warning not a failure", () => { + const authed = githubProbe( + fakeRunner({ "gh auth status": { stdout: "Logged in to github.com account synthetic-user" } }), + ); + assert.equal(authed.verified.state, "yes"); + assert.match(String(authed.verified.evidence), /synthetic-user/); + + const missing = githubProbe(fakeRunner({})); + assert.equal(missing.verified.state, "no"); + assert.equal(missing.severity, "warning"); + assert.match(String(missing.remediation), /gh auth login/); +}); + +// ── custody receipt round trip ────────────────────────────────────── + +test("the receipt round trip persists, reads back, and de-duplicates a replay", () => { + const dir = sandbox(); + const check = custodyProbe(dir, RUN_ID); + assert.equal(check.verified.state, "yes"); + assert.match(String(check.verified.evidence), /de-duplicated/); + assert.ok(existsSync(join(dir, "custody.jsonl"))); +}); + +test("the receipt proof writes only inside its sandbox", () => { + const dir = sandbox(); + custodyProbe(dir, RUN_ID); + assert.deepEqual(readdirSync(dir), ["custody.jsonl"]); +}); + +// ── spend ledger ──────────────────────────────────────────────────── + +test("the ledger records billed frames and unclosed sessions", () => { + const clean = new SpendLedger(); + clean.sessionOpened(); + clean.observe({ type: "done", seq: 1, uvt: 0, cents: 0, ok: true }); + clean.sessionClosed(); + assert.equal(clean.spent, false); + assert.equal(clean.orphaned, 0); + assert.equal(spendCheck(clean).verified.state, "yes"); + + const billed = new SpendLedger(); + billed.observe({ type: "usage", seq: 1, uvt: 12, cents: 3 }); + assert.equal(billed.spent, true); + const check = spendCheck(billed); + assert.equal(check.verified.state, "no"); + assert.equal(check.severity, "error"); + assert.match(String(check.verified.evidence), /this run was billed/); + + const orphan = new SpendLedger(); + orphan.sessionOpened(); + assert.equal(orphan.orphaned, 1); + assert.equal(spendCheck(orphan).severity, "error"); +}); + +// ── opener ────────────────────────────────────────────────────────── + +test("a headless session reports the opener skipped, never verified", async () => { + const check = await openerProbe({ headless: true }); + assert.equal(check.verified.state, "not-checked"); + assert.match(String(check.verified.evidence), /headless/); +}); + +test("the opener proof passes only when the opened page calls back", async () => { + // Stand in for a browser: load the page, then run what its script would run. + // Fetching the page alone is deliberately NOT enough — the callback is the + // evidence that something actually rendered. + const browser = (_file: string, args: string[]): unknown => { + void (async () => { + const target = new URL(args[0]!); + await fetch(target.href).catch(() => {}); + const nonce = target.searchParams.get("nonce"); + await fetch(new URL(`/cb?nonce=${nonce}`, target.origin).href).catch(() => {}); + })(); + return { on: (): void => {}, unref: (): void => {} }; + }; + + const verified = await openerProbe({ + openerTimeoutMs: 5_000, + openerOptions: { + platform: "linux", + env: { DISPLAY: ":0" } as NodeJS.ProcessEnv, + spawnFn: browser as never, + }, + }); + assert.equal(verified.verified.state, "yes"); + assert.match(String(verified.verified.evidence), /called back on loopback/); + + // A process that spawns but renders nothing is not a pass. + const silent = await openerProbe({ + openerTimeoutMs: 300, + openerOptions: { + platform: "linux", + env: { DISPLAY: ":0" } as NodeJS.ProcessEnv, + spawnFn: (() => ({ on: (): void => {}, unref: (): void => {} })) as never, + }, + }); + assert.equal(silent.verified.state, "no"); + assert.match(String(silent.verified.evidence), /nothing rendered/); +}); + +// ── full live run ─────────────────────────────────────────────────── + +test("a server that will not confirm a non-billable session is closed, not driven", async () => { + // No purpose/billable echo — the old server. Nothing may be spent. + const { ctx, posts, deletes } = fakeCtx({ created: { session_id: "sess-legacy" } }); + const report = await liveReport(ctx, liveOpts()); + + assert.equal(report.mode, "live"); + for (const id of ["agent.frames", "agent.session.control", "agent.tool.roundtrip"]) { + assert.equal(find(report, id).verified.state, "not-checked"); + } + assert.match( + String(find(report, "agent.session").verified.evidence), + /non-billable doctor session/, + ); + assert.equal(deletes.length, 1, "the unconfirmed session was closed immediately"); + assert.equal( + posts.some((p) => p.path.endsWith("/control")), + false, + "no control was issued", + ); + assert.equal(find(report, "spend.none").verified.state, "yes"); +}); + +test("the create request carries the doctor purpose and a zero spend ceiling", async () => { + const { ctx, posts } = fakeCtx({ created: { session_id: "sess-legacy" } }); + await liveReport(ctx, liveOpts()); + const create = posts.find((p) => p.path === "/agent/dev/sessions"); + assert.ok(create); + const body = create.body as Record; + assert.equal(body["purpose"], "doctor"); + assert.equal(body["max_uvt"], 0); +}); + +test("a confirmed doctor session proves frames, control and the tool round trip", async () => { + const { ctx, posts, deletes } = fakeCtx({ + created: { session_id: "sess-0198f4c2", purpose: "doctor", billable: false }, + frames: HAPPY_FRAMES, + }); + const report = await liveReport(ctx, liveOpts()); + + assert.equal(find(report, "agent.session").verified.state, "yes"); + assert.equal(find(report, "agent.frames").verified.state, "yes"); + assert.match(String(find(report, "agent.frames").verified.evidence), /seq 1\.\.4/); + assert.equal(find(report, "agent.session.control").verified.state, "yes"); + assert.equal(find(report, "agent.tool.roundtrip").verified.state, "yes"); + assert.equal(find(report, "agent.session.close").verified.state, "yes"); + assert.equal(find(report, "spend.none").verified.state, "yes"); + + // pause, then resume, then steer — in that order, with the run nonce. + const control = posts + .filter((p) => p.path.endsWith("/control")) + .map((p) => p.body as Record); + assert.deepEqual( + control.map((c) => c["action"]), + ["pause", "resume", "steer"], + ); + assert.equal(control[2]?.["note"], `doctor-${RUN_ID}`); + assert.equal(deletes.length, 1); +}); + +test("a session the server bills is reported as an error, not a footnote", async () => { + const { ctx } = fakeCtx({ + created: { session_id: "sess-0198f4c2", purpose: "doctor", billable: false }, + frames: [ + { type: "session", seq: 1, sessionId: "sess-0198f4c2", protocolVersion: 1 }, + { type: "done", seq: 2, uvt: 42, cents: 7, ok: true }, + ], + }); + const report = await liveReport(ctx, liveOpts()); + const spend = find(report, "spend.none"); + assert.equal(spend.verified.state, "no"); + assert.equal(spend.severity, "error"); + assert.match(String(spend.verified.evidence), /this run was billed/); +}); + +test("an out-of-order frame fails the sequence proof", async () => { + const { ctx } = fakeCtx({ + created: { session_id: "sess-0198f4c2", purpose: "doctor", billable: false }, + frames: [ + { type: "session", seq: 5, sessionId: "sess-0198f4c2", protocolVersion: 1 }, + { type: "done", seq: 2, uvt: 0, cents: 0, ok: true }, + ], + }); + const report = await liveReport(ctx, liveOpts()); + assert.equal(find(report, "agent.frames").verified.state, "no"); + assert.equal(find(report, "agent.frames").severity, "error"); +}); + +test("an unacknowledged control action fails the control proof", async () => { + const { ctx } = fakeCtx({ + created: { session_id: "sess-0198f4c2", purpose: "doctor", billable: false }, + frames: HAPPY_FRAMES, + controlFails: true, + }); + const report = await liveReport(ctx, liveOpts()); + const control = find(report, "agent.session.control"); + assert.equal(control.verified.state, "no"); + assert.match(String(control.verified.evidence), /pause unacked/); +}); + +test("a session that will not close is an orphan, and says so", async () => { + const { ctx } = fakeCtx({ + created: { session_id: "sess-0198f4c2", purpose: "doctor", billable: false }, + frames: HAPPY_FRAMES, + closeFails: true, + }); + const report = await liveReport(ctx, liveOpts()); + assert.equal(find(report, "agent.session.close").verified.state, "no"); + const spend = find(report, "spend.none"); + assert.equal(spend.verified.state, "no"); + assert.match(String(spend.verified.evidence), /session\(s\) left open/); +}); + +test("a broker with no doctor-safe tool is reachable but unproven", async () => { + const client = { + listProviders: async () => [{ provider_id: "docs", display_name: "Docs", flow: "pat_paste" }], + listConnections: async () => [{ provider_id: "docs", created_at: "t", updated_at: "t" }], + listTools: async () => [{ name: "search" }], + } as unknown as McpClient; + const { ctx } = fakeCtx({ created: { session_id: "s" } }); + const report = await liveReport(ctx, liveOpts({ mcpClient: client })); + const mcp = find(report, "mcp.broker"); + assert.equal(mcp.reachable.state, "yes"); + assert.equal(mcp.verified.state, "not-checked"); + assert.match(String(mcp.verified.evidence), /readOnly \+ doctorSafe/); +}); + +test("signed out means the agent loop is unproven, not failed", async () => { + const { ctx, posts } = fakeCtx(); + (ctx as unknown as { tokens: { get: () => Promise } }).tokens = { + get: async () => null, + }; + const report = await liveReport(ctx, liveOpts()); + assert.equal(find(report, "auth.credential").verified.state, "no"); + assert.equal(find(report, "agent.session").verified.state, "not-checked"); + assert.equal( + posts.some((p) => p.path === "/agent/dev/sessions"), + false, + "no session was created", + ); +}); + +test("a live run leaves no doctor sandbox behind", async () => { + const before = readdirSync(tmpdir()).filter((n) => n.startsWith("aether-doctor-")).length; + const { ctx } = fakeCtx({ + created: { session_id: "sess-0198f4c2", purpose: "doctor", billable: false }, + frames: HAPPY_FRAMES, + }); + await liveReport(ctx, liveOpts()); + const after = readdirSync(tmpdir()).filter((n) => n.startsWith("aether-doctor-")).length; + assert.ok(after <= before, "the doctor sandbox was not cleaned up"); +}); + +test("a live report never leaks the stored credential", async () => { + const { ctx } = fakeCtx({ + created: { session_id: "sess-0198f4c2", purpose: "doctor", billable: false }, + frames: HAPPY_FRAMES, + }); + const report = await liveReport(ctx, liveOpts()); + assert.equal(JSON.stringify(report).includes("SYNTHETIC-TOKEN"), false); +}); From 4a49d77566b6355b7ba72fb31d117ebcf89cc64d Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 14 Aug 2026 14:21:46 -0400 Subject: [PATCH 4/5] refactor(doctor): drop a no-op spread in the --live options --- src/commands/doctor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 0242b59..f0adc5c 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -123,7 +123,6 @@ export async function cmdDoctor( const live = await liveReport(ctx, { ...(options.liveOptions ?? {}), ...(flags.noUi ? { headless: true } : {}), - ...(flags.only.length ? {} : {}), }); const filtered = flags.only.length ? { ...live, checks: live.checks.filter((check) => flags.only.includes(check.id)) } From e4ce22262af1878aa6d7cdbbdb8c541243b6fc44 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 14 Aug 2026 14:22:42 -0400 Subject: [PATCH 5/5] fix(doctor): close keep-alive sockets so --live cannot hang the CLI on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.close() only stops accepting new connections. A browser holding the loopback proof page open keeps the handle — and therefore the process — alive after the probe has already answered. --- src/core/doctor_live.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/doctor_live.ts b/src/core/doctor_live.ts index 9ad4a73..0a3b3f9 100644 --- a/src/core/doctor_live.ts +++ b/src/core/doctor_live.ts @@ -604,6 +604,9 @@ export async function openerProbe(options: LiveOptions = {}): Promise