diff --git a/COMMANDS.md b/COMMANDS.md index 15bc58d..46ab6e7 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -150,18 +150,57 @@ Exports the cryptographic proof package for one audit entry. Find ids with aether receipt chat_8f3a... ``` -### `aether doctor [--deep]` — runtime diagnostics -Runs an ordered set of structured checks — auth, network reachability, -config integrity, MCP registry health, and more — and prints a pass/fail -summary. `--deep` adds slower, bounded checks on top of the fast baseline. -Exits `1` if any check fails, so it's safe to gate scripts on. +### `aether doctor [--live | --fix]` — health, proof, and repair +Every check answers three questions separately, so "configured" is never +mistaken for "working": + +``` +Agent transport + configured yes + reachable yes + verified now yes · 15:42:08 +``` + +A check nobody exercised reports `not checked` — never a pass. A surface this +build genuinely does not have reports `n/a` with the reason. Exits `1` if any +check is an error, so it is safe to gate scripts on. + +**`aether doctor`** — fast and strictly read-only. No network call, no model +call, no session, no opener launch, no credential refresh, no write. Covers +runtime, workspace, git, transport config, auth config, tools, memory, MCP +registry, persistence, the media output index, the opener, GitHub, and +Protocol-C receipt storage. + +**`aether doctor --live`** — proves the paths end to end, right now: +authenticated catalog fetch, a dev session, sequence-numbered frames, +pause/resume/steer acknowledgement, a sandboxed tool write/read/compare/delete +round trip, clean session close, a real browser open confirmed by a loopback +callback, GitHub identity, branch freshness compared **without fetching**, the +MCP broker, and a Protocol-C receipt round trip. Billing is accounted across +the run and reported as `spend.none`; the agent loop runs only when the server +confirms a non-billable doctor session, and is reported as unproven otherwise. +`--no-ui` skips the browser proof on a headless box (reported as skipped, not +passed). + +**`aether doctor --fix`** — a closed allowlist of local repairs, never a repair +agent. Prints the exact scope, action, risk, reversibility and backup of every +planned repair and changes nothing without `--yes`. It will not rotate +credentials, spend UVT, invoke a model, edit source, mutate a git ref, dispatch +Actions, run Predator, or call an MCP write tool. ```bash -aether doctor -aether doctor --deep -aether doctor --json +aether doctor # fast, read-only +aether doctor --live # end-to-end proof, no spend +aether doctor --live --no-ui # same, on a headless box +aether doctor --fix --dry-run # show the repair plan, change nothing +aether doctor --fix --yes # apply the plan +aether doctor --fix --only media.rebuild --yes +aether doctor --json # schema-versioned report for automation ``` +`--deep` still means the read-only report it always meant; it now points at +`--live` for the end-to-end proof. + ### `aether mcp [list|doctor|repair]` — manage and diagnose MCP servers With no subcommand (in a TTY), opens the same interactive MCP manager as the `/mcp` slash command: an arrow-key menu over backend connections (OAuth/PAT @@ -320,7 +359,7 @@ Requires an active orchestrator — switch with `/agent neo` or `/agent kronus` | `/sequence ` | Cinematic multi-shot video (routes to a cinematic model by default). | | `/animate [motion]` | Animate a still image into video. | | `/re-cut ` | Re-edit the last generated video. | -| `/output [open \|clean]` | List, open, or clear recent generations. | +| `/output [open \|clean]` | List, open, or clear recent generations. `` is a sequence number, a full artifact ID, or a unique ID prefix; an ambiguous reference lists its candidates instead of guessing. | | `/storyboard [--scenes --style]` | Multi-scene storyboard: parse → preview → `--generate`/`--animate`/`--render`. | ### HUD diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 667131b..9979c4c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,44 @@ +# Aether Agent — your artifacts get an identity, and doctor stops guessing + +**August 14, 2026** + +Two things you can lose quietly — the record of what you generated, and the +belief that your setup works — stop being losable. + +- **Every generated artifact gets a UUID** plus a persistent short reference. + The old log numbered entries by array length, so once retention trimmed to + 100, the 101st generation and every one after it all answered to `101`. + `output open 101` picked whichever came first. That is gone. +- **History cannot silently vanish.** Writes are locked across processes, + atomic, flushed, and backed up. A corrupt index recovers from backup or + rebuilds from the files still on disk — and always tells you which happened + instead of rendering an empty list. The unreadable copy is kept as evidence, + and an index written by a newer Aether is never overwritten. +- **Opening a file or a URL no longer goes through a shell.** Both paths used + to build a command string, so a filename containing a quote or an `&` was an + injection primitive. One implementation now hands the target to the OS as an + argument array — and the Windows URL path actually works, which it did not + before. +- **`aether doctor` answers three questions, not one.** Configured, reachable, + and verified-now are separate. A check nobody ran says `not checked`; a + surface this build does not have says `n/a` with the reason. Neither is a + green tick any more. +- **`aether doctor --live` proves it** — a real session, sequence-numbered + frames, pause/resume/steer acknowledgement, a sandboxed tool round trip, a + browser open confirmed by a loopback callback, branch freshness compared + without fetching, and a Protocol-C receipt round trip. Billing is accounted + across the run: the agent loop runs only when the server confirms a + non-billable doctor session, and reports itself unproven otherwise. +- **`aether doctor --fix`** shows its exact repair plan — scope, risk, + reversibility, backup — and changes nothing without `--yes`. It cannot rotate + credentials, spend, invoke a model, edit source, or move a git ref. + +Nothing to do on upgrade: `output open ` keeps working and existing +history migrates on first read. Full detail in +[docs/releases/2026-08-14.md](docs/releases/2026-08-14.md). + +--- + # Aether Agent — the API brain goes bidirectional **August 12, 2026** diff --git a/docs/releases/2026-08-14.md b/docs/releases/2026-08-14.md new file mode 100644 index 0000000..eeebb3a --- /dev/null +++ b/docs/releases/2026-08-14.md @@ -0,0 +1,111 @@ +# Release notes — 2026-08-14 + +One lane landed: **custody of the things you can lose**. Generated artifacts get +an identity that never moves, the media index stops being able to vanish +quietly, opening a file or a URL stops going through a shell, and `aether +doctor` stops answering "is it configured?" when you asked "does it work?". + +## Highlights + +- **Media output history v2** — every artifact gets a UUID plus a persistent + monotonic reference. `output open 101` used to be ambiguous once you passed + 100 generations; it no longer can be. +- **History that cannot silently disappear** — writes are locked, atomic, + fsync'd and backed up; a corrupt index recovers from backup or rebuilds from + disk, and always says which happened. +- **One safe opener** — files and URLs are handed to the OS as an argument + array. The two old paths each built a shell string. +- **`aether doctor` v2** — configured / reachable / verified-now are three + separate answers, and `--live` proves the whole path without spending + anything. +- **`aether doctor --fix`** — a closed allowlist of local repairs that shows its + exact plan first and changes nothing without `--yes`. + +## Media output history + +The v1 log wrote `index: entries.length + 1`. Retention trims the array to 100, +so the 101st generation — and every one after it — all claimed index `101`. +`output open 101` resolved to whichever duplicate happened to come first. + +- Each artifact now carries a UUID that is generated once and never changes, + plus a `sequence` alias that keeps the short numeric reference convenient. + The counter is persisted, not derived from the retained window, so trimming + can never hand the same number out twice. +- A reference resolves by sequence, full artifact ID, or a unique ID prefix. An + ambiguous reference lists its candidates instead of guessing. +- Existing logs migrate on first read. Duplicate `101`s are repaired + deterministically before the migrated index is committed. +- Writes run one locked transaction — read the best valid generation, allocate + under the lock, validate, write a same-directory temp, flush, stage a backup + of the previous generation, rename atomically, then re-read and confirm. Two + Agent turns writing at once can no longer drop each other's entry, and a + crash at any phase leaves either the old or the new generation intact. +- Reads recover in order: primary, backup, then a rebuild from the files still + on disk. Every degraded outcome prints a warning above the results, so a lost + history never looks like an empty one. An unreadable index is preserved as + `.corrupt.` rather than overwritten, and an index written by a + newer Aether is left strictly alone. +- Recovered entries are labelled as such. Prompt and model cannot be recovered + from a file on disk, so they stay empty instead of being invented. + +## Opening files and URLs + +Opening a generated file ran `execSync` on an interpolated shell string, and +opening a URL spawned `cmd /c start "" ` on Windows. A filename containing +a quote, an `&`, or a backtick was a command-injection primitive — and the +Windows path was also broken, because `start` is a shell builtin, not an +executable. + +Both now go through one implementation that validates the target first +(http/https only, no embedded credentials, files must exist) and hands it to +`explorer.exe` / `open` / `xdg-open` as an argument array with the shell +disabled. `doctor --live` proves this exact code path. + +## `aether doctor` + +The old report had one axis: pass / warn / fail / skip. "The backend URL is +well-formed" and "the backend answered just now" both rendered as a green +`pass`. Now every check answers three questions independently, and an axis +nobody exercised reports `not checked` rather than a pass: + +``` +Agent transport + configured yes + reachable yes + verified now yes · 15:42:08 +``` + +- **`aether doctor`** is fast and strictly read-only: no network call, no model + call, no session, no opener launch, no credential refresh, no write. New + checks cover the media index, the opener, GitHub, and Protocol-C receipt + storage. +- **`aether doctor --live`** proves it: an authenticated catalog fetch, a dev + session, sequence-numbered frames, pause/resume/steer acknowledgement, a + sandboxed tool write/read/compare/delete round trip, a clean session close, a + real browser open confirmed by a loopback callback, GitHub identity, branch + freshness compared without fetching, the MCP broker, and a Protocol-C receipt + round trip. Billing is accounted across the run and reported as `spend.none`. +- **No billed health probe.** The agent loop runs only when the server confirms + a non-billable doctor session; otherwise the session is closed immediately and + those checks report unproven with the reason. A skipped check is never a pass. +- **`--no-ui`** skips the browser proof on a headless box and reports it as + skipped, not passed. +- **`aether doctor --fix`** is a closed allowlist: create missing state + directories, tighten Aether-owned permissions, remove provably abandoned locks + and transaction temps, rebuild the media index while preserving the corrupt + original, and prune worktree metadata git already reports as gone. It prints + the exact scope, action, risk, reversibility and backup of every repair, and + changes nothing without `--yes`. It will not rotate credentials, spend UVT, + invoke a model, edit source, mutate a git ref, dispatch Actions, run Predator, + or call an MCP write tool. +- A surface this build genuinely does not have — Actions dispatch, Predator — + reports `n/a` with the reason instead of a green tick. +- `--deep` still means the read-only report it always meant, and points at + `--live` for the end-to-end proof. + +## Upgrading + +Nothing to do. `output open ` keeps working; existing history migrates +on first read and stays visible. The doctor JSON report is now +`schemaVersion: 2` — if you script against it, read the three axes rather than a +single status field. diff --git a/docs/releases/README.md b/docs/releases/README.md index e10e3c4..6922f6a 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -8,4 +8,5 @@ For the live command reference, see [COMMANDS.md](../COMMANDS.md). ## Index +- [2026-08-14](2026-08-14.md) — Durable media output history, one safe opener, and `aether doctor` v2 (fast / `--live` / `--fix`). - [2026-06-09](2026-06-09.md) — Aether Agent rebrand + slash-command console (PRs #4–#16). diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3d95037..f0adc5c 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,30 +1,197 @@ +// `aether doctor` — fast, read-only inspection (no network, no writes) +// `aether doctor --live` — prove the paths actually work right now +// `aether doctor --fix` — allowlisted local repair, after showing the plan +// +// `--deep` keeps its original read-only meaning. Repointing it at a mutating +// mode would change what an existing habit does, so it stays an alias for the +// fast report and only prints where the new behaviour lives. + import type { Writable } from "node:stream"; import type { AppContext } from "../core/context.js"; +import { diagnosticReport, type DiagnosticDependencies } from "../core/diagnostics.js"; +import { liveReport, type LiveOptions } from "../core/doctor_live.js"; +import { renderHealthReport, type HealthReport } from "../core/health.js"; import { - diagnosticReport, - renderDiagnosticReport, - type DiagnosticDependencies, -} from "../core/diagnostics.js"; + applyRepair, + planRepairs, + renderRepairPlan, + repairIds, + type RepairContext, +} from "../core/doctor_repair.js"; export interface DoctorCommandOptions { out?: Writable; dependencies?: DiagnosticDependencies; + repairContext?: RepairContext; + liveOptions?: LiveOptions; deep?: boolean; } +export interface DoctorFlags { + live: boolean; + fix: boolean; + dryRun: boolean; + yes: boolean; + deep: boolean; + noUi: boolean; + only: string[]; + unknown: string[]; +} + +const USAGE = + "usage: aether doctor [--live | --fix] [--dry-run] [--yes] [--no-ui] " + + "[--only ] [--json]\n"; + +export function parseDoctorArgs(argv: readonly string[]): DoctorFlags { + const flags: DoctorFlags = { + live: false, + fix: false, + dryRun: false, + yes: false, + deep: false, + noUi: false, + only: [], + unknown: [], + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + switch (arg) { + case "--live": + case "live": + flags.live = true; + break; + case "--fix": + case "fix": + flags.fix = true; + break; + case "--dry-run": + flags.dryRun = true; + break; + case "--yes": + case "-y": + flags.yes = true; + break; + case "--deep": + case "deep": + flags.deep = true; + break; + case "--no-ui": + flags.noUi = true; + break; + case "--only": { + const value = argv[i + 1]; + if (value && !value.startsWith("-")) { + flags.only.push(value); + i += 1; + } else { + flags.unknown.push("--only (missing value)"); + } + break; + } + default: + flags.unknown.push(arg); + } + } + return flags; +} + +function exitCode(report: HealthReport): number { + return report.summary.error > 0 ? 1 : 0; +} + export async function cmdDoctor( ctx: AppContext, argv: string[] = [], options: DoctorCommandOptions = {}, ): Promise { - const unknown = argv.filter((arg) => arg !== "deep" && arg !== "--deep"); - if (unknown.length) { - (options.out ?? process.stdout).write("usage: aether doctor [--deep] [--json]\n"); + const out = options.out ?? process.stdout; + const flags = parseDoctorArgs(argv); + if (options.deep === true) flags.deep = true; + + if (flags.unknown.length) { + out.write(USAGE); return 2; } - const deep = options.deep === true || argv.includes("deep") || argv.includes("--deep"); - const report = await diagnosticReport(ctx, deep, options.dependencies); - const out = options.out ?? process.stdout; - out.write(ctx.flags.json ? JSON.stringify(report) + "\n" : renderDiagnosticReport(report)); - return report.summary.fail > 0 ? 1 : 0; + if (flags.live && flags.fix) { + out.write("aether doctor: --live and --fix are separate modes; run one at a time\n"); + return 2; + } + + // --live replaces the report entirely rather than annotating the fast one: + // mixing "configured" rows into a live run is how a false green happens. + if (flags.live) { + const live = await liveReport(ctx, { + ...(options.liveOptions ?? {}), + ...(flags.noUi ? { headless: true } : {}), + }); + const filtered = flags.only.length + ? { ...live, checks: live.checks.filter((check) => flags.only.includes(check.id)) } + : live; + out.write( + ctx.flags.json ? JSON.stringify(filtered) + "\n" : renderHealthReport(filtered), + ); + return exitCode(filtered); + } + + // Fast inspection is the input to every other mode, including --fix. + const report = await diagnosticReport(ctx, { dependencies: options.dependencies }); + + if (flags.fix) return runFix(report, flags, out, options); + + if (ctx.flags.json) { + out.write(JSON.stringify(report) + "\n"); + return exitCode(report); + } + + out.write(renderHealthReport(report)); + if (flags.deep) { + out.write( + "\nNote: --deep is the read-only report (unchanged). " + + "The live end-to-end proof is: aether doctor --live\n", + ); + } + return exitCode(report); +} + +function runFix( + report: HealthReport, + flags: DoctorFlags, + out: Writable, + options: DoctorCommandOptions, +): number { + const only = flags.only.length ? flags.only : undefined; + if (only) { + const known = new Set(repairIds()); + const bad = only.filter((id) => !known.has(id)); + if (bad.length) { + out.write(`aether doctor --fix: unknown repair ${bad.join(", ")}\n`); + out.write(`available: ${repairIds().join(", ")}\n`); + return 2; + } + } + + const plan = planRepairs(report, options.repairContext ?? {}, only); + out.write(renderRepairPlan(plan)); + if (!plan.length) return exitCode(report); + + if (flags.dryRun) { + out.write("\n--dry-run: no change was made.\n"); + return 0; + } + if (!flags.yes) { + // Non-interactive by construction: this command never prompts, so an + // explicit --yes is the only way a mutation happens. + out.write("\nRe-run with --yes to apply the plan above.\n"); + return 0; + } + + let skipped = 0; + out.write("\n"); + for (const item of plan) { + const result = applyRepair(item.id, options.repairContext ?? {}); + out.write(` ${result.applied ? "applied" : "skipped"} ${result.id} ${result.detail}\n`); + if (result.preserved) out.write(` evidence kept at ${result.preserved}\n`); + if (!result.applied) skipped += 1; + } + return skipped === plan.length ? 1 : 0; } 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/custody.ts b/src/core/custody.ts index 684a98b..1684f41 100644 --- a/src/core/custody.ts +++ b/src/core/custody.ts @@ -22,14 +22,20 @@ export interface CustodyRecord { [k: string]: unknown; } -/** Append one signed custody record (de-duped by order_id against the recent tail). */ -export function appendCustody(custody: Record): void { +/** + * Append one signed custody record (de-duped by order_id against the recent + * tail). `path` is injectable so `doctor --live` can prove this exact code path + * against a doctor-owned sandbox instead of writing into the user's real log. + */ +export function appendCustody( + custody: Record, + path: string = custodyLogPath(), +): void { try { const orderId = custody["order_id"]; if (!orderId) return; - const existing = readCustodyLog(MAX); + const existing = readCustodyLog(MAX, path); if (existing.some((e) => e.order_id === orderId)) return; - const path = custodyLogPath(); mkdirSync(dirname(path), { recursive: true }); const rec: CustodyRecord = { ...custody, received_at: Date.now() }; appendFileSync(path, JSON.stringify(rec) + "\n", "utf8"); @@ -39,9 +45,8 @@ export function appendCustody(custody: Record): void { } /** Read the client-held custody log, newest-first, capped at `limit`. */ -export function readCustodyLog(limit = 200): CustodyRecord[] { +export function readCustodyLog(limit = 200, path: string = custodyLogPath()): CustodyRecord[] { try { - const path = custodyLogPath(); if (!existsSync(path)) return []; const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim()); const recs: CustodyRecord[] = []; diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts index 3eb7686..063ddb3 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -1,39 +1,57 @@ +// src/core/diagnostics.ts — `aether doctor` fast mode: read-only inspection. +// +// Fast mode makes no network call, spends no tokens, creates no session, +// launches no opener, refreshes no credential and writes no file. It answers +// "is this machine configured correctly", and it is explicit that it has not +// answered "does any of it work right now" — every remote axis reports +// `not-checked` rather than an optimistic pass. `--live` is what turns those +// into verified. + import { accessSync, constants, existsSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import type { AppContext } from "./context.js"; import { configDir } from "./config.js"; -import { isCredentialSafeUrl, MODELS_PATH } from "./transport.js"; +import { isCredentialSafeUrl } from "./transport.js"; import { gateActionFor } from "./autonomy.js"; import { validateToolDefinitionCoverage } from "./tool_registry.js"; import { localMemoryReport, type MemoryRoots } from "./memory.js"; import { LocalMcpStore } from "./mcp_store.js"; -import { McpClient } from "./mcp.js"; -import { bounded, collectMcpDiagnostics } from "./mcp_diagnostics.js"; - -export const DIAGNOSTIC_CONCURRENCY = 8; +import type { McpClient } from "./mcp.js"; +import { custodyLogPath } from "./custody.js"; +import { ghAvailable } from "./repo.js"; +import { planOpen } from "./opener.js"; +import { historyPaths, loadHistory } from "./media_history.js"; +import { + axis, + buildReport, + notApplicable, + notChecked, + renderHealthReport, + type HealthCheck, + type HealthReport, + type Severity, +} from "./health.js"; -export type DiagnosticStatus = "pass" | "warn" | "fail" | "skip"; +export { renderHealthReport as renderDiagnosticReport }; +export type { HealthReport, HealthCheck }; -export interface DiagnosticCheck { - id: string; - category: string; - status: DiagnosticStatus; - detail: string; - durationMs: number; -} +export const DIAGNOSTIC_CONCURRENCY = 8; -export interface DiagnosticReport { - schemaVersion: 1; - generatedAt: string; - deep: boolean; - checks: DiagnosticCheck[]; - summary: Record; +/** What a single check produces. Axes it does not speak to default sensibly. */ +export interface CheckOutcome { + configured: HealthCheck["configured"]; + reachable?: HealthCheck["reachable"]; + verified?: HealthCheck["verified"]; + severity: Severity; + repairId?: string; + remediation?: string; } export interface DiagnosticCheckSpec { id: string; category: string; - run(): Promise<{ status: DiagnosticStatus; detail: string }> | { status: DiagnosticStatus; detail: string }; + title: string; + run(): Promise | CheckOutcome; } export interface DiagnosticDependencies { @@ -41,19 +59,22 @@ export interface DiagnosticDependencies { mcpClient?: McpClient; mcpStore?: LocalMcpStore; memoryRoots?: MemoryRoots; + /** Where generated media lands. Injected so tests get a sandbox. */ + outputDir?: string; + custodyPath?: string; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; timeoutMs?: number; } -function monotonicMs(): number { - return Number(process.hrtime.bigint()) / 1_000_000; -} +const LIVE_HINT = "run: aether doctor --live"; export async function executeDiagnosticChecks( - specs: DiagnosticCheckSpec[], + specs: readonly DiagnosticCheckSpec[], concurrency = DIAGNOSTIC_CONCURRENCY, -): Promise { +): Promise { const width = Math.max(1, Math.min(Math.floor(concurrency), 32)); - const results: DiagnosticCheck[] = new Array(specs.length); + const results: HealthCheck[] = new Array(specs.length); let cursor = 0; const worker = async (): Promise => { for (;;) { @@ -61,23 +82,28 @@ export async function executeDiagnosticChecks( cursor += 1; if (index >= specs.length) return; const spec = specs[index]!; - const started = monotonicMs(); try { - const result = await spec.run(); + const outcome = await spec.run(); results[index] = { id: spec.id, category: spec.category, - status: result.status, - detail: result.detail, - durationMs: Math.max(0, monotonicMs() - started), + title: spec.title, + configured: outcome.configured, + reachable: outcome.reachable ?? notChecked(LIVE_HINT), + verified: outcome.verified ?? notChecked(LIVE_HINT), + severity: outcome.severity, + ...(outcome.repairId ? { repairId: outcome.repairId } : {}), + ...(outcome.remediation ? { remediation: outcome.remediation } : {}), }; } catch { results[index] = { id: spec.id, category: spec.category, - status: "fail", - detail: "check failed safely", - durationMs: Math.max(0, monotonicMs() - started), + title: spec.title, + configured: axis("unknown", { evidence: "check failed safely" }), + reachable: notChecked(LIVE_HINT), + verified: notChecked(LIVE_HINT), + severity: "error", }; } } @@ -92,103 +118,130 @@ function nearestExisting(path: string): string { return current; } -export async function diagnosticReport( +export function fastCheckSpecs( ctx: AppContext, - deep: boolean, dependencies: DiagnosticDependencies = {}, -): Promise { - const timeoutMs = Math.max(100, Math.min(dependencies.timeoutMs ?? 2000, 10000)); +): DiagnosticCheckSpec[] { const mcpStore = dependencies.mcpStore ?? new LocalMcpStore(); - const mcpClient = dependencies.mcpClient ?? new McpClient(ctx.api); - const specs: DiagnosticCheckSpec[] = [ + const outputDir = dependencies.outputDir ?? "./aether-output"; + const custody = dependencies.custodyPath ?? custodyLogPath(); + const platform = dependencies.platform ?? process.platform; + const env = dependencies.env ?? process.env; + + return [ { id: "runtime.node", category: "runtime", - run: () => { + title: "Node runtime", + run: (): CheckOutcome => { // Must track package.json's engines.node — this project's own test // script needs Node 24 (`--test --test-isolation` support). const MIN_SUPPORTED_NODE_MAJOR = 24; const major = Number(process.versions.node.split(".")[0]); + const ok = major >= MIN_SUPPORTED_NODE_MAJOR; return { - status: major >= MIN_SUPPORTED_NODE_MAJOR ? "pass" : "fail", - detail: - major >= MIN_SUPPORTED_NODE_MAJOR - ? "Node runtime supported" - : `Node ${MIN_SUPPORTED_NODE_MAJOR} or newer required`, + configured: axis(ok ? "yes" : "no", { evidence: `Node ${process.versions.node}` }), + reachable: notApplicable("local runtime"), + verified: axis(ok ? "yes" : "no"), + severity: ok ? "info" : "error", + ...(ok ? {} : { remediation: `install Node ${MIN_SUPPORTED_NODE_MAJOR} or newer` }), }; }, }, { id: "workspace.directory", category: "workspace", - run: () => { + title: "Workspace directory", + run: (): CheckOutcome => { const target = resolve(ctx.flags.cwd); const valid = existsSync(target) && statSync(target).isDirectory(); return { - status: valid ? "pass" : "fail", - detail: valid ? "workspace directory available" : "workspace directory unavailable", + configured: axis(valid ? "yes" : "no", { evidence: target }), + reachable: notApplicable("local path"), + verified: axis(valid ? "yes" : "no"), + severity: valid ? "info" : "error", }; }, }, { id: "workspace.git", category: "workspace", - run: () => ({ - status: existsSync(join(ctx.flags.cwd, ".git")) ? "pass" : "warn", - detail: existsSync(join(ctx.flags.cwd, ".git")) - ? "git metadata detected" - : "workspace is not a git checkout", - }), + title: "Git checkout", + run: (): CheckOutcome => { + const isRepo = existsSync(join(ctx.flags.cwd, ".git")); + return { + configured: axis(isRepo ? "yes" : "no", { + evidence: isRepo ? "git metadata detected" : "workspace is not a git checkout", + }), + reachable: notChecked("remote branch state is compared only in --live"), + verified: axis(isRepo ? "yes" : "na"), + severity: isRepo ? "info" : "warning", + }; + }, }, { - id: "configuration.transport", - category: "configuration", - run: () => ({ - status: isCredentialSafeUrl(ctx.cfg.baseUrl) ? "pass" : "fail", - detail: isCredentialSafeUrl(ctx.cfg.baseUrl) - ? "backend URL transport is credential-safe" - : "backend URL transport is unsafe", - }), + id: "agent.transport", + category: "agent", + title: "Agent transport", + run: (): CheckOutcome => { + const safe = isCredentialSafeUrl(ctx.cfg.baseUrl); + return { + // Configured means the URL is well-formed and credential-safe. It + // says nothing about whether anything answers there. + configured: axis(safe ? "yes" : "no", { evidence: ctx.cfg.baseUrl }), + severity: safe ? "info" : "error", + ...(safe ? {} : { remediation: "backend URL transport is unsafe for credentials" }), + }; + }, }, { - id: "authentication", + id: "auth.credential", category: "auth", - run: async () => { + title: "Authentication", + run: async (): Promise => { + // Reads the stored token; never refreshes, never contacts the issuer. const configured = Boolean(await ctx.tokens.get()); return { - status: configured ? "pass" : "warn", - detail: configured ? "credential configured" : "signed out", + configured: axis(configured ? "yes" : "no", { + evidence: configured ? "credential stored locally" : "signed out", + }), + severity: configured ? "info" : "warning", + ...(configured ? {} : { remediation: "run: aether auth login" }), }; }, }, { - id: "backend.catalog", - category: "backend", - run: async () => { - if (!deep) return { status: "skip", detail: "deep check not requested" }; - try { - await bounded(ctx.api.getJson(MODELS_PATH), timeoutMs); - return { status: "pass", detail: "bounded catalog request succeeded" }; - } catch { - return { status: "warn", detail: "backend catalog unavailable" }; - } - }, + id: "agent.catalog", + category: "agent", + title: "Capability manifest and model catalog", + run: (): CheckOutcome => ({ + configured: axis(isCredentialSafeUrl(ctx.cfg.baseUrl) ? "yes" : "no"), + severity: "info", + }), }, { id: "tools.schemas", category: "tools", - run: () => { + title: "Tool schema coverage", + run: (): CheckOutcome => { const errors = validateToolDefinitionCoverage(); return { - status: errors.length ? "fail" : "pass", - detail: errors.length ? "tool schema coverage incomplete" : "all protocol tools have schemas", + configured: axis(errors.length ? "no" : "yes", { + evidence: errors.length + ? "tool schema coverage incomplete" + : "all protocol tools have schemas", + }), + reachable: notApplicable("local registry"), + verified: axis(errors.length ? "no" : "yes"), + severity: errors.length ? "error" : "info", }; }, }, { id: "tools.gates", category: "tools", - run: () => { + title: "Side-effect gates", + run: (): CheckOutcome => { const valid = gateActionFor("write_file") === "write" && gateActionFor("run_shell") === "shell" && @@ -196,15 +249,20 @@ export async function diagnosticReport( gateActionFor("git_commit") === "shell" && gateActionFor("read_file") === null; return { - status: valid ? "pass" : "fail", - detail: valid ? "side-effect gates are fail-closed" : "tool gate mapping drifted", + configured: axis(valid ? "yes" : "no", { + evidence: valid ? "side-effect gates are fail-closed" : "tool gate mapping drifted", + }), + reachable: notApplicable("local registry"), + verified: axis(valid ? "yes" : "no"), + severity: valid ? "info" : "error", }; }, }, { id: "memory.health", category: "memory", - run: () => { + title: "Local memory stores", + run: (): CheckOutcome => { const report = localMemoryReport( ctx.flags.cwd, undefined, @@ -212,95 +270,207 @@ export async function diagnosticReport( dependencies.now, ); const sources = report.tiers.flatMap((tier) => tier.sources); - const failed = sources.some((source) => source.status === "degraded"); + const degraded = sources.some((source) => source.status === "degraded"); const unscoped = sources.reduce((sum, source) => sum + source.unscoped, 0); return { - status: failed ? "warn" : "pass", - detail: - (failed ? "one or more stores are degraded; " : "") + - unscoped + - " legacy unscoped record(s), never auto-injected", + configured: axis(degraded ? "no" : "yes", { + evidence: + (degraded ? "one or more stores are degraded; " : "") + + `${unscoped} legacy unscoped record(s), never auto-injected`, + }), + reachable: notApplicable("local store"), + verified: axis(degraded ? "no" : "yes"), + severity: degraded ? "warning" : "info", }; }, }, { id: "mcp.registry", category: "mcp", - run: () => { + title: "Local MCP registry", + run: (): CheckOutcome => { const state = mcpStore.inspect(); + const broken = state.status === "corrupt" || state.status === "unreadable"; return { - status: - state.status === "corrupt" || state.status === "unreadable" - ? "fail" - : state.status === "missing" - ? "warn" - : "pass", - detail: "local MCP registry " + state.status, + configured: axis(broken ? "no" : state.status === "missing" ? "unknown" : "yes", { + evidence: `local MCP registry ${state.status}`, + }), + reachable: notApplicable("local file"), + verified: axis(broken ? "no" : "yes"), + severity: broken ? "error" : state.status === "missing" ? "warning" : "info", + ...(broken ? { remediation: "run: aether mcp repair" } : {}), }; }, }, { id: "mcp.broker", category: "mcp", - run: async () => { - if (!deep) return { status: "skip", detail: "deep check not requested" }; - const report = await collectMcpDiagnostics(mcpClient, mcpStore, { - timeoutMs, - includeToolCounts: true, - now: dependencies.now, - }); + title: "MCP broker", + run: (): CheckOutcome => { + const registered = mcpStore.inspect().servers.length; return { - status: report.brokerStatus === "available" ? "pass" : "warn", - detail: - report.brokerStatus === "available" - ? report.providers.length + " provider(s) diagnosed" - : "broker unavailable", + configured: axis(registered ? "yes" : "unknown", { + evidence: `${registered} server(s) registered`, + }), + severity: "info", }; }, }, { id: "persistence.local", category: "persistence", - run: () => { + title: "Local persistence root", + run: (): CheckOutcome => { try { accessSync(nearestExisting(configDir()), constants.R_OK | constants.W_OK); - return { status: "pass", detail: "local persistence root is readable and writable" }; + return { + configured: axis("yes", { evidence: configDir() }), + reachable: notApplicable("local path"), + verified: axis("yes"), + severity: "info", + }; + } catch { + return { + configured: axis("no", { evidence: "local persistence root is unavailable" }), + reachable: notApplicable("local path"), + verified: axis("no"), + severity: "error", + repairId: "state.directories", + }; + } + }, + }, + { + id: "media.history", + category: "media", + title: "Media output history", + run: (): CheckOutcome => { + const paths = historyPaths(outputDir); + if (!existsSync(paths.primary)) { + return { + configured: axis("unknown", { evidence: "no generations recorded yet" }), + reachable: notApplicable("local index"), + verified: axis("na"), + severity: "info", + }; + } + const load = loadHistory(paths, dependencies.now); + const healthy = load.state === "ok" || load.state === "migrated"; + return { + configured: axis(healthy ? "yes" : "no", { + evidence: + `schema v${load.doc.schemaVersion}, ${load.doc.entries.length} retained, ` + + `next reference ${load.doc.nextSequence}`, + }), + reachable: notApplicable("local index"), + verified: axis(healthy ? "yes" : "no", { + evidence: load.warning ? load.warning.message : `history state: ${load.state}`, + }), + severity: load.state === "degraded" ? "error" : healthy ? "info" : "warning", + ...(healthy ? {} : { repairId: "media.rebuild" }), + }; + }, + }, + { + id: "opener.local", + category: "opener", + title: "Browser and file opener", + run: (): CheckOutcome => { + // planOpen validates and selects the command WITHOUT spawning, which + // is what makes this safe in a read-only mode. + const plan = planOpen("https://example.invalid/", { platform, env }); + const ok = plan.status === "spawned"; + return { + configured: axis(ok ? "yes" : "no", { + evidence: ok ? `${plan.executable} (argument array, no shell)` : plan.detail, + }), + reachable: notChecked("a real open is proven only in --live"), + verified: notChecked("a real open is proven only in --live"), + severity: plan.status === "unavailable" ? "warning" : ok ? "info" : "error", + }; + }, + }, + { + id: "github.connection", + category: "github", + title: "GitHub identity", + run: (): CheckOutcome => { + // Presence of the binary only. `gh auth status` is a network call and + // belongs in --live. + const hasGh = ghAvailable(); + return { + configured: axis(hasGh ? "yes" : "no", { + evidence: hasGh ? "local gh CLI present" : "gh CLI not on PATH", + }), + severity: hasGh ? "info" : "warning", + ...(hasGh ? {} : { remediation: "install the GitHub CLI to publish branches and PRs" }), + }; + }, + }, + { + id: "custody.receipts", + category: "custody", + title: "Protocol-C receipt persistence", + run: (): CheckOutcome => { + try { + accessSync(nearestExisting(dirname(custody)), constants.R_OK | constants.W_OK); + return { + configured: axis("yes", { evidence: custody }), + reachable: notApplicable("local log"), + verified: notChecked("a signed receipt round trip runs only in --live"), + severity: "info", + }; } catch { - return { status: "fail", detail: "local persistence root is unavailable" }; + return { + configured: axis("no", { evidence: "receipt log directory is not writable" }), + reachable: notApplicable("local log"), + verified: axis("no"), + severity: "error", + repairId: "state.directories", + }; } }, }, + { + id: "actions.dispatch", + category: "automation", + title: "GitHub Actions dispatch", + run: (): CheckOutcome => ({ + // This CLI has no workflow-dispatch surface. Saying so is honest; + // reporting a green pass for something that does not exist is not. + configured: notApplicable("this build has no workflow-dispatch surface"), + reachable: notApplicable("this build has no workflow-dispatch surface"), + verified: notApplicable("this build has no workflow-dispatch surface"), + severity: "info", + }), + }, + { + id: "predator.readiness", + category: "automation", + title: "Predator readiness", + run: (): CheckOutcome => ({ + configured: notApplicable("this build has no Predator client"), + reachable: notApplicable("this build has no Predator client"), + verified: notApplicable("this build has no Predator client"), + severity: "info", + }), + }, ]; +} - const checks = await executeDiagnosticChecks(specs); - const summary: Record = { pass: 0, warn: 0, fail: 0, skip: 0 }; - for (const check of checks) summary[check.status] += 1; - return { - schemaVersion: 1, - generatedAt: dependencies.now ?? new Date().toISOString(), - deep, - checks, - summary, - }; +export interface DiagnosticOptions { + dependencies?: DiagnosticDependencies; } -export function renderDiagnosticReport(report: DiagnosticReport): string { - const lines = [ - "Aether doctor v" + report.schemaVersion + (report.deep ? " (deep)" : ""), - ]; - for (const check of report.checks) { - lines.push(" [" + check.status + "] " + check.id + ": " + check.detail); - } - lines.push( - "Summary: " + - report.summary.pass + - " pass, " + - report.summary.warn + - " warn, " + - report.summary.fail + - " fail, " + - report.summary.skip + - " skip", - ); - return lines.join("\n") + "\n"; +/** + * Fast, read-only health report. Performs no network call, no model call, no + * session creation, no opener launch and no write. + */ +export async function diagnosticReport( + ctx: AppContext, + options: DiagnosticOptions = {}, +): Promise { + const dependencies = options.dependencies ?? {}; + const checks = await executeDiagnosticChecks(fastCheckSpecs(ctx, dependencies)); + return buildReport("fast", checks, dependencies.now ?? new Date().toISOString()); } diff --git a/src/core/doctor_live.ts b/src/core/doctor_live.ts new file mode 100644 index 0000000..0a3b3f9 --- /dev/null +++ b/src/core/doctor_live.ts @@ -0,0 +1,991 @@ +// src/core/doctor_live.ts — `aether doctor --live`: prove the paths, now. +// +// Fast mode answers "is this configured". This answers "does it work", and it +// only ever claims what it actually exercised. Three rules shape everything +// here: +// +// 1. No spend. Billing state is accounted across the run and every probe +// asserts it did not move. The Agent-loop proof runs only when the server +// confirms a non-billable doctor session; otherwise it reports +// not-checked and says why. A skipped check is never a pass. +// 2. No orphans. Sessions, temp files and the loopback server are torn down +// in `finally`. +// 3. No guessing. Where the surface to prove something does not exist yet +// (an MCP tool nobody marked doctor-safe, a server that does not honour +// the doctor purpose), that is reported as unproven, not assumed good. + +import { randomUUID } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AppContext } from "./context.js"; +import type { CatalogItem } from "../types.js"; +import { + DEV_SESSIONS_PATH, + MODELS_PATH, + devSessionControlPath, + devSessionPath, + devSessionStreamPath, + devSessionToolResultsPath, +} from "./transport.js"; +import { buildDevSessionRequest } from "./envelope.js"; +import { decodeSse, type StreamFrame } from "./stream.js"; +import { TOOLS } from "./brain_protocol.js"; +import { DEV_PROTOCOL_VERSION } from "./brain_cloud.js"; +import { appendCustody, readCustodyLog } from "./custody.js"; +import { McpClient } from "./mcp.js"; +import { LocalMcpStore } from "./mcp_store.js"; +import { bounded } from "./mcp_diagnostics.js"; +import { openTarget, type OpenOptions } from "./opener.js"; +import { defaultRunner, ghAuthStatus, type Runner } from "./worktree.js"; +import { + axis, + buildReport, + notApplicable, + notChecked, + type Axis, + type HealthCheck, + type HealthReport, + type Severity, +} from "./health.js"; + +const DEFAULT_PROBE_TIMEOUT_MS = 10_000; +const DEFAULT_OPENER_TIMEOUT_MS = 15_000; + +export interface LiveOptions { + now?: () => string; + /** Per-probe bound. A broken service must never hang the CLI. */ + timeoutMs?: number; + openerTimeoutMs?: number; + /** --no-ui: no desktop to open into, so the opener proof reports skipped. */ + headless?: boolean; + runId?: string; + cwd?: string; + mcpClient?: McpClient; + mcpStore?: LocalMcpStore; + runner?: Runner; + openerOptions?: OpenOptions; + /** Test seam: skip binding a real loopback socket. */ + skipOpenerProbe?: boolean; +} + +/** What a probe reports before it is folded into a HealthCheck. */ +interface ProbeResult { + configured?: Axis; + reachable?: Axis; + verified?: Axis; + severity: Severity; + remediation?: string; +} + +interface Probe { + id: string; + category: string; + title: string; +} + +function check(probe: Probe, result: ProbeResult): HealthCheck { + return { + id: probe.id, + category: probe.category, + title: probe.title, + configured: result.configured ?? axis("yes"), + reachable: result.reachable ?? notChecked("not exercised by this run"), + verified: result.verified ?? notChecked("not exercised by this run"), + severity: result.severity, + ...(result.remediation ? { remediation: result.remediation } : {}), + }; +} + +function since(startedMs: number): number { + return Math.max(0, Date.now() - startedMs); +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +// ═════════════════════════════════════════════════════════════════════ +// Billing ledger — the no-spend invariant +// ═════════════════════════════════════════════════════════════════════ + +/** + * Every uvt/cents figure this run observed on the wire. The doctor path is + * supposed to move none of it; if any frame reports otherwise, that is an + * error, not a footnote. + */ +export class SpendLedger { + private uvt = 0; + private cents = 0; + private opened = 0; + private closed = 0; + + observe(frame: StreamFrame): void { + if (frame.type === "usage" || frame.type === "done") { + this.uvt += Number(frame.uvt ?? 0); + this.cents += Number(frame.cents ?? 0); + } + } + + sessionOpened(): void { + this.opened += 1; + } + + sessionClosed(): void { + this.closed += 1; + } + + get spent(): boolean { + return this.uvt > 0 || this.cents > 0; + } + + get orphaned(): number { + return Math.max(0, this.opened - this.closed); + } + + summary(): string { + return `uvt ${this.uvt}, cents ${this.cents}, sessions opened ${this.opened}, closed ${this.closed}`; + } +} + +// ═════════════════════════════════════════════════════════════════════ +// Agent session proof +// ═════════════════════════════════════════════════════════════════════ + +/** + * The dev-session create response. `purpose` and `billable` are the doctor + * contract: a server that supports a non-billable health session echoes + * `purpose: "doctor"` and `billable: false`. Until it does, this client will + * not run the agent loop — see agentProbes(). + */ +interface DevSessionCreated { + session_id: string; + purpose?: string; + billable?: boolean; +} + +export interface AgentProofState { + sessionId: string | null; + /** Frame sequence numbers in the order they arrived. */ + seqs: number[]; + sawSessionFrame: boolean; + pauseAcked: boolean; + resumeAcked: boolean; + steerAcked: boolean; + toolResultAcked: boolean; + closed: boolean; + nonce: string; +} + +/** Strictly increasing, which is what the resume-from-lastSeq contract needs. */ +export function seqIsMonotonic(seqs: readonly number[]): boolean { + for (let i = 1; i < seqs.length; i++) { + if (seqs[i]! <= seqs[i - 1]!) return false; + } + return true; +} + +const AGENT_PROBES = { + session: { id: "agent.session", category: "agent", title: "Agent dev session" }, + frames: { id: "agent.frames", category: "agent", title: "Agent frame sequence" }, + control: { id: "agent.session.control", category: "agent", title: "Agent session control" }, + tool: { id: "agent.tool.roundtrip", category: "agent", title: "Agent tool round trip" }, + close: { id: "agent.session.close", category: "agent", title: "Agent session close" }, +} as const; + +const DOCTOR_SESSION_CONTRACT = + 'the server must accept a non-billable doctor session (request purpose:"doctor", max_uvt:0; ' + + 'response echoing purpose:"doctor", billable:false) before the agent loop can be proven without spending'; + +export function agentUnproven(reason: string): HealthCheck[] { + return Object.values(AGENT_PROBES).map((probe) => + check(probe, { + configured: axis("yes"), + reachable: notChecked(reason), + verified: notChecked(reason), + severity: "warning", + remediation: DOCTOR_SESSION_CONTRACT, + }), + ); +} + +/** + * Exercise create -> frames -> pause -> resume -> steer -> tool round trip -> + * close, in that order. The sequence is deliberately serial: a parallel + * version could not tell a missing pause acknowledgement from a racing one. + */ +async function agentProbes( + ctx: AppContext, + ledger: SpendLedger, + options: LiveOptions, + sandbox: string, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const nonce = options.runId ?? randomUUID(); + + let created: DevSessionCreated; + const startedCreate = Date.now(); + try { + const request = { + ...buildDevSessionRequest({ + task: `aether doctor health probe ${nonce}`, + capabilities: TOOLS, + protocolVersion: DEV_PROTOCOL_VERSION, + }), + // The doctor contract. A server that does not understand it will either + // ignore these fields (caught by the billable check below) or reject. + purpose: "doctor", + max_uvt: 0, + }; + created = await bounded( + ctx.api.postJson(DEV_SESSIONS_PATH, request), + timeoutMs, + ); + } catch (err) { + return agentUnproven(`dev session could not be created: ${message(err)}`); + } + + if (!created?.session_id) { + return agentUnproven("dev session response carried no session_id"); + } + ledger.sessionOpened(); + + // Fail closed. If the server has not confirmed this is non-billable, close + // immediately rather than run a probe that might cost the user money. + if (created.purpose !== "doctor" || created.billable !== false) { + await closeSession(ctx, created.session_id, ledger, timeoutMs); + return agentUnproven( + "the server created a session but did not confirm it as a non-billable doctor session; " + + "closed it immediately rather than risk a billed probe", + ); + } + + const state: AgentProofState = { + sessionId: created.session_id, + seqs: [], + sawSessionFrame: false, + pauseAcked: false, + resumeAcked: false, + steerAcked: false, + toolResultAcked: false, + closed: false, + nonce, + }; + + const checks: HealthCheck[] = [ + check(AGENT_PROBES.session, { + reachable: axis("yes", { + checkedAt: new Date().toISOString(), + latencyMs: since(startedCreate), + }), + verified: axis("yes", { + checkedAt: new Date().toISOString(), + evidence: `non-billable doctor session ${created.session_id.slice(0, 8)}`, + }), + severity: "info", + }), + ]; + + try { + await drive(ctx, state, ledger, { ...options, timeoutMs }, sandbox); + checks.push(framesCheck(state), controlCheck(state), toolCheck(state)); + } catch (err) { + const reason = `agent loop aborted: ${message(err)}`; + checks.push( + check(AGENT_PROBES.frames, { verified: axis("no", { evidence: reason }), severity: "error" }), + check(AGENT_PROBES.control, { verified: notChecked(reason), severity: "warning" }), + check(AGENT_PROBES.tool, { verified: notChecked(reason), severity: "warning" }), + ); + } finally { + state.closed = await closeSession(ctx, created.session_id, ledger, timeoutMs); + } + + checks.push( + check(AGENT_PROBES.close, { + verified: axis(state.closed ? "yes" : "no", { + checkedAt: new Date().toISOString(), + evidence: state.closed + ? "session closed, no lease left behind" + : "session did not close cleanly", + }), + severity: state.closed ? "info" : "error", + }), + ); + return checks; +} + +export function framesCheck(state: AgentProofState): HealthCheck { + const ok = state.seqs.length > 0 && seqIsMonotonic(state.seqs); + return check(AGENT_PROBES.frames, { + reachable: axis(state.seqs.length ? "yes" : "no"), + verified: axis(ok ? "yes" : "no", { + checkedAt: new Date().toISOString(), + evidence: + `${state.seqs.length} frame(s), seq ${state.seqs[0] ?? "-"}..${state.seqs.at(-1) ?? "-"}` + + (state.sawSessionFrame ? ", session frame received" : ", no session frame"), + }), + severity: ok ? "info" : "error", + }); +} + +export function controlCheck(state: AgentProofState): HealthCheck { + const ok = state.pauseAcked && state.resumeAcked && state.steerAcked; + return check(AGENT_PROBES.control, { + verified: axis(ok ? "yes" : "no", { + checkedAt: new Date().toISOString(), + evidence: [ + `pause ${state.pauseAcked ? "acked" : "unacked"}`, + `resume ${state.resumeAcked ? "acked" : "unacked"}`, + `steer ${state.steerAcked ? "acked" : "unacked"}`, + ].join(", "), + }), + severity: ok ? "info" : "error", + }); +} + +export function toolCheck(state: AgentProofState): HealthCheck { + return check(AGENT_PROBES.tool, { + verified: axis(state.toolResultAcked ? "yes" : "no", { + checkedAt: new Date().toISOString(), + evidence: state.toolResultAcked + ? "sandbox write/read/compare/delete completed and the result was acknowledged" + : "the tool result was not acknowledged by the session protocol", + }), + severity: state.toolResultAcked ? "info" : "error", + }); +} + +/** + * Read the frame stream, issuing control actions at known points and + * answering any tool_call inside a doctor-owned temp directory. + */ +async function drive( + ctx: AppContext, + state: AgentProofState, + ledger: SpendLedger, + options: LiveOptions, + sandbox: string, +): Promise { + const id = state.sessionId!; + const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const net = new AbortController(); + const deadline = setTimeout(() => net.abort(), timeoutMs); + + try { + const stream = await ctx.api.stream(devSessionStreamPath(id, 0), undefined, { + method: "GET", + signal: net.signal, + }); + + let controlIssued = false; + for await (const frame of decodeSse(stream)) { + ledger.observe(frame); + if (typeof frame.seq === "number") state.seqs.push(frame.seq); + if (frame.type === "session") state.sawSessionFrame = true; + + if (frame.type === "tool_call") { + await answerToolCall(ctx, id, frame, sandbox, timeoutMs); + } + if (frame.type === "tool_result_ack") state.toolResultAcked = true; + + // Issue the control sequence once the session is live. Ordering matters: + // a resume before a pause proves nothing. + if (!controlIssued) { + controlIssued = true; + state.pauseAcked = await control(ctx, id, "pause", null, timeoutMs); + state.resumeAcked = await control(ctx, id, "resume", null, timeoutMs); + state.steerAcked = await control(ctx, id, "steer", `doctor-${state.nonce}`, timeoutMs); + } + + if (frame.type === "done" || frame.type === "error") break; + } + } finally { + clearTimeout(deadline); + net.abort(); + } +} + +async function control( + ctx: AppContext, + id: string, + action: "pause" | "resume" | "steer", + note: string | null, + timeoutMs: number, +): Promise { + try { + await bounded(ctx.api.postJson(devSessionControlPath(id), { action, note }), timeoutMs); + return true; + } catch { + return false; + } +} + +/** + * Answer a tool_call entirely inside the doctor sandbox: write a random value, + * read it back, compare, delete. Nothing outside `sandbox` is ever touched, + * whatever the server asked for. + */ +async function answerToolCall( + ctx: AppContext, + id: string, + frame: Extract, + sandbox: string, + timeoutMs: number, +): Promise { + const probeFile = join(sandbox, `roundtrip-${frame.toolCallId.replace(/[^\w-]/g, "_")}.txt`); + const secret = randomUUID(); + let ok = false; + try { + writeFileSync(probeFile, secret, { encoding: "utf8", mode: 0o600 }); + ok = readFileSync(probeFile, "utf8") === secret; + } finally { + rmSync(probeFile, { force: true }); + } + try { + await bounded( + ctx.api.postJson(devSessionToolResultsPath(id), { + tool_call_id: frame.toolCallId, + ok, + output: ok ? "doctor sandbox round trip verified" : "doctor sandbox round trip failed", + }), + timeoutMs, + ); + } catch { + // A refused result surfaces as the missing tool_result_ack, not here. + } +} + +async function closeSession( + ctx: AppContext, + id: string, + ledger: SpendLedger, + timeoutMs: number, +): Promise { + try { + await bounded(ctx.api.deleteJson(devSessionPath(id)), timeoutMs); + ledger.sessionClosed(); + return true; + } catch { + return false; + } +} + +// ═════════════════════════════════════════════════════════════════════ +// Catalog +// ═════════════════════════════════════════════════════════════════════ + +async function catalogProbe(ctx: AppContext, timeoutMs: number): Promise { + const probe = { + id: "agent.catalog", + category: "agent", + title: "Capability manifest and model catalog", + }; + const started = Date.now(); + try { + const items = await bounded(ctx.api.getJson(MODELS_PATH), timeoutMs); + const list = Array.isArray(items) ? items : []; + const wellFormed = list.every( + (item) => typeof item?.id === "string" && typeof item?.kind === "string", + ); + const ok = wellFormed && list.length > 0; + return check(probe, { + reachable: axis("yes", { checkedAt: new Date().toISOString(), latencyMs: since(started) }), + verified: axis(ok ? "yes" : "no", { + checkedAt: new Date().toISOString(), + evidence: `${list.length} catalog item(s), ${list.filter((i) => i?.available).length} available to this tier`, + }), + severity: ok ? "info" : "error", + }); + } catch (err) { + return check(probe, { + reachable: axis("no", { checkedAt: new Date().toISOString(), evidence: message(err) }), + verified: axis("no"), + severity: "error", + }); + } +} + +// ═════════════════════════════════════════════════════════════════════ +// Opener loopback proof +// ═════════════════════════════════════════════════════════════════════ + +const OPENER_PROBE = { id: "opener.local", category: "opener", title: "Browser and file opener" }; + +/** + * Prove the production opener actually launched something, by opening a + * loopback page and waiting for that page to call back with our nonce. A + * headless session has no desktop, so it reports skipped rather than passing. + */ +export async function openerProbe(options: LiveOptions = {}): Promise { + const skipReason = options.headless + ? "headless session (--no-ui): no desktop to open into" + : options.skipOpenerProbe + ? "opener probe disabled for this run" + : null; + if (skipReason) { + return check(OPENER_PROBE, { + reachable: notChecked(skipReason), + verified: notChecked(skipReason), + severity: "info", + }); + } + + const nonce = randomUUID(); + const timeoutMs = options.openerTimeoutMs ?? DEFAULT_OPENER_TIMEOUT_MS; + const started = Date.now(); + let server: Server | null = null; + + try { + let resolveCalled: (value: boolean) => void = () => {}; + const called = new Promise((resolve) => { + resolveCalled = resolve; + }); + + server = createServer((req, res) => { + if (req.url?.includes(`/cb?nonce=${nonce}`)) { + res.writeHead(204).end(); + resolveCalled(true); + return; + } + // The page the browser lands on immediately calls back, which is what + // proves a real browser rendered it rather than merely spawned. + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end( + `Aether doctor` + + `

Aether doctor verified your browser. You can close this tab.

` + + ``, + ); + }); + + 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 { + // close() alone only stops accepting NEW connections — a browser holding + // the page open on keep-alive would keep the handle (and the CLI) alive. + server?.closeAllConnections(); + 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/src/core/doctor_repair.ts b/src/core/doctor_repair.ts new file mode 100644 index 0000000..40393e8 --- /dev/null +++ b/src/core/doctor_repair.ts @@ -0,0 +1,378 @@ +// src/core/doctor_repair.ts — `aether doctor --fix`: allowlisted local repair. +// +// This is deliberately not a "repair my project" agent. Every repair is a named +// entry in REPAIRS with a fixed scope, and the planner can only ever return +// entries from that list. Nothing here rotates a credential, spends anything, +// touches tracked source, or mutates a git ref — see FORBIDDEN, which exists so +// those exclusions are testable rather than merely documented. + +import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { configDir } from "./config.js"; +import { custodyLogPath } from "./custody.js"; +import { isLockStale, preserveCorrupt, readJsonFile, type LockOwner } from "./durable_store.js"; +import { historyPaths, loadHistory } from "./media_history.js"; +import { repersist } from "./media_history_store.js"; +import type { HealthReport } from "./health.js"; + +export interface RepairContext { + outputDir?: string; + configRoot?: string; + custodyPath?: string; + cwd?: string; + now?: string; + platform?: NodeJS.Platform; + /** Files older than this may be treated as abandoned. */ + staleMs?: number; +} + +type ResolvedContext = RepairContext & + Required>; + +export interface RepairPlanItem { + id: string; + title: string; + /** The exact paths or refs this repair will touch. */ + scope: string[]; + action: string; + risk: "low" | "medium"; + reversible: boolean; + backup: string; +} + +export interface RepairResult { + id: string; + applied: boolean; + detail: string; + preserved?: string; +} + +/** + * Operations `--fix` must never perform. Kept as data so each one has a + * negative test instead of living only in a comment. + */ +export const FORBIDDEN: readonly string[] = [ + "credential rotation or deletion", + "login, OAuth consent, or permission escalation", + "UVT reservation, spend, or settlement", + "model invocation", + "source edits, formatting, or dependency updates", + "git commit, reset, clean, checkout, merge, rebase, push, pull, or branch deletion", + "remote ref mutation", + "GitHub Actions dispatch", + "Predator runs", + "MCP write-tool invocation", +]; + +const DEFAULT_STALE_MS = 60 * 60 * 1000; + +interface Repair { + id: string; + title: string; + action: string; + risk: "low" | "medium"; + reversible: boolean; + backup: string; + /** Paths this repair would touch, or [] when there is nothing to do. */ + scope(ctx: ResolvedContext): string[]; + apply(ctx: ResolvedContext): RepairResult; +} + +function resolveContext(ctx: RepairContext): ResolvedContext { + return { + ...ctx, + outputDir: ctx.outputDir ?? "./aether-output", + configRoot: ctx.configRoot ?? configDir(), + custodyPath: ctx.custodyPath ?? custodyLogPath(), + cwd: ctx.cwd ?? process.cwd(), + }; +} + +function ageMs(path: string, now: number): number { + try { + return Math.max(0, now - statSync(path).mtimeMs); + } catch { + return 0; + } +} + +function clockOf(ctx: RepairContext): number { + return ctx.now ? Date.parse(ctx.now) : Date.now(); +} + +function staleLockPaths(ctx: ResolvedContext): string[] { + const lock = historyPaths(ctx.outputDir).lock; + if (!existsSync(lock)) return []; + const owner = readJsonFile(lock); + const stale = isLockStale( + owner.ok ? owner.value : null, + ageMs(lock, clockOf(ctx)), + ctx.staleMs ?? DEFAULT_STALE_MS, + ); + return stale ? [lock] : []; +} + +function staleTemps(ctx: ResolvedContext): string[] { + if (!existsSync(ctx.outputDir)) return []; + const threshold = ctx.staleMs ?? DEFAULT_STALE_MS; + const clock = clockOf(ctx); + let names: string[]; + try { + names = readdirSync(ctx.outputDir); + } catch { + return []; + } + return names + .filter((name) => name.startsWith(".") && name.endsWith(".tmp")) + .map((name) => join(ctx.outputDir, name)) + .filter((path) => ageMs(path, clock) >= threshold); +} + +function prunableWorktrees(cwd: string): string[] { + let raw: string; + try { + raw = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd, + encoding: "utf8", + timeout: 5_000, + }); + } catch { + return []; + } + const missing: string[] = []; + for (const line of raw.split(/\r?\n/)) { + if (!line.startsWith("worktree ")) continue; + const path = line.slice("worktree ".length); + if (path && !existsSync(path)) missing.push(path); + } + return missing; +} + +function missingStateDirs(ctx: ResolvedContext): string[] { + return [ctx.configRoot, dirname(ctx.custodyPath), ctx.outputDir].filter( + (dir) => !existsSync(dir), + ); +} + +function looseStateDirs(ctx: ResolvedContext): string[] { + if ((ctx.platform ?? process.platform) === "win32") return []; + // Only the Aether-owned roots. Never a recursive chmod over a workspace. + return [ctx.configRoot, dirname(ctx.custodyPath)].filter((dir) => { + if (!existsSync(dir)) return false; + try { + return (statSync(dir).mode & 0o077) !== 0; + } catch { + return false; + } + }); +} + +export const REPAIRS: readonly Repair[] = [ + { + id: "state.directories", + title: "Create missing Aether state directories", + action: "mkdir with owner-only permissions", + risk: "low", + reversible: true, + backup: "not needed — creates only, never replaces", + scope: missingStateDirs, + apply: (ctx): RepairResult => { + const missing = missingStateDirs(ctx); + for (const dir of missing) mkdirSync(dir, { recursive: true, mode: 0o700 }); + return { + id: "state.directories", + applied: missing.length > 0, + detail: missing.length ? `created ${missing.join(", ")}` : "nothing missing", + }; + }, + }, + { + id: "state.permissions", + title: "Tighten permissions on Aether-owned state", + action: "chmod 0700 on the Aether state roots only", + risk: "low", + reversible: true, + backup: "not needed — mode change only", + scope: looseStateDirs, + apply: (ctx): RepairResult => { + if ((ctx.platform ?? process.platform) === "win32") { + return { + id: "state.permissions", + applied: false, + detail: "Windows ACLs remain authoritative; no mode change attempted", + }; + } + const targets = looseStateDirs(ctx); + for (const dir of targets) chmodSync(dir, 0o700); + return { + id: "state.permissions", + applied: targets.length > 0, + detail: targets.length ? `tightened ${targets.join(", ")}` : "nothing to tighten", + }; + }, + }, + { + id: "state.stale-locks", + title: "Remove provably abandoned state locks", + action: "unlink a lock whose owner process is gone", + risk: "low", + reversible: false, + backup: "not needed — a lock holds no data", + scope: staleLockPaths, + apply: (ctx): RepairResult => { + const targets = staleLockPaths(ctx); + if (!targets.length) { + return { id: "state.stale-locks", applied: false, detail: "no abandoned lock found" }; + } + for (const lock of targets) rmSync(lock, { force: true }); + return { id: "state.stale-locks", applied: true, detail: `removed ${targets.join(", ")}` }; + }, + }, + { + id: "state.temp", + title: "Remove abandoned transaction temp files", + action: "unlink .tmp files older than the stale threshold", + risk: "low", + reversible: false, + backup: "not needed — a temp is an incomplete write", + scope: staleTemps, + apply: (ctx): RepairResult => { + const targets = staleTemps(ctx); + for (const path of targets) rmSync(path, { force: true }); + return { + id: "state.temp", + applied: targets.length > 0, + detail: targets.length ? `removed ${targets.length} abandoned temp file(s)` : "none found", + }; + }, + }, + { + id: "media.rebuild", + title: "Rebuild the media output index", + action: "recover from backup or from files on disk, preserving the corrupt original", + risk: "medium", + reversible: true, + backup: "the unreadable primary is kept as .corrupt.", + scope: (ctx): string[] => { + const paths = historyPaths(ctx.outputDir); + if (!existsSync(paths.primary)) return []; + return loadHistory(paths, ctx.now).state === "ok" ? [] : [paths.primary]; + }, + apply: (ctx): RepairResult => { + const paths = historyPaths(ctx.outputDir); + if (!existsSync(paths.primary) || loadHistory(paths, ctx.now).state === "ok") { + return { id: "media.rebuild", applied: false, detail: "media history is already healthy" }; + } + // Preserve first, then rewrite. preserveCorrupt copies, so the evidence + // survives even if the rewrite then fails. + const preserved = preserveCorrupt(paths.primary, ctx.now) ?? undefined; + const recovered = repersist(paths, ctx.now ? { now: ctx.now } : {}); + return { + id: "media.rebuild", + applied: true, + detail: `rebuilt from ${recovered.state}; ${recovered.doc.entries.length} entr(ies) recovered`, + ...(preserved ? { preserved } : {}), + }; + }, + }, + { + id: "git.worktrees", + title: "Prune git worktree metadata for directories that no longer exist", + action: "git worktree prune — metadata only, never a checkout", + risk: "low", + reversible: false, + backup: "not needed — prunes only entries git itself reports as gone", + scope: (ctx): string[] => prunableWorktrees(ctx.cwd), + apply: (ctx): RepairResult => { + const prunable = prunableWorktrees(ctx.cwd); + if (!prunable.length) { + return { id: "git.worktrees", applied: false, detail: "no abandoned worktree metadata" }; + } + // `git worktree prune` removes administrative records for worktrees whose + // directory is already gone. It never touches a present worktree, a dirty + // tree, the current worktree, or any branch. + try { + execFileSync("git", ["worktree", "prune"], { + cwd: ctx.cwd, + encoding: "utf8", + timeout: 10_000, + }); + } catch { + return { id: "git.worktrees", applied: false, detail: "git worktree prune failed" }; + } + return { + id: "git.worktrees", + applied: true, + detail: `pruned metadata for ${prunable.length} missing worktree(s)`, + }; + }, + }, +]; + +/** Housekeeping repairs are scoped by their own preconditions, not by a check. */ +const ALWAYS_CONSIDERED = ["state.permissions", "state.stale-locks", "state.temp", "git.worktrees"]; + +/** + * The repairs that apply right now. Only checks that named a `repairId` can + * pull a targeted repair in, and every repair must independently agree it has + * work to do — so a stale report can never authorise a mutation. + */ +export function planRepairs( + report: HealthReport, + context: RepairContext = {}, + only?: readonly string[], +): RepairPlanItem[] { + const ctx = resolveContext(context); + const requested = new Set(ALWAYS_CONSIDERED); + for (const check of report.checks) if (check.repairId) requested.add(check.repairId); + + return REPAIRS.filter((repair) => requested.has(repair.id)) + .filter((repair) => !only || only.includes(repair.id)) + .map((repair) => ({ repair, scope: repair.scope(ctx) })) + .filter(({ scope }) => scope.length > 0) + .map(({ repair, scope }) => ({ + id: repair.id, + title: repair.title, + scope, + action: repair.action, + risk: repair.risk, + reversible: repair.reversible, + backup: repair.backup, + })); +} + +/** + * Apply one repair by id. Preconditions are re-checked inside `apply`, so a + * plan built before an unrelated change cannot force a mutation that is no + * longer warranted. + */ +export function applyRepair(id: string, context: RepairContext = {}): RepairResult { + const repair = REPAIRS.find((entry) => entry.id === id); + if (!repair) { + return { id, applied: false, detail: `no such repair: ${id} is not in the allowlist` }; + } + return repair.apply(resolveContext(context)); +} + +export function repairIds(): string[] { + return REPAIRS.map((repair) => repair.id); +} + +export function renderRepairPlan(plan: readonly RepairPlanItem[]): string { + if (!plan.length) return "Nothing to repair.\n"; + const lines = ["Repair plan — nothing runs until you confirm:", ""]; + for (const item of plan) { + lines.push(` ${item.id} ${item.title}`); + lines.push(` action ${item.action}`); + lines.push(` scope ${item.scope.join("\n ")}`); + lines.push( + ` risk ${item.risk}${item.reversible ? ", reversible" : ", not reversible"}`, + ); + lines.push(` backup ${item.backup}`); + lines.push(""); + } + lines.push("Never performed by --fix:"); + for (const item of FORBIDDEN) lines.push(` · ${item}`); + return lines.join("\n") + "\n"; +} 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/health.ts b/src/core/health.ts new file mode 100644 index 0000000..4130f0a --- /dev/null +++ b/src/core/health.ts @@ -0,0 +1,185 @@ +// src/core/health.ts — the result model `aether doctor` reports against. +// +// v1 collapsed every check onto one axis: pass / warn / fail / skip. That made +// "the backend URL is well-formed" and "the backend answered just now" both +// read as a green `pass`, which is exactly the confusion a health command +// exists to remove. v2 splits the claim into three: +// +// configured the local setup names this thing +// reachable something answered at that address +// verified now the path was exercised end to end during THIS run +// +// A check that was not run reports `not-checked`, never a pass. Cached +// evidence carries the timestamp it was gathered at and is never rendered as +// current verification. + +export const DOCTOR_SCHEMA_VERSION = 2; + +export type DoctorMode = "fast" | "live" | "fix"; + +/** + * `na` means the axis cannot apply here (there is no such surface in this + * build). `not-checked` means it applies but this run did not test it. + */ +export type AxisState = "yes" | "no" | "unknown" | "na" | "not-checked"; + +export interface Axis { + state: AxisState; + /** Only ever set from the current run. */ + checkedAt?: string; + latencyMs?: number; + evidence?: string; +} + +export type Severity = "info" | "warning" | "error"; + +export interface HealthCheck { + id: string; + category: string; + title: string; + configured: Axis; + reachable: Axis; + verified: Axis; + severity: Severity; + /** Names an allowlisted `doctor --fix` repair that addresses this check. */ + repairId?: string; + remediation?: string; +} + +export interface HealthSummary { + error: number; + warning: number; + info: number; + notChecked: number; +} + +export interface HealthReport { + schemaVersion: number; + mode: DoctorMode; + generatedAt: string; + checks: HealthCheck[]; + summary: HealthSummary; +} + +export function axis(state: AxisState, extra: Omit = {}): Axis { + return { state, ...extra }; +} + +/** An axis this build has no surface for. Never a pass, never a failure. */ +export function notApplicable(evidence: string): Axis { + return { state: "na", evidence }; +} + +/** An axis that applies but was not exercised by this run. */ +export function notChecked(evidence: string): Axis { + return { state: "not-checked", evidence }; +} + +// ═════════════════════════════════════════════════════════════════════ +// Redaction +// ═════════════════════════════════════════════════════════════════════ + +const SECRET_PATTERNS: readonly RegExp[] = [ + /\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{16,}/g, + /\bsk-[A-Za-z0-9_-]{16,}/g, + /\bxox[baprs]-[A-Za-z0-9-]{8,}/g, + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g, + /\bAKIA[0-9A-Z]{16}\b/g, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, +]; + +/** + * Strip anything credential-shaped, plus the query string of any URL — signed + * media and download URLs carry their authorisation there. Applied to every + * evidence string before it reaches stdout, a log, or the JSON report. + */ +export function redact(value: string): string { + let out = value; + for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, "[redacted]"); + out = out.replace(/(https?:\/\/)[^/\s:@]+:[^/\s@]+@/gi, "$1[redacted]@"); + out = out.replace(/(https?:\/\/[^\s?]*)\?[^\s]*/gi, "$1?[redacted]"); + return out; +} + +function redactAxis(value: Axis): Axis { + return value.evidence ? { ...value, evidence: redact(value.evidence) } : value; +} + +/** Apply redaction across a whole check. Called once, at report assembly. */ +export function redactCheck(check: HealthCheck): HealthCheck { + return { + ...check, + configured: redactAxis(check.configured), + reachable: redactAxis(check.reachable), + verified: redactAxis(check.verified), + ...(check.remediation ? { remediation: redact(check.remediation) } : {}), + }; +} + +export function summarize(checks: readonly HealthCheck[]): HealthSummary { + const summary: HealthSummary = { error: 0, warning: 0, info: 0, notChecked: 0 }; + for (const check of checks) { + summary[check.severity] += 1; + if (check.verified.state === "not-checked" || check.reachable.state === "not-checked") { + summary.notChecked += 1; + } + } + return summary; +} + +export function buildReport( + mode: DoctorMode, + checks: readonly HealthCheck[], + generatedAt: string, +): HealthReport { + const redacted = checks.map(redactCheck); + return { + schemaVersion: DOCTOR_SCHEMA_VERSION, + mode, + generatedAt, + checks: redacted, + summary: summarize(redacted), + }; +} + +// ═════════════════════════════════════════════════════════════════════ +// Rendering +// ═════════════════════════════════════════════════════════════════════ + +const AXIS_LABEL: Record = { + yes: "yes", + no: "no", + unknown: "unknown", + na: "n/a", + "not-checked": "not checked", +}; + +function axisLine(label: string, value: Axis): string { + const parts = [AXIS_LABEL[value.state]]; + if (value.checkedAt) parts.push("· " + value.checkedAt.slice(11, 19)); + if (typeof value.latencyMs === "number") parts.push(`· ${Math.round(value.latencyMs)}ms`); + const head = ` ${label.padEnd(14)}${parts.join(" ")}`; + return value.evidence ? `${head}\n${" ".repeat(16)}${value.evidence}` : head; +} + +export function renderHealthReport(report: HealthReport): string { + const lines = [`Aether doctor v${report.schemaVersion} (${report.mode})`, ""]; + for (const check of report.checks) { + const flag = check.severity === "error" ? "✗" : check.severity === "warning" ? "!" : " "; + lines.push(`${flag} ${check.title} [${check.id}]`); + lines.push(axisLine("configured", check.configured)); + lines.push(axisLine("reachable", check.reachable)); + lines.push(axisLine("verified now", check.verified)); + if (check.remediation) lines.push(` → ${check.remediation}`); + if (check.repairId) lines.push(` → repairable: aether doctor --fix --only ${check.repairId}`); + lines.push(""); + } + lines.push( + `Summary: ${report.summary.error} error, ${report.summary.warning} warning, ` + + `${report.summary.info} ok, ${report.summary.notChecked} not verified`, + ); + if (report.mode === "fast") { + lines.push("Remote reachability is not checked in fast mode — run: aether doctor --live"); + } + return lines.join("\n") + "\n"; +} 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/diagnostics.test.ts b/test/diagnostics.test.ts index 50ab3f6..92eae6b 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -102,9 +102,10 @@ test("diagnostic executor preserves declaration order under concurrency", async const spec = (id: string, delay: number): DiagnosticCheckSpec => ({ id, category: "test", + title: id, run: async () => { await new Promise((resolve) => setTimeout(resolve, delay)); - return { status: "pass", detail: id }; + return { configured: { state: "yes" as const, evidence: id }, severity: "info" as const }; }, }); const checks = await executeDiagnosticChecks( @@ -112,59 +113,94 @@ test("diagnostic executor preserves declaration order under concurrency", async 3, ); assert.deepEqual(checks.map((check) => check.id), ["first", "second", "third"]); - assert.deepEqual(checks.map((check) => check.status), ["pass", "pass", "pass"]); + assert.deepEqual(checks.map((check) => check.configured.state), ["yes", "yes", "yes"]); + // An axis a check does not speak to defaults to not-checked, never to a pass. + assert.deepEqual(checks.map((check) => check.verified.state), [ + "not-checked", + "not-checked", + "not-checked", + ]); }); -test("default doctor is fast/local, ordered, fail-soft, and content-redacted", async () => { +test("fast doctor is local, ordered, fail-soft, and content-redacted", async () => { const { ctx, roots, store, client, counters } = setup(); - const report = await diagnosticReport(ctx, false, { - now: "2026-07-10T00:00:00.000Z", - memoryRoots: roots, - mcpStore: store, - mcpClient: client, - timeoutMs: 50, + const report = await diagnosticReport(ctx, { + dependencies: { + now: "2026-07-10T00:00:00.000Z", + memoryRoots: roots, + mcpStore: store, + mcpClient: client, + outputDir: mkdtempSync(join(tmpdir(), "aether-doctor-media-")), + timeoutMs: 50, + }, }); + assert.equal(report.schemaVersion, 2); + assert.equal(report.mode, "fast"); assert.deepEqual(report.checks.map((check) => check.id), [ "runtime.node", "workspace.directory", "workspace.git", - "configuration.transport", - "authentication", - "backend.catalog", + "agent.transport", + "auth.credential", + "agent.catalog", "tools.schemas", "tools.gates", "memory.health", "mcp.registry", "mcp.broker", "persistence.local", + "media.history", + "opener.local", + "github.connection", + "custody.receipts", + "actions.dispatch", + "predator.readiness", ]); - assert.equal(report.checks.find((check) => check.id === "backend.catalog")?.status, "skip"); - assert.equal(report.checks.find((check) => check.id === "mcp.broker")?.status, "skip"); + // The whole point of fast mode: nothing remote was contacted, and the report + // says so instead of implying otherwise. assert.deepEqual(counters, { backend: 0, broker: 0 }); + assert.equal( + report.checks.find((check) => check.id === "agent.transport")?.reachable.state, + "not-checked", + ); + assert.equal( + report.checks.find((check) => check.id === "agent.catalog")?.verified.state, + "not-checked", + ); assert.equal(JSON.stringify(report).includes(SECRET), false); assert.match( - report.checks.find((check) => check.id === "memory.health")?.detail ?? "", + report.checks.find((check) => check.id === "memory.health")?.configured.evidence ?? "", /never auto-injected/, ); }); -test("deep doctor performs bounded backend and broker/catalog checks", async () => { +test("a surface this build does not have reports n/a, not a pass", async () => { + const { ctx, roots, store, client } = setup(); + const report = await diagnosticReport(ctx, { + dependencies: { memoryRoots: roots, mcpStore: store, mcpClient: client }, + }); + for (const id of ["actions.dispatch", "predator.readiness"]) { + const check = report.checks.find((entry) => entry.id === id); + assert.equal(check?.verified.state, "na", `${id} must not claim a pass`); + assert.match(String(check?.verified.evidence), /this build has no/); + } +}); + +test("fast doctor contacts nothing even when --deep is passed", async () => { const { ctx, roots, store, client, counters } = setup(); - const report = await diagnosticReport(ctx, true, { - memoryRoots: roots, - mcpStore: store, - mcpClient: client, - timeoutMs: 100, + const captured = sink(); + const code = await cmdDoctor(ctx, ["--deep"], { + out: captured.out, + dependencies: { memoryRoots: roots, mcpStore: store, mcpClient: client }, }); - assert.equal(report.deep, true); - assert.equal(report.checks.find((check) => check.id === "backend.catalog")?.status, "pass"); - assert.equal(report.checks.find((check) => check.id === "mcp.broker")?.status, "pass"); - assert.equal(counters.backend, 1); - assert.equal(counters.broker, 3); - assert.equal(JSON.stringify(report).includes(SECRET), false); + assert.equal(code, 0); + assert.deepEqual(counters, { backend: 0, broker: 0 }, "--deep stayed read-only"); + // --deep must keep meaning what it meant, and point at the new mode. + assert.match(captured.text(), /--deep is the read-only report \(unchanged\)/); + assert.match(captured.text(), /aether doctor --live/); }); -test("deep doctor bounds unavailable API and broker timeouts", async () => { +test("a hanging backend cannot stall the fast report", async () => { const { ctx, roots, store } = setup(); ctx.api = { getJson: async () => new Promise(() => {}) } as unknown as AppContext["api"]; const hanging = { @@ -173,15 +209,34 @@ test("deep doctor bounds unavailable API and broker timeouts", async () => { listTools: async () => new Promise(() => {}), } as unknown as McpClient; const started = Date.now(); - const report = await diagnosticReport(ctx, true, { - memoryRoots: roots, - mcpStore: store, - mcpClient: hanging, - timeoutMs: 50, + const report = await diagnosticReport(ctx, { + dependencies: { memoryRoots: roots, mcpStore: store, mcpClient: hanging, timeoutMs: 50 }, }); assert.ok(Date.now() - started < 500); - assert.equal(report.checks.find((check) => check.id === "backend.catalog")?.status, "warn"); - assert.equal(report.checks.find((check) => check.id === "mcp.broker")?.status, "warn"); + assert.equal(report.mode, "fast"); +}); + +test("--live renders the live report, never the fast one relabelled", async () => { + const { ctx, roots, store, client } = setup(); + const captured = sink(); + 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 }, + }); + 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 () => { @@ -195,11 +250,14 @@ test("doctor command supports JSON and rejects unknown arguments", async () => { mcpStore: store, mcpClient: client, now: "2026-07-10T00:00:00.000Z", + outputDir: mkdtempSync(join(tmpdir(), "aether-doctor-json-")), }, }); assert.equal(code, 0); const parsed = JSON.parse(captured.text()); - assert.equal(parsed.schemaVersion, 1); + assert.equal(parsed.schemaVersion, 2); + assert.equal(parsed.mode, "fast"); + assert.equal(parsed.generatedAt, "2026-07-10T00:00:00.000Z"); assert.equal(captured.text().includes(SECRET), false); const bad = sink(); 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); +}); diff --git a/test/doctor_repair.test.ts b/test/doctor_repair.test.ts new file mode 100644 index 0000000..c3bbd90 --- /dev/null +++ b/test/doctor_repair.test.ts @@ -0,0 +1,215 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyRepair, + FORBIDDEN, + planRepairs, + renderRepairPlan, + repairIds, + REPAIRS, + type RepairContext, +} from "../src/core/doctor_repair.js"; +import { historyPaths, loadHistory } from "../src/core/media_history.js"; +import { appendEntry, type AppendInput } from "../src/core/media_history_store.js"; +import { buildReport, type HealthCheck, type HealthReport } from "../src/core/health.js"; + +const NOW = "2026-08-14T19:42:08.194Z"; +const LONG_AGO = new Date("2020-01-01T00:00:00.000Z"); + +function sandbox(): Required> & + RepairContext { + const root = mkdtempSync(join(tmpdir(), "aether-repair-")); + return { + outputDir: join(root, "aether-output"), + configRoot: join(root, "config"), + custodyPath: join(root, "custody", "custody.jsonl"), + cwd: root, + now: NOW, + staleMs: 1_000, + }; +} + +function artifact(displayName: string): AppendInput { + return { + kind: "image", + displayName, + filePath: `/synthetic/${displayName}`, + url: "https://example.invalid/a.png", + model: "vision_nano_pro", + prompt: "synthetic", + sizeBytes: 1, + }; +} + +function reportWith(repairId?: string): HealthReport { + const check: HealthCheck = { + id: "media.history", + category: "media", + title: "Media output history", + configured: { state: "no" }, + reachable: { state: "na" }, + verified: { state: "no" }, + severity: "error", + ...(repairId ? { repairId } : {}), + }; + return buildReport("fix", [check], NOW); +} + +test("the allowlist is closed — an unknown repair id is refused", () => { + const result = applyRepair("rm -rf /", sandbox()); + assert.equal(result.applied, false); + assert.match(result.detail, /not in the allowlist/); +}); + +test("every planned repair comes from the allowlist", () => { + const plan = planRepairs(reportWith("media.rebuild"), sandbox()); + const allowed = new Set(repairIds()); + for (const item of plan) assert.ok(allowed.has(item.id), `${item.id} is not allowlisted`); +}); + +test("no repair touches credentials, source, or git refs", () => { + // The exclusions are data, so each one is asserted rather than merely + // documented. If a repair is ever added that does one of these, the list + // and this test are what has to change first. + const banned = [ + /credential/i, + /token/i, + /rotate/i, + /login/i, + /oauth/i, + /uvt|spend|settle/i, + /model invocation/i, + /git (commit|reset|clean|checkout|merge|rebase|push|pull)/i, + /branch deletion/i, + /dispatch/i, + /predator/i, + ]; + for (const repair of REPAIRS) { + const described = `${repair.id} ${repair.title} ${repair.action}`; + for (const pattern of banned) { + assert.equal(pattern.test(described), false, `${repair.id} describes a forbidden action`); + } + } + // `git worktree prune` is the one git call, and it is metadata-only. + const worktree = REPAIRS.find((repair) => repair.id === "git.worktrees"); + assert.match(String(worktree?.action), /metadata only, never a checkout/); + assert.ok(FORBIDDEN.length >= 10); +}); + +test("the plan is shown with exact scope, risk and backup before anything runs", () => { + const plan = planRepairs(reportWith("state.directories"), sandbox()); + const rendered = renderRepairPlan(plan); + assert.match(rendered, /nothing runs until you confirm/); + assert.match(rendered, /Never performed by --fix/); + for (const item of plan) { + assert.ok(item.scope.length > 0, `${item.id} was planned with an empty scope`); + assert.match(rendered, new RegExp(item.id.replace(".", "\\."))); + } +}); + +test("state.directories creates only the missing Aether roots", () => { + const ctx = sandbox(); + assert.equal(existsSync(ctx.configRoot), false); + assert.equal(applyRepair("state.directories", ctx).applied, true); + assert.equal(existsSync(ctx.configRoot), true); + assert.equal(existsSync(ctx.outputDir), true); + + // Idempotent: a second run has nothing to do. + assert.equal(applyRepair("state.directories", ctx).applied, false); +}); + +test("a live lock is left alone; only a provably abandoned one is removed", () => { + const ctx = sandbox(); + applyRepair("state.directories", ctx); + const paths = historyPaths(ctx.outputDir); + + // Owner is this very process — not abandoned. + writeFileSync( + paths.lock, + JSON.stringify({ pid: process.pid, host: hostname(), startedAt: NOW, label: "media-history" }), + ); + // Age is measured against the injected clock, so pin the mtime to it. + utimesSync(paths.lock, new Date(NOW), new Date(NOW)); + assert.equal(applyRepair("state.stale-locks", ctx).applied, false); + assert.equal(existsSync(paths.lock), true, "a live lock must survive"); + + // Same stamp, but old enough to be provably abandoned. + utimesSync(paths.lock, LONG_AGO, LONG_AGO); + assert.equal(applyRepair("state.stale-locks", ctx).applied, true); + assert.equal(existsSync(paths.lock), false); +}); + +test("only abandoned transaction temps are removed", () => { + const ctx = sandbox(); + applyRepair("state.directories", ctx); + const fresh = join(ctx.outputDir, ".genlog.json.1.1.tmp"); + const stale = join(ctx.outputDir, ".genlog.json.2.1.tmp"); + const keep = join(ctx.outputDir, "hero.png"); + writeFileSync(fresh, "x"); + writeFileSync(stale, "x"); + writeFileSync(keep, "x"); + // Age is measured against the injected clock, so pin both mtimes rather than + // letting the wall clock decide which side of the threshold they land on. + utimesSync(fresh, new Date(NOW), new Date(NOW)); + utimesSync(stale, LONG_AGO, LONG_AGO); + + assert.equal(applyRepair("state.temp", ctx).applied, true); + assert.equal(existsSync(stale), false); + assert.equal(existsSync(fresh), true, "an in-flight write must not be swept"); + assert.equal(existsSync(keep), true, "a real artifact must never be touched"); +}); + +test("media.rebuild preserves the corrupt original and restores a valid index", () => { + const ctx = sandbox(); + applyRepair("state.directories", ctx); + const paths = historyPaths(ctx.outputDir); + appendEntry(paths, artifact("first.png"), { now: NOW }); + appendEntry(paths, artifact("second.png"), { now: NOW }); + writeFileSync(paths.primary, "not json at all"); + + const result = applyRepair("media.rebuild", ctx); + assert.equal(result.applied, true); + assert.ok(result.preserved, "the unreadable original must be kept as evidence"); + assert.equal(readFileSync(String(result.preserved), "utf8"), "not json at all"); + assert.equal(loadHistory(paths, NOW).state, "ok"); + assert.ok(readdirSync(ctx.outputDir).some((name) => name.includes(".corrupt."))); +}); + +test("media.rebuild is a no-op on a healthy index", () => { + const ctx = sandbox(); + applyRepair("state.directories", ctx); + appendEntry(historyPaths(ctx.outputDir), artifact("ok.png"), { now: NOW }); + + const result = applyRepair("media.rebuild", ctx); + assert.equal(result.applied, false); + assert.match(result.detail, /already healthy/); +}); + +test("a repair is re-checked at apply time, so a stale plan cannot force a mutation", () => { + const ctx = sandbox(); + applyRepair("state.directories", ctx); + const paths = historyPaths(ctx.outputDir); + writeFileSync(paths.primary, "not json at all"); + + const plan = planRepairs(reportWith("media.rebuild"), ctx); + assert.ok(plan.some((item) => item.id === "media.rebuild")); + + // The condition resolves before the plan is applied. + appendEntry(paths, artifact("ok.png"), { now: NOW }); + assert.equal(applyRepair("media.rebuild", ctx).applied, false); +}); + +test("a check with no repairId never pulls in a targeted repair", () => { + const plan = planRepairs(reportWith(undefined), sandbox()); + assert.equal(plan.some((item) => item.id === "media.rebuild"), false); +}); 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,