From fb99c8d92379c67ba4d690fccd10e45e3aec65df Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 23 Aug 2026 10:58:09 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(amico-run):=20doctor=20v2=20surface=20?= =?UTF-8?q?inventory=20=E2=80=94=20six=20probes,=20current-cell=20tracer?= =?UTF-8?q?=20(#525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit surfaces.ts: server-binary, extension, vendored-binary, staged-skills, agent-cards-global, agent-cards-staging probes over an injectable SurfaceContext. Verdicts by version string or content digest only — never mtime; version-sorted 'newest' selection; probes degrade individually. Hermetic current-world fixture: fake binaries print pinned far-future build dates, git fixtures carry pinned commit dates, remotes are local bare repos. --- packages/amico-run/src/surfaces.ts | 625 +++++++++++++++++++++++ packages/amico-run/test/surfaces.test.ts | 245 +++++++++ 2 files changed, 870 insertions(+) create mode 100644 packages/amico-run/src/surfaces.ts create mode 100644 packages/amico-run/test/surfaces.test.ts diff --git a/packages/amico-run/src/surfaces.ts b/packages/amico-run/src/surfaces.ts new file mode 100644 index 00000000..ff98898b --- /dev/null +++ b/packages/amico-run/src/surfaces.ts @@ -0,0 +1,625 @@ +// surfaces.ts — doctor v2's surface inventory (#525, spec-20260823-094507 D1): +// FIVE physical fleet surfaces, SIX records (agent cards are global + staging +// deployments of one source). Every record carries surface, installed/running +// version, source-of-truth version, verdict, evidence. +// +// Invariants (spec D1 + the 2026-08-08 mtime-race lesson): +// - NO staleness judgment ever reads an mtime — version strings or content +// digests, always. "Newest" always means version-sorted. +// - Probes degrade INDIVIDUALLY: one unreachable source of truth degrades +// that surface to `unknown`, never fails the report. Local hard facts +// (sidecar mismatch, absent process, running≠frozen) outrank `unknown`; +// `unknown` means "source of truth unreachable, nothing local to say". +// - Read-only w.r.t. the fleet surfaces. Doctor's `git fetch` is a +// source-of-truth refresh (remote-tracking refs + tags in the SOURCE +// repos), explicitly not a surface mutation. No working tree is touched. +// - Absent SURFACE (e.g. no process, no staged dir) → `stale` with absence +// evidence — the repairable state. Absent SOURCE OF TRUTH → `unknown`. +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export type Verdict = "current" | "stale" | "integrity-failure" | "unknown"; + +export const SURFACE_ORDER = [ + "server-binary", + "extension", + "vendored-binary", + "staged-skills", + "agent-cards-global", + "agent-cards-staging", +] as const; + +export type SurfaceName = (typeof SURFACE_ORDER)[number]; + +export interface SurfaceRecord { + surface: SurfaceName; + /** installed/running version (or set digest for digest-identified surfaces) */ + version: string | null; + /** source-of-truth version (or set digest) */ + source_version: string | null; + verdict: Verdict; + /** digests, version strings, reason codes — at least one line, always */ + evidence: string[]; +} + +export interface SurfacesReport { + surfaces: SurfaceRecord[]; +} + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +/** Injectable process runner. Never throws — failures are {code != 0}. */ +export type Exec = (file: string, args: string[]) => Promise; + +export interface SurfaceContext { + /** server dir holding bin/opencode + bin/opencode.sha256 (default ~/.amico/server) */ + rootServer: string; + /** VS Code extensions dir (default ~/.vscode/extensions) */ + rootVscext: string; + /** opencode config root; global cards at /agents (default ~/.config/opencode) */ + rootConfig: string; + /** amicode repo checkout (default ~/armonia/repos/amicode) */ + rootRepoAmicode: string; + /** opencode fork checkout, branch local/amicode (default ~/armonia/repos/opencode) */ + rootRepoFork: string; + /** staged opencode-project dir (default /opencode-project-staging/opencode-project) */ + rootStaging: string; + /** running-process evidence stub; null = discover via ps */ + runningBinary: string | null; + /** vendor platform dir name, e.g. "darwin-arm64" */ + platform: string; + run: Exec; + discoverRunning: () => Promise; +} + +const GIT_TIMEOUT_MS = 30_000; + +const realExec: Exec = (file, args) => + new Promise((resolve) => { + execFile(file, args, { timeout: GIT_TIMEOUT_MS }, (err, stdout, stderr) => { + const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; + resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }); + }); + }); + +/** ps-based live discovery of the running server binary (default; hermetic + * fixtures inject --running-binary or their own discoverRunning). */ +async function defaultDiscoverRunning(): Promise { + const r = await realExec("ps", ["-axo", "command="]); + if (r.code !== 0) return null; + for (const line of r.stdout.split("\n")) { + if (!/\bopencode serve\b/.test(line)) continue; + const bin = line.trim().split(/\s+/)[0] ?? ""; + if (bin.includes("opencode")) return bin; + } + return null; +} + +export function defaultSurfaceContext(): SurfaceContext { + const rootServer = process.env.AMICO_SERVER_DIR ?? join(homedir(), ".amico", "server"); + return { + rootServer, + rootVscext: join(homedir(), ".vscode", "extensions"), + rootConfig: join(homedir(), ".config", "opencode"), + rootRepoAmicode: join(homedir(), "armonia", "repos", "amicode"), + rootRepoFork: join(homedir(), "armonia", "repos", "opencode"), + rootStaging: join(rootServer, "opencode-project-staging", "opencode-project"), + runningBinary: null, + platform: `${process.platform}-${process.arch}`, + run: realExec, + discoverRunning: defaultDiscoverRunning, + }; +} + +// ── small helpers ──────────────────────────────────────────────────────────── + +const sha256hex = (buf: Buffer): string => createHash("sha256").update(buf).digest("hex"); + +async function readFileSafe(p: string): Promise { + try { + return await readFile(p, "utf8"); + } catch { + return null; + } +} + +async function fileSha(p: string): Promise { + try { + return sha256hex(await readFile(p)); + } catch { + return null; + } +} + +/** Natural version compare (digits numerically, runs otherwise lexicographically). + * Returns <0, 0, >0. Handles "0.2.6", "1.18.10-amicode.15", "v1.18.12". */ +export function compareVersions(a: string, b: string): number { + const tok = (s: string) => s.match(/\d+|[^\d]+/g) ?? []; + const ta = tok(a); + const tb = tok(b); + const n = Math.max(ta.length, tb.length); + for (let i = 0; i < n; i++) { + const xa = ta[i]; + const xb = tb[i]; + if (xa === undefined) return -1; + if (xb === undefined) return 1; + const da = /^\d+$/.test(xa); + const db = /^\d+$/.test(xb); + if (da && db) { + const va = Number(xa); + const vb = Number(xb); + if (va !== vb) return va < vb ? -1 : 1; + } else if (da !== db) { + return da ? -1 : 1; // numeric chunks sort before alpha chunks + } else { + if (xa !== xb) return xa < xb ? -1 : 1; + } + } + return 0; +} + +/** The leading numeric version of a string: "0.2.4-darwin-arm64" → "0.2.4". */ +export function versionPrefix(s: string): string { + return s.match(/\d+(?:\.\d+)*/)?.[0] ?? ""; +} + +/** Build date embedded in a frozen-binary version string, e.g. + * "0.0.0-local/amicode-202608231309" → Date(2026-08-23T13:09Z). Null if absent. */ +export function parseBuildDate(version: string): Date | null { + const m = version.match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(?=\D|$)/); + if (!m) return null; + const [y, mo, d, h, mi] = m.slice(1).map(Number); + if (y < 2000) return null; // a 12-digit run from a year we never built in + const t = Date.UTC(y, mo - 1, d, h, mi); + return Number.isNaN(t) ? null : new Date(t); +} + +/** Deterministic content digest of a directory: sha256 over the sorted + * relative-path + file-bytes pairs. mtime-free by construction. */ +async function dirDigest(dir: string): Promise { + const files: string[] = []; + const walk = async (rel: string) => { + let entries: import("node:fs").Dirent[]; + try { + entries = await readdir(join(dir, rel), { withFileTypes: true }); + } catch { + return; + } + for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) { + const child = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) await walk(child); + else if (e.isFile()) files.push(child); + } + }; + await walk(""); + if (files.length === 0) return null; + const parts: string[] = []; + for (const f of files) { + const bytes = await readFileSafeBuffer(join(dir, f)); + if (bytes === null) return null; + parts.push(`${f}\0`); + parts.push(bytes.toString("binary")); + } + return sha256hex(Buffer.from(parts.join("\u0001"), "binary")); +} + +async function readFileSafeBuffer(p: string): Promise { + try { + return await readFile(p); + } catch { + return null; + } +} + +async function listSubdirs(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } +} + +async function listFiles(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }); + return entries.filter((e) => e.isFile()).map((e) => e.name); + } catch { + return []; + } +} + +/** Set digest over a name→digest map (sorted names) — the identity of a + * digest-identified surface (staged skills, agent cards). */ +function setDigest(items: Map): string { + const parts = [...items.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([name, digest]) => `${name}\0${digest}`); + return sha256hex(Buffer.from(parts.join("\u0001"), "binary")); +} + +const firstErrLine = (s: string, max = 200): string => + s.split("\n").map((l) => l.trim()).filter(Boolean).join(" ").slice(0, max); + +// ── shared source-of-truth refresh ────────────────────────────────────────── + +export interface FetchOutcome { + ok: boolean; + error?: string; +} + +/** `git fetch` in a SOURCE repo — writes remote-tracking refs (+ tags when + * asked) only; never touches a working tree. The vendored probe needs tags + * (release tags may live off-branch), so the fork fetch passes --tags. */ +async function fetchOrigin(run: Exec, repo: string, tags: boolean): Promise { + const args = ["-C", repo, "fetch", "origin"]; + if (tags) args.push("--tags"); + const r = await run("git", args); + return r.code === 0 + ? { ok: true } + : { ok: false, error: firstErrLine(r.stderr || r.stdout) || `exit ${r.code}` }; +} + +async function gitOutput(run: Exec, repo: string, args: string[]): Promise { + const r = await run("git", ["-C", repo, ...args]); + return r.code === 0 ? r.stdout : null; +} + +// ── extension-dir selection (version-sorted, never mtime) ────────────────── + +export interface NewestExtension { + dir: string; + /** raw suffix after "harmoniqs.amicode-", e.g. "0.2.4-darwin-arm64" */ + suffix: string; + /** numeric version prefix, e.g. "0.2.4" */ + version: string; +} + +export async function newestExtensionDir(rootVscext: string): Promise { + const dirs = await listSubdirs(rootVscext); + const exts = dirs.filter((d) => /^harmoniqs\.amicode-/.test(d)); + if (exts.length === 0) return null; + exts.sort((a, b) => { + const va = versionPrefix(a.replace(/^harmoniqs\.amicode-/, "")); + const vb = versionPrefix(b.replace(/^harmoniqs\.amicode-/, "")); + const byVersion = compareVersions(va, vb); + if (byVersion !== 0) return byVersion; + return compareVersions(a, b); + }); + const newest = exts[exts.length - 1]; + const suffix = newest.replace(/^harmoniqs\.amicode-/, ""); + return { dir: join(rootVscext, newest), suffix, version: versionPrefix(suffix) || suffix }; +} + +// ── the six probes ─────────────────────────────────────────────────────────── + +async function probeServerBinary(ctx: SurfaceContext, forkFetch: FetchOutcome): Promise { + const surface: SurfaceName = "server-binary"; + const frozen = join(ctx.rootServer, "bin", "opencode"); + const sidecarPath = `${frozen}.sha256`; + + const frozenSha = await fileSha(frozen); + if (frozenSha === null) { + return { surface, version: null, source_version: null, verdict: "stale", evidence: [`frozen binary missing: ${frozen}`] }; + } + + // integrity: the freeze contract is binary + sidecar pair + const sidecarText = await readFileSafe(sidecarPath); + if (sidecarText === null) { + return { surface, version: null, source_version: null, verdict: "integrity-failure", evidence: [`sidecar missing: ${sidecarPath}`, `frozen sha256 ${frozenSha}`] }; + } + const sidecarSha = sidecarText.match(/[0-9a-f]{64}/i)?.[0]?.toLowerCase() ?? null; + if (sidecarSha === null) { + return { surface, version: null, source_version: null, verdict: "integrity-failure", evidence: [`sidecar unreadable (no sha256 digest found): ${sidecarPath}`, `frozen sha256 ${frozenSha}`] }; + } + if (sidecarSha !== frozenSha) { + return { surface, version: null, source_version: null, verdict: "integrity-failure", evidence: [`frozen sha256 ${frozenSha} ≠ sidecar ${sidecarSha} (tampered binary or stale sidecar)`, `sidecar ${sidecarPath}`] }; + } + + const versionRun = await ctx.run(frozen, ["--version"]); + if (versionRun.code !== 0) { + return { surface, version: null, source_version: null, verdict: "integrity-failure", evidence: [`frozen binary --version failed (exit ${versionRun.code}): ${firstErrLine(versionRun.stderr)}`, `frozen sha256 ${frozenSha} = sidecar`] }; + } + const version = versionRun.stdout.trim().split("\n").pop() ?? ""; + + // running process (local facts) + const running = ctx.runningBinary ?? (await ctx.discoverRunning()); + if (!running) { + return { surface, version, source_version: null, verdict: "stale", evidence: ["server-down: no running opencode serve process found", `frozen ${version} sha256 ${frozenSha} = sidecar`] }; + } + const runningSha = await fileSha(running); + if (runningSha === null) { + return { surface, version, source_version: null, verdict: "stale", evidence: [`running binary unreadable: ${running}`, `frozen ${version} sha256 ${frozenSha} = sidecar`] }; + } + if (runningSha !== frozenSha) { + return { surface, version, source_version: null, verdict: "stale", evidence: [`running ${running} sha256 ${runningSha} ≠ frozen sha256 ${frozenSha} (restart pending)`, `frozen ${version} sha256 ${frozenSha} = sidecar`] }; + } + + // version staleness vs the fetched fork HEAD + if (!forkFetch.ok) { + return { surface, version, source_version: null, verdict: "unknown", evidence: [`fork fetch failed (source of truth unreachable): ${forkFetch.error}`, `local checks pass: frozen sha = sidecar, running sha = frozen sha`] }; + } + const headDateRaw = await gitOutput(ctx.run, ctx.rootRepoFork, ["log", "-1", "--format=%cI", "origin/local/amicode"]); + if (headDateRaw === null) { + return { surface, version, source_version: null, verdict: "unknown", evidence: ["fetched, but origin/local/amicode not found in the fork", `frozen ${version} sha256 ${frozenSha} = sidecar`] }; + } + const headDate = new Date(headDateRaw.trim()); + const headSha = (await gitOutput(ctx.run, ctx.rootRepoFork, ["rev-parse", "--short", "origin/local/amicode"]))?.trim() ?? ""; + const buildDate = parseBuildDate(version); + if (buildDate !== null && headDate.getTime() > 0 && buildDate.getTime() < headDate.getTime()) { + return { + surface, + version, + source_version: headDateRaw.trim(), + verdict: "stale", + evidence: [ + `build date ${buildDate.toISOString()} < HEAD commit date ${headDate.toISOString()} (origin/local/amicode ${headSha})`, + `frozen ${version} sha256 ${frozenSha} = sidecar; running sha = frozen sha`, + ], + }; + } + return { + surface, + version, + source_version: headDateRaw.trim(), + verdict: "current", + evidence: [ + `frozen ${version} sha256 ${frozenSha} = sidecar`, + `running ${running} sha256 = frozen sha256`, + `build date ${buildDate ? buildDate.toISOString() : "unparseable"} ≥ HEAD commit date ${headDate.toISOString()} (origin/local/amicode ${headSha})`, + ], + }; +} + +async function probeExtension(ctx: SurfaceContext, amicodeFetch: FetchOutcome): Promise { + const surface: SurfaceName = "extension"; + if (!amicodeFetch.ok) { + return { surface, version: null, source_version: null, verdict: "unknown", evidence: [`amicode fetch failed (source of truth unreachable): ${amicodeFetch.error}`] }; + } + const pkgRaw = await gitOutput(ctx.run, ctx.rootRepoAmicode, ["show", "origin/main:packages/extension/package.json"]); + let sourceVersion: string | null = null; + if (pkgRaw !== null) { + try { + sourceVersion = (JSON.parse(pkgRaw) as { version?: string }).version ?? null; + } catch { + sourceVersion = null; + } + } + if (pkgRaw === null || sourceVersion === null) { + return { surface, version: null, source_version: null, verdict: "unknown", evidence: ["origin/main packages/extension/package.json unreadable or has no version"] }; + } + const newest = await newestExtensionDir(ctx.rootVscext); + if (newest === null) { + return { surface, version: null, source_version: sourceVersion, verdict: "stale", evidence: [`no installed extension dir (harmoniqs.amicode-*) under ${ctx.rootVscext}`, `origin/main version ${sourceVersion}`] }; + } + const cmp = compareVersions(newest.version, sourceVersion); + const verdict: Verdict = cmp === 0 ? "current" : "stale"; + const direction = + cmp === 0 + ? `installed ${newest.version} = origin/main ${sourceVersion}` + : cmp < 0 + ? `installed ${newest.version} behind origin/main ${sourceVersion}` + : `installed ${newest.version} ahead of origin/main ${sourceVersion} (source repo behind?)`; + return { + surface, + version: newest.suffix, + source_version: sourceVersion, + verdict, + evidence: [`installed ${newest.suffix} — version-sorted newest under ${ctx.rootVscext}`, direction], + }; +} + +async function probeVendoredBinary(ctx: SurfaceContext, forkFetch: FetchOutcome): Promise { + const surface: SurfaceName = "vendored-binary"; + if (!forkFetch.ok) { + return { surface, version: null, source_version: null, verdict: "unknown", evidence: [`fork fetch failed (release tags not refreshable): ${forkFetch.error}`] }; + } + const tagsRaw = await gitOutput(ctx.run, ctx.rootRepoFork, ["tag", "--list"]); + const releaseTags = (tagsRaw ?? "").split("\n").map((t) => t.trim()).filter(Boolean).filter((t) => /^v\d+\.\d+\.\d+-amicode\.\d+$/.test(t)); + if (releaseTags.length === 0) { + return { surface, version: null, source_version: null, verdict: "unknown", evidence: ["no fork release tags (v-amicode.) in the fetched fork"] }; + } + releaseTags.sort((a, b) => compareVersions(a.replace(/^v/, ""), b.replace(/^v/, ""))); + const newestTag = releaseTags[releaseTags.length - 1]; + const baseVersion = newestTag.replace(/^v(\d+\.\d+\.\d+)-amicode\.\d+$/, "$1"); + + const bin = join(ctx.rootRepoAmicode, "packages", "extension", "vendor", "opencode", ctx.platform, "opencode"); + const binSha = await fileSha(bin); + if (binSha === null) { + return { surface, version: null, source_version: baseVersion, verdict: "stale", evidence: [`vendored binary missing: ${bin}`, `latest fork release tag ${newestTag} (base ${baseVersion})`] }; + } + const vr = await ctx.run(bin, ["--version"]); + if (vr.code !== 0) { + return { surface, version: null, source_version: baseVersion, verdict: "stale", evidence: [`vendored binary --version failed (exit ${vr.code}): ${firstErrLine(vr.stderr)}`, `latest fork release tag ${newestTag} (base ${baseVersion})`] }; + } + const printed = vr.stdout.trim().split("\n").pop() ?? ""; + const cmp = compareVersions(printed, baseVersion); + return { + surface, + version: printed, + source_version: baseVersion, + verdict: cmp === 0 ? "current" : "stale", + evidence: [ + `vendored ${bin} --version ${printed}`, + `latest fork release tag ${newestTag} (base ${baseVersion})`, + cmp === 0 ? `version = release tag base` : cmp < 0 ? `version behind release tag base ${baseVersion}` : `version ahead of release tag base ${baseVersion}`, + ], + }; +} + +async function probeStagedSkills(ctx: SurfaceContext): Promise { + const surface: SurfaceName = "staged-skills"; + const newest = await newestExtensionDir(ctx.rootVscext); + const sourceSkillsDir = newest ? join(newest.dir, "skills") : null; + const sourceSkills = sourceSkillsDir ? await listSubdirs(sourceSkillsDir) : []; + if (newest === null || sourceSkills.length === 0) { + return { surface, version: null, source_version: null, verdict: "unknown", evidence: [`missing local source: no VSIX skills set (no harmoniqs.amicode-* dir with skills/ under ${ctx.rootVscext})`] }; + } + const stagedDir = join(ctx.rootStaging, "skills"); + const stagedSkills = await listSubdirs(stagedDir); + if (stagedSkills.length === 0) { + return { surface, version: null, source_version: null, verdict: "stale", evidence: [`staged skills dir missing or empty: ${stagedDir}`, `source: VSIX ${newest.suffix} skills set (${sourceSkills.length} skills)`] }; + } + const sourceDigests = new Map(); + const stagedDigests = new Map(); + const diffs: string[] = []; + for (const skill of sourceSkills) { + const src = await dirDigest(join(sourceSkillsDir!, skill)); + const dst = await dirDigest(join(stagedDir, skill)); + if (src === null) continue; // unreadable source skill: not the staged surface's verdict + sourceDigests.set(skill, src); + if (dst === null) diffs.push(`skill ${skill} missing from staged set`); + else { + stagedDigests.set(skill, dst); + if (dst !== src) diffs.push(`skill ${skill} changed (staged ${dst.slice(0, 12)} ≠ VSIX ${src.slice(0, 12)})`); + } + } + const sourceSet = setDigest(sourceDigests); + const stagedSet = setDigest(stagedDigests); + if (diffs.length > 0) { + return { surface, version: `sha256:${stagedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: [...diffs, `source: VSIX ${newest.suffix} skills set`] }; + } + return { + surface, + version: `sha256:${stagedSet}`, + source_version: `sha256:${sourceSet}`, + verdict: "current", + evidence: [`all ${sourceDigests.size} staged skills byte-match the VSIX ${newest.suffix} skills set`, `set digest sha256:${stagedSet}`], + }; +} + +async function probeAgentCards( + ctx: SurfaceContext, + deployedDir: string, + surface: "agent-cards-global" | "agent-cards-staging", +): Promise { + const srcDir = join(ctx.rootRepoAmicode, "packages", "extension", "agents"); + const sourceCards = (await listFiles(srcDir)).filter((f) => f.endsWith(".md")); + if (sourceCards.length === 0) { + return { surface, version: null, source_version: null, verdict: "unknown", evidence: [`missing local source: ${srcDir} absent or has no cards`] }; + } + const sourceDigests = new Map(); + for (const card of sourceCards) { + const sha = await fileSha(join(srcDir, card)); + if (sha !== null) sourceDigests.set(card, sha); + } + const sourceSet = setDigest(sourceDigests); + + const deployedCards = (await listFiles(deployedDir)).filter((f) => f.endsWith(".md")); + if (deployedCards.length === 0) { + return { surface, version: null, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: [`deployed dir missing or empty: ${deployedDir}`, `source: ${sourceDigests.size} cards in ${srcDir}`] }; + } + const deployedDigests = new Map(); + const diffs: string[] = []; + for (const [card, srcSha] of sourceDigests) { + const dstSha = await fileSha(join(deployedDir, card)); + if (dstSha === null) diffs.push(`card ${card} missing from ${deployedDir}`); + else { + deployedDigests.set(card, dstSha); + if (dstSha !== srcSha) diffs.push(`card ${card} changed (deployed ${dstSha.slice(0, 12)} ≠ source ${srcSha.slice(0, 12)})`); + } + } + const deployedSet = setDigest(deployedDigests); + + // receipt — secondary evidence: the digest diff governs; a missing/lying + // receipt is itself staleness (no auditable current deployment) + if (diffs.length > 0) { + return { surface, version: `sha256:${deployedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: [...diffs, `source: ${srcDir}`] }; + } + const receiptRaw = await readFileSafe(join(srcDir, ".deploy-receipt.json")); + if (receiptRaw === null) { + return { surface, version: `sha256:${deployedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: ["deploy receipt missing (.deploy-receipt.json) — deployment not auditable", `all ${sourceDigests.size} cards byte-match sources`] }; + } + let receipt: { sources?: { card?: string; sha256?: string }[] } | null = null; + try { + receipt = JSON.parse(receiptRaw); + } catch { + receipt = null; + } + const receiptEntries = receipt?.sources ?? []; + if (receipt === null || receiptEntries.length === 0) { + return { surface, version: `sha256:${deployedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: ["deploy receipt unparseable or empty — deployment not auditable", `all ${sourceDigests.size} cards byte-match sources`] }; + } + const receiptMismatches: string[] = []; + for (const entry of receiptEntries) { + if (!entry.card) continue; + const claimed = (entry.sha256 ?? "").replace(/^sha256:/, "").toLowerCase(); + const actual = sourceDigests.get(entry.card); + if (actual === undefined) receiptMismatches.push(`receipt names card ${entry.card} absent from sources`); + else if (claimed !== actual) receiptMismatches.push(`receipt source digest for ${entry.card} ≠ current source (${claimed.slice(0, 12)} ≠ ${actual.slice(0, 12)})`); + } + if (receiptMismatches.length > 0) { + return { surface, version: `sha256:${deployedSet}`, source_version: `sha256:${sourceSet}`, verdict: "stale", evidence: [...receiptMismatches, `all cards byte-match sources, but the receipt records different sources`] }; + } + return { + surface, + version: `sha256:${deployedSet}`, + source_version: `sha256:${sourceSet}`, + verdict: "current", + evidence: [`all ${sourceDigests.size} cards byte-match sources (${deployedDir})`, `deploy receipt source digests match current sources`, `set digest sha256:${deployedSet}`], + }; +} + +// ── the inventory ──────────────────────────────────────────────────────────── + +async function guarded(name: SurfaceName, fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + // probes degrade individually — never a failed report + return { surface: name, version: null, source_version: null, verdict: "unknown", evidence: [`probe error: ${e instanceof Error ? e.message : String(e)}`] }; + } +} + +export async function surfaceInventory(partial: Partial = {}): Promise { + const ctx: SurfaceContext = { ...defaultSurfaceContext(), ...partial }; + // source-of-truth refresh: one fetch per source repo, shared by its probes + const forkFetch = await fetchOrigin(ctx.run, ctx.rootRepoFork, true); // tags: release tags + const amicodeFetch = await fetchOrigin(ctx.run, ctx.rootRepoAmicode, false); + const surfaces: SurfaceRecord[] = []; + surfaces.push(await guarded("server-binary", () => probeServerBinary(ctx, forkFetch))); + surfaces.push(await guarded("extension", () => probeExtension(ctx, amicodeFetch))); + surfaces.push(await guarded("vendored-binary", () => probeVendoredBinary(ctx, forkFetch))); + surfaces.push(await guarded("staged-skills", () => probeStagedSkills(ctx))); + surfaces.push(await guarded("agent-cards-global", () => probeAgentCards(ctx, join(ctx.rootConfig, "agents"), "agent-cards-global"))); + surfaces.push(await guarded("agent-cards-staging", () => probeAgentCards(ctx, join(ctx.rootStaging, ".opencode", "agents"), "agent-cards-staging"))); + return { surfaces }; +} + +// ── rendering + canonical JSON ─────────────────────────────────────────────── + +/** Canonical JSON: deep-sorted keys, 2-space indent, trailing newline — the + * same contract as the vault-card slice's canonicalJson. */ +export function canonicalJson(value: unknown): string { + return `${JSON.stringify(sortDeep(value), null, 2)}\n`; +} + +function sortDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortDeep); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value as Record) + .sort() + .map((k) => [k, sortDeep((value as Record)[k])]), + ); + } + return value; +} + +/** The human table appended to doctor v1's binding report. */ +export function renderSurfacesTable(surfaces: SurfaceRecord[]): string { + const width = Math.max(...surfaces.map((s) => s.surface.length)); + const lines = surfaces.map((s) => { + const v = s.version ?? "—"; + const sv = s.source_version ?? "—"; + return ` ${s.surface.padEnd(width)} ${String(v).padEnd(44)} ${String(sv).padEnd(44)} ${s.verdict}`; + }); + return `surfaces:\n${lines.join("\n")}`; +} diff --git a/packages/amico-run/test/surfaces.test.ts b/packages/amico-run/test/surfaces.test.ts new file mode 100644 index 00000000..d4f8fd7d --- /dev/null +++ b/packages/amico-run/test/surfaces.test.ts @@ -0,0 +1,245 @@ +// surfaces.test.ts — doctor v2's verdict-matrix fixture suite (#525, spec D1 + +// Measurement Protocol). Fully hermetic: every fixture injects temp roots — fake +// binaries are shell scripts printing PINNED version strings, sidecars are +// fabricated next to them, git fixtures are real repos with PINNED commit dates +// (env overrides at setup) whose "remotes" are local bare repos (or dead paths +// for the unreachable-remote cells). The real ~/.amico, ~/.vscode and +// ~/armonia are NEVER touched. +// +// Date determinism (the spec's rule): current cells pin BOTH sides — fake +// binaries print FAR-FUTURE build dates (2099…), git commits carry FAR-PAST +// pinned dates (2026-08-01); stale cells flip one side. No mtime is ever read. +import { describe, test, expect } from "vitest"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + chmodSync, + rmSync, + copyFileSync, + readFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { surfaceInventory, type SurfaceContext } from "../src/surfaces.js"; + +// ── pinned dates ───────────────────────────────────────────────────────────── +// Git commit dates via the standard env overrides (far past); fake binaries +// print far-future build dates for current cells, far-past for stale ones. +const GIT_COMMIT_DATE = "2026-08-01T12:00:00Z"; +const GIT_DATE_ENV = { + GIT_AUTHOR_DATE: GIT_COMMIT_DATE, + GIT_COMMITTER_DATE: GIT_COMMIT_DATE, + GIT_AUTHOR_NAME: "doctor fixture", + GIT_AUTHOR_EMAIL: "fixture@example.test", + GIT_COMMITTER_NAME: "doctor fixture", + GIT_COMMITTER_EMAIL: "fixture@example.test", +}; +const FUTURE_BUILD = "0.0.0-local/amicode-209901010000"; // build date 2099-01-01 +const PAST_BUILD = "0.0.0-local/amicode-202601010000"; // build date 2026-01-01 + +// ── fixture helpers ────────────────────────────────────────────────────────── +let dirs: string[] = []; + +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), "doctor-v2-")); + dirs.push(d); + return d; +} + +function cleanup(): void { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs = []; +} + +function git(dir: string, args: string[], env: Record = {}): void { + execFileSync("git", ["-C", dir, ...args], { + env: { ...process.env, ...GIT_DATE_ENV, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +/** A fake "binary": a shell script printing a pinned version string. */ +function fakeBin(dir: string, name: string, versionLine: string): string { + mkdirSync(dir, { recursive: true }); + const p = join(dir, name); + writeFileSync(p, `#!/bin/sh\necho "${versionLine}"\n`); + chmodSync(p, 0o755); + return p; +} + +const sha256 = (p: string): string => createHash("sha256").update(readFileSync(p)).digest("hex"); + +function writeJson(p: string, v: unknown): void { + writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); +} + +interface World { + root: string; + server: string; + vscext: string; + config: string; + repoAmicode: string; + repoFork: string; + remoteAmicode: string; + remoteFork: string; + staging: string; + running: string; + frozenBin: string; +} + +interface WorldOpts { + /** printed by the frozen server binary (build date embedded) */ + frozenVersion?: string; + /** the running-process stub; null = fabricate a byte-copy of frozen */ + runningVersion?: string | null; +} + +/** The canonical CURRENT world: every surface at its source of truth. */ +function buildWorld(opts: WorldOpts = {}): World { + const root = tmp(); + const server = join(root, "server"); + const vscext = join(root, "vscode", "extensions"); + const config = join(root, "config", "opencode"); + const staging = join(root, "staging", "opencode-project"); + const repoAmicode = join(root, "repos", "amicode"); + const repoFork = join(root, "repos", "opencode"); + const remoteAmicode = join(root, "remotes", "amicode.git"); + const remoteFork = join(root, "remotes", "opencode.git"); + + // ── frozen server binary + sidecar + running-process stub ── + const frozenBin = fakeBin(join(server, "bin"), "opencode", opts.frozenVersion ?? FUTURE_BUILD); + writeFileSync(`${frozenBin}.sha256`, `${sha256(frozenBin)} opencode\n`); + const running = opts.runningVersion === null ? "" : join(root, "running-opencode"); + if (opts.runningVersion === undefined) copyFileSync(frozenBin, running); + else if (opts.runningVersion !== null) fakeBin(root, "running-opencode", opts.runningVersion); + + // ── VSIX extension dirs: 0.2.6 NEWEST by version, 0.2.4 written AFTER it + // (newer mtime) — proves selection is version-sorted, never mtime ── + const extDir = join(vscext, "harmoniqs.amicode-0.2.6"); + const oldExtDir = join(vscext, "harmoniqs.amicode-0.2.4-darwin-arm64"); + for (const s of ["alpha", "beta"]) { + mkdirSync(join(extDir, "skills", s), { recursive: true }); + writeFileSync(join(extDir, "skills", s, "SKILL.md"), `# ${s}\nVSIX skill ${s} v0.2.6\n`); + } + // (created after 0.2.6 → newer mtime; different skills so a mispick shows) + for (const s of ["alpha", "beta"]) { + mkdirSync(join(oldExtDir, "skills", s), { recursive: true }); + writeFileSync(join(oldExtDir, "skills", s, "SKILL.md"), `# ${s}\nVSIX skill ${s} v0.2.4\n`); + } + + // ── staged skills: byte-identical to the newest VSIX set ── + for (const s of ["alpha", "beta"]) { + mkdirSync(join(staging, "skills", s), { recursive: true }); + copyFileSync(join(extDir, "skills", s, "SKILL.md"), join(staging, "skills", s, "SKILL.md")); + } + + // ── agent cards: source (amicode repo) + both deployments + receipt ── + const agentsSrc = join(repoAmicode, "packages", "extension", "agents"); + for (const c of ["autodev.md", "autoresearch.md"]) { + mkdirSync(agentsSrc, { recursive: true }); + writeFileSync(join(agentsSrc, c), `---\nmode: ${c.replace(".md", "")}\n---\n# ${c}\n`); + } + mkdirSync(join(config, "agents"), { recursive: true }); + mkdirSync(join(staging, ".opencode", "agents"), { recursive: true }); + for (const c of ["autodev.md", "autoresearch.md"]) { + copyFileSync(join(agentsSrc, c), join(config, "agents", c)); + copyFileSync(join(agentsSrc, c), join(staging, ".opencode", "agents", c)); + } + writeJson(join(agentsSrc, ".deploy-receipt.json"), { + receipt_version: 1, + deployed_at: "2026-08-01T00:00:00.000Z", + dry_run: false, + sources: ["autodev.md", "autoresearch.md"].map((c) => ({ + card: c, + path: join(agentsSrc, c), + sha256: `sha256:${sha256(join(agentsSrc, c))}`, + })), + destinations: [], + }); + + // ── amicode repo (git fixture): extension version 0.2.6 on main, vendored + // binary printing the fork release base version, agent-card sources ── + writeFileSync( + join(repoAmicode, "packages", "extension", "package.json"), + JSON.stringify({ name: "amicode", version: "0.2.6" }, null, 2) + "\n", + ); + fakeBin( + join(repoAmicode, "packages", "extension", "vendor", "opencode", "darwin-arm64"), + "opencode", + "1.18.10", + ); + git(repoAmicode, ["init", "-b", "main"]); + git(repoAmicode, ["add", "-A"]); + git(repoAmicode, ["commit", "-m", "amicode fixture"]); + execFileSync("git", ["init", "--bare", "-b", "main", remoteAmicode]); + git(repoAmicode, ["remote", "add", "origin", remoteAmicode]); + git(repoAmicode, ["push", "-u", "origin", "main"]); + + // ── fork repo (git fixture): branch local/amicode, release tag on tip ── + mkdirSync(repoFork, { recursive: true }); + writeFileSync(join(repoFork, "README.md"), "fork fixture\n"); + git(repoFork, ["init", "-b", "local/amicode"]); + git(repoFork, ["add", "-A"]); + git(repoFork, ["commit", "-m", "fork fixture"]); + git(repoFork, ["tag", "v1.18.10-amicode.15"]); + execFileSync("git", ["init", "--bare", "-b", "local/amicode", remoteFork]); + git(repoFork, ["remote", "add", "origin", remoteFork]); + git(repoFork, ["push", "-u", "origin", "local/amicode"]); + git(repoFork, ["push", "origin", "v1.18.10-amicode.15"]); + + return { root, server, vscext, config, repoAmicode, repoFork, remoteAmicode, remoteFork, staging, running, frozenBin }; +} + +function ctxFor(w: World, over: Partial = {}): SurfaceContext { + return { + rootServer: w.server, + rootVscext: w.vscext, + rootConfig: w.config, + rootRepoAmicode: w.repoAmicode, + rootRepoFork: w.repoFork, + rootStaging: w.staging, + runningBinary: w.running || null, + platform: "darwin-arm64", + ...over, + }; +} + +const bySurface = (report: { surfaces: { surface: string }[] }, name: string) => + report.surfaces.find((r) => r.surface === name)!; + +// ── the matrix ─────────────────────────────────────────────────────────────── +describe("doctor v2 surface inventory — current cells", () => { + test("current world: all six surfaces current, records complete and ordered", async () => { + const w = buildWorld(); + const report = await surfaceInventory(ctxFor(w)); + expect(report.surfaces.map((r) => [r.surface, r.verdict])).toEqual([ + ["server-binary", "current"], + ["extension", "current"], + ["vendored-binary", "current"], + ["staged-skills", "current"], + ["agent-cards-global", "current"], + ["agent-cards-staging", "current"], + ]); + for (const r of report.surfaces) { + expect(r.version, `${r.surface} version`).toBeTruthy(); + expect(r.source_version, `${r.surface} source_version`).toBeTruthy(); + expect(r.evidence.length, `${r.surface} evidence`).toBeGreaterThan(0); + } + // server-binary: frozen version observed, source = fetched HEAD commit date + const sb = bySurface(report, "server-binary"); + expect(sb.version).toBe(FUTURE_BUILD); + expect(sb.evidence.join(" ")).toMatch(/sha256/); + // extension: version-SORTED newest (0.2.6), never mtime (0.2.4 dir is newer) + const ext = bySurface(report, "extension"); + expect(ext.version).toContain("0.2.6"); + expect(ext.source_version).toBe("0.2.6"); + // vendored: printed version == latest release tag base + const vb = bySurface(report, "vendored-binary"); + expect(vb.version).toBe("1.18.10"); + expect(vb.source_version).toBe("1.18.10"); + cleanup(); + }); +}); From a324eca42d5907051756b5b84e39c1ef3eb94615 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 23 Aug 2026 11:01:56 -0400 Subject: [PATCH 2/4] =?UTF-8?q?test(amico-run):=20doctor=20v2=20verdict=20?= =?UTF-8?q?matrix=20=E2=80=94=20stale/integrity/unknown=20cells=20(#525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server-binary's three stale mechanisms as separate fixtures (version-stale, running≠frozen, server-down); per-surface stale cells; tampered-sidecar integrity-failure; unknown ×6 (dead-remote stubs, missing-local-source); degradation proof — every source dead still yields six records. --- packages/amico-run/test/surfaces.test.ts | 231 +++++++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/packages/amico-run/test/surfaces.test.ts b/packages/amico-run/test/surfaces.test.ts index d4f8fd7d..095b8529 100644 --- a/packages/amico-run/test/surfaces.test.ts +++ b/packages/amico-run/test/surfaces.test.ts @@ -210,6 +210,34 @@ function ctxFor(w: World, over: Partial = {}): SurfaceContext { const bySurface = (report: { surfaces: { surface: string }[] }, name: string) => report.surfaces.find((r) => r.surface === name)!; +// ── remote-side mutations (the source of truth moves WITHOUT the local +// checkout — commits/tags are pushed from throwaway clones only, so the +// fixture's checkout learns of them solely through doctor's fetch) ──────── +function withBareClone(bare: string, branch: string, fn: (clone: string) => void): void { + const clone = join(tmp(), "clone"); + execFileSync("git", ["clone", "--branch", branch, bare, clone], { stdio: ["ignore", "pipe", "pipe"] }); + fn(clone); + git(clone, ["push", "origin", `HEAD:refs/heads/${branch}`]); +} + +function bumpExtensionOnRemote(bare: string, version: string): void { + withBareClone(bare, "main", (clone) => { + writeFileSync( + join(clone, "packages", "extension", "package.json"), + JSON.stringify({ name: "amicode", version }, null, 2) + "\n", + ); + git(clone, ["add", "-A"]); + git(clone, ["commit", "-m", `bump extension to ${version}`]); + }); +} + +function addReleaseTagOnRemote(bare: string, tag: string): void { + withBareClone(bare, "local/amicode", (clone) => { + git(clone, ["tag", tag]); + git(clone, ["push", "origin", tag]); + }); +} + // ── the matrix ─────────────────────────────────────────────────────────────── describe("doctor v2 surface inventory — current cells", () => { test("current world: all six surfaces current, records complete and ordered", async () => { @@ -243,3 +271,206 @@ describe("doctor v2 surface inventory — current cells", () => { cleanup(); }); }); + +describe("doctor v2 surface inventory — integrity-failure cell", () => { + test("server-binary integrity-failure: tampered sidecar (frozen sha ≠ sidecar)", async () => { + const w = buildWorld(); + const sidecar = `${w.frozenBin}.sha256`; + writeFileSync(sidecar, `${"0".repeat(64)} opencode\n`); // the sidecar lies + const report = await surfaceInventory(ctxFor(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("integrity-failure"); + expect(sb.evidence.join(" ")).toMatch(/frozen sha256 .* ≠ sidecar/); + // one bad surface never fails the report: the other five still judged + expect(bySurface(report, "extension").verdict).toBe("current"); + expect(bySurface(report, "agent-cards-global").verdict).toBe("current"); + cleanup(); + }); +}); + +describe("doctor v2 surface inventory — unknown cells (every surface degrades individually)", () => { + const DEAD_REMOTE = "/nonexistent/doctors-fixture-remote.git"; + + test("server-binary unknown: unreachable fork remote", async () => { + const w = buildWorld(); + git(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); + const report = await surfaceInventory(ctxFor(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("unknown"); + expect(sb.evidence.join(" ")).toMatch(/fork fetch failed/); + // local facts still reported: integrity + running checks pass + expect(sb.evidence.join(" ")).toMatch(/running .* = frozen|local checks pass/); + cleanup(); + }); + + test("extension unknown: unreachable amicode remote", async () => { + const w = buildWorld(); + git(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]); + const report = await surfaceInventory(ctxFor(w)); + const ext = bySurface(report, "extension"); + expect(ext.verdict).toBe("unknown"); + expect(ext.evidence.join(" ")).toMatch(/amicode fetch failed/); + // the agent-cards source is the LOCAL checkout — unaffected by the dead remote + expect(bySurface(report, "agent-cards-global").verdict).toBe("current"); + cleanup(); + }); + + test("vendored-binary unknown: unreachable fork remote (release tags not refreshable)", async () => { + const w = buildWorld(); + git(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); + const report = await surfaceInventory(ctxFor(w)); + const vb = bySurface(report, "vendored-binary"); + expect(vb.verdict).toBe("unknown"); + expect(vb.evidence.join(" ")).toMatch(/fork fetch failed/); + cleanup(); + }); + + test("staged-skills unknown: missing local source (no VSIX skills set)", async () => { + const w = buildWorld(); + rmSync(w.vscext, { recursive: true, force: true }); + const report = await surfaceInventory(ctxFor(w)); + const sk = bySurface(report, "staged-skills"); + expect(sk.verdict).toBe("unknown"); + expect(sk.evidence.join(" ")).toMatch(/missing local source/); + cleanup(); + }); + + test("agent-cards-global unknown: missing source dir", async () => { + const w = buildWorld(); + rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true }); + const report = await surfaceInventory(ctxFor(w)); + const g = bySurface(report, "agent-cards-global"); + expect(g.verdict).toBe("unknown"); + expect(g.evidence.join(" ")).toMatch(/missing local source/); + cleanup(); + }); + + test("agent-cards-staging unknown: missing source dir", async () => { + const w = buildWorld(); + rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true }); + const report = await surfaceInventory(ctxFor(w)); + const st = bySurface(report, "agent-cards-staging"); + expect(st.verdict).toBe("unknown"); + expect(st.evidence.join(" ")).toMatch(/missing local source/); + cleanup(); + }); + + test("no report ever fails: all six records present even when every source is unreachable", async () => { + const w = buildWorld(); + git(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); + git(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]); + rmSync(w.vscext, { recursive: true, force: true }); + rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true }); + const report = await surfaceInventory(ctxFor(w)); + expect(report.surfaces).toHaveLength(6); + // every source of truth is dead → every surface degrades to unknown, and + // the report still returns all six records — never a failed report + expect(report.surfaces.every((r) => r.verdict === "unknown")).toBe(true); + expect(report.surfaces.every((r) => r.evidence.length > 0)).toBe(true); + cleanup(); + }); +}); + +describe("doctor v2 surface inventory — stale cells", () => { + test("server-binary stale (version): far-past build date < pinned HEAD commit date", async () => { + const w = buildWorld({ frozenVersion: PAST_BUILD }); // build 2026-01-01 < HEAD 2026-08-01 + const report = await surfaceInventory(ctxFor(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("stale"); + expect(sb.version).toBe(PAST_BUILD); + expect(sb.evidence.join(" ")).toMatch(/build date .* < HEAD commit date/); + cleanup(); + }); + + test("server-binary stale (restart pending): running binary sha ≠ frozen sha", async () => { + // different bytes (one-digit-different version line) → different sha + const w = buildWorld({ runningVersion: "0.0.0-local/amicode-209901010001" }); + const report = await surfaceInventory(ctxFor(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("stale"); + expect(sb.evidence.join(" ")).toMatch(/running .* sha256 .* ≠ frozen sha256 .* \(restart pending\)/); + cleanup(); + }); + + test("server-binary stale (server-down): absent process is stale with server-down evidence", async () => { + const w = buildWorld({ runningVersion: null }); + const report = await surfaceInventory(ctxFor(w, { discoverRunning: async () => null })); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("stale"); + expect(sb.evidence.join(" ")).toMatch(/server-down: no running opencode serve process/); + cleanup(); + }); + + test("extension stale: installed 0.2.6 behind fetched origin/main 0.2.7", async () => { + const w = buildWorld(); + bumpExtensionOnRemote(w.remoteAmicode, "0.2.7"); + const report = await surfaceInventory(ctxFor(w)); + const ext = bySurface(report, "extension"); + expect(ext.verdict).toBe("stale"); + expect(ext.version).toContain("0.2.6"); + expect(ext.source_version).toBe("0.2.7"); + expect(ext.evidence.join(" ")).toMatch(/behind/); + cleanup(); + }); + + test("vendored-binary stale: printed 1.18.10 behind new release tag base 1.18.12", async () => { + const w = buildWorld(); + addReleaseTagOnRemote(w.remoteFork, "v1.18.12-amicode.1"); + const report = await surfaceInventory(ctxFor(w)); + const vb = bySurface(report, "vendored-binary"); + expect(vb.verdict).toBe("stale"); + expect(vb.version).toBe("1.18.10"); + expect(vb.source_version).toBe("1.18.12"); + expect(vb.evidence.join(" ")).toMatch(/behind/); + cleanup(); + }); + + test("staged-skills stale: per-skill digest diff (changed skill named in evidence)", async () => { + const w = buildWorld(); + writeFileSync(join(w.staging, "skills", "beta", "SKILL.md"), "# beta\nDRIFTED staged copy\n"); + const report = await surfaceInventory(ctxFor(w)); + const sk = bySurface(report, "staged-skills"); + expect(sk.verdict).toBe("stale"); + expect(sk.evidence.join(" ")).toMatch(/skill beta changed/); + expect(sk.evidence.join(" ")).not.toMatch(/skill alpha/); // alpha still byte-matches + cleanup(); + }); + + test("agent-cards-global stale: deployed card tampered (per-card digest diff)", async () => { + const w = buildWorld(); + writeFileSync(join(w.config, "agents", "autodev.md"), "---\nmode: autodev\n---\n# TAMPERED\n"); + const report = await surfaceInventory(ctxFor(w)); + const g = bySurface(report, "agent-cards-global"); + expect(g.verdict).toBe("stale"); + expect(g.evidence.join(" ")).toMatch(/card autodev\.md changed/); + const st = bySurface(report, "agent-cards-staging"); + expect(st.verdict).toBe("current"); // the OTHER deployment is unaffected + cleanup(); + }); + + test("agent-cards-staging stale: source present + receipt missing is stale (digest diff governs, receipt secondary)", async () => { + const w = buildWorld(); + rmSync(join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json")); + const report = await surfaceInventory(ctxFor(w)); + for (const name of ["agent-cards-global", "agent-cards-staging"]) { + const r = bySurface(report, name); + expect(r.verdict).toBe("stale"); + expect(r.evidence.join(" ")).toMatch(/receipt missing/); + expect(r.evidence.join(" ")).toMatch(/byte-match/); // bytes agree — the receipt is the staleness + } + cleanup(); + }); + + test("agent-cards stale: receipt source digests ≠ current sources", async () => { + const w = buildWorld(); + const receiptPath = join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"); + const receipt = JSON.parse(readFileSync(receiptPath, "utf8")) as { sources: { card: string; sha256: string }[] }; + receipt.sources[0].sha256 = "sha256:" + "0".repeat(64); // lies about autodev.md + writeJson(receiptPath, receipt); + const report = await surfaceInventory(ctxFor(w)); + const g = bySurface(report, "agent-cards-global"); + expect(g.verdict).toBe("stale"); + expect(g.evidence.join(" ")).toMatch(/receipt source digest for autodev\.md ≠ current source/); + cleanup(); + }); +}); From 150e1ab656617b5c8add9dff0aba371d50116114 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 23 Aug 2026 11:14:39 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat(amico-run):=20doctor=20v2=20=E2=80=94?= =?UTF-8?q?=20JSON=20contract,=20injectable=20roots,=20CLI=20wiring=20(#52?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schemas/doctor-report.schema.json: the committed machine contract (surfaces minItems 6; required surface/version/verdict/evidence), itself in canonical form; doctor_schema.ts validates against it (zero new deps, the vault-card minimal-engine pattern). - doctor.ts: doctorReport(argv) parses --json + the seven injectable flags; composes v1's binding diagnosis with the surface inventory; v1's rendered/exit contract preserved verbatim (report GAINS a surfaces section; --json emits canonical JSON, surfaces only). - amico.ts: the doctor case passes argv and prints json when present. - Fixture world extracted to test/helpers.ts (shared by the unit + CLI suites); platform dir derived from the live platform (runner-portable); hermetic discoverRunning default (a forgotten stub reads server-down, never the real machine's process). - CLI end-to-end: amico doctor --json through the built bundle emits the canonical contract; human output keeps the v1 table + surfaces section. - test/fixtures/surfaces/README.md: the authorship-split record (implementer cells here; reviewer adversarial variants listed). --- .../schemas/doctor-report.schema.json | 68 +++ packages/amico-run/src/amico.ts | 14 +- packages/amico-run/src/doctor.ts | 88 ++- packages/amico-run/src/doctor_schema.ts | 118 ++++ packages/amico-run/src/surfaces.ts | 2 +- packages/amico-run/test/amico.test.ts | 48 +- packages/amico-run/test/doctor.test.ts | 45 +- .../test/fixtures/surfaces/README.md | 46 ++ packages/amico-run/test/helpers.ts | 231 ++++++- packages/amico-run/test/surfaces.test.ts | 578 +++++++----------- 10 files changed, 881 insertions(+), 357 deletions(-) create mode 100644 packages/amico-run/schemas/doctor-report.schema.json create mode 100644 packages/amico-run/src/doctor_schema.ts create mode 100644 packages/amico-run/test/fixtures/surfaces/README.md diff --git a/packages/amico-run/schemas/doctor-report.schema.json b/packages/amico-run/schemas/doctor-report.schema.json new file mode 100644 index 00000000..bb892418 --- /dev/null +++ b/packages/amico-run/schemas/doctor-report.schema.json @@ -0,0 +1,68 @@ +{ + "$id": "https://harmoniqs.github.io/amicode/schemas/doctor-report.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "The `amico doctor --json` machine contract (doctor v2, #525): the surface inventory the settings panel and watchdog consume. Canonical form: deep-sorted keys, 2-space indent, trailing newline.", + "properties": { + "surfaces": { + "additionalProperties": false, + "items": { + "additionalProperties": true, + "properties": { + "evidence": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "source_version": { + "type": [ + "string", + "null" + ] + }, + "surface": { + "enum": [ + "server-binary", + "extension", + "vendored-binary", + "staged-skills", + "agent-cards-global", + "agent-cards-staging" + ] + }, + "verdict": { + "enum": [ + "current", + "stale", + "integrity-failure", + "unknown" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "surface", + "version", + "verdict", + "evidence" + ], + "type": "object" + }, + "minItems": 6, + "type": "array" + } + }, + "required": [ + "surfaces" + ], + "title": "doctor --json report", + "type": "object" +} diff --git a/packages/amico-run/src/amico.ts b/packages/amico-run/src/amico.ts index 11dedba1..9b8e3fca 100644 --- a/packages/amico-run/src/amico.ts +++ b/packages/amico-run/src/amico.ts @@ -23,7 +23,7 @@ function usage(): string { ["resolve --platform

--kind --size ", "tier resolution → JSON (amico-run subcommand)"], ["sandbox --packages A,B,…", "generate a per-problem Julia env (amico-run subcommand)"], ["estimate | --spec ", "v0 size estimate → JSON suggestion signal, never a route (Δ10 #34)"], - ["doctor", "validate the studio binding — paths, mounts, drift (#402)"], + ["doctor [--json] [--root-…]", "studio binding + fleet surface inventory — six records, verdicts (#402, #525)"], [ "pasqal devices | submit --device --artifact

[--confirm ]", "Pasqal device path — list/select + gated submit (#160)", @@ -77,11 +77,15 @@ export async function main(argv: string[]): Promise { return launch(["estimate", ...rest]); case "doctor": { - // The studio binding's health check (#402): the manifest's world — - // paths exist, mounts readable, exactly one rw personal, drift flagged. + // The studio binding's health check (#402) + the fleet surface inventory + // (#525): six records, each with version + verdict + evidence. `--json` + // emits the machine contract (canonical JSON, surfaces only); the human + // table derives from the same records. Root flags make every probe + // injectable — the fixture suite never touches the real fleet surfaces. const { doctorReport } = await import("./doctor.js"); - const report = await doctorReport(); - console.log(report.rendered); + const report = await doctorReport(rest); + if (report.json !== null) console.log(report.json); + else console.log(report.rendered); return report.exit; } diff --git a/packages/amico-run/src/doctor.ts b/packages/amico-run/src/doctor.ts index cfa57cd3..2a537d65 100644 --- a/packages/amico-run/src/doctor.ts +++ b/packages/amico-run/src/doctor.ts @@ -1,9 +1,17 @@ -// doctor.ts — `amico doctor` (#402): validate the studio BINDING — the world, -// not just the schema. The schema checks structure; this checks existence, -// mount health, the exactly-one-rw-personal rule, and flags the KNOWN legacy -// drift as warnings (the relocation slices' to-do list — drift is not breakage). +// doctor.ts — `amico doctor`: v1 (#402) validates the studio BINDING — the +// world, not just the schema; v2 (#525) adds the FLEET SURFACE INVENTORY +// (surfaces.ts): five physical surfaces, six records, verdicts by version +// string or content digest (never mtime). v1's checks are preserved verbatim +// — the report GAINS a surfaces section; nothing v1 consumers rely on breaks. import type { StudioPaths } from "@amicode/schema"; import { studioPathsOrLegacy } from "@amicode/schema"; +import { + surfaceInventory, + renderSurfacesTable, + canonicalJson, + type SurfaceContext, + type SurfacesReport, +} from "./surfaces.js"; export interface Diagnosis { ok: boolean; // no ERRORS (warnings don't fail the doctor) @@ -71,8 +79,62 @@ export async function diagnoseStudio(paths: StudioPaths, exists: Exists): Promis return { ok: errors.length === 0, errors, warnings, checks }; } -/** The CLI entry: diagnose THIS machine's binding and print the table. */ -export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered: string; exit: number }> { +/** The CLI entry: diagnose THIS machine's binding and print the table. + * v2 (#525): accepts the doctor flags — `--json` (the machine contract) and + * the injectable roots (`--root-vscext`, `--root-config`, `--root-server`, + * `--root-repo-amicode`, `--root-repo-fork`, `--root-staging`, plus + * `--running-binary ` to stub the running-process evidence). With no + * args it behaves exactly as v1 plus the appended surfaces table. */ +export interface DoctorArgs { + json: boolean; + roots: Partial; + runningBinary: string | null; +} + +export function parseDoctorArgs(argv: string[]): { ok: true; args: DoctorArgs } | { ok: false; message: string } { + const args: DoctorArgs = { json: false, roots: {}, runningBinary: null }; + const rootFlags: Record = { + "--root-vscext": "rootVscext", + "--root-config": "rootConfig", + "--root-server": "rootServer", + "--root-repo-amicode": "rootRepoAmicode", + "--root-repo-fork": "rootRepoFork", + "--root-staging": "rootStaging", + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--json") { + args.json = true; + } else if (a === "--running-binary") { + const v = argv[++i]; + if (!v) return { ok: false, message: "--running-binary requires a path" }; + args.runningBinary = v; + } else if (rootFlags[a]) { + const v = argv[++i]; + if (!v) return { ok: false, message: `${a} requires a path` }; + (args.roots as Record)[rootFlags[a]] = v; + } else { + return { ok: false, message: `unknown doctor flag: ${a}` }; + } + } + if (args.runningBinary !== null) args.roots.runningBinary = args.runningBinary; + return { ok: true, args }; +} + +export async function doctorReport( + argv: string[] = [], +): Promise<{ diagnosis: Diagnosis; surfaces: SurfacesReport; rendered: string; json: string | null; exit: number }> { + const parsed = parseDoctorArgs(argv); + if (!parsed.ok) { + const message = `doctor: ${parsed.message}`; + return { + diagnosis: { ok: false, errors: [message], warnings: [], checks: [] }, + surfaces: { surfaces: [] }, + rendered: message, + json: null, + exit: 64, + }; + } let paths = studioPathsOrLegacy(); try { paths = studioPathsOrLegacy(); @@ -89,6 +151,7 @@ export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered: } }; const diagnosis = await diagnoseStudio(paths, stat); + const surfaces = await surfaceInventory(parsed.args.roots); const width = Math.max(...diagnosis.checks.map((c) => c.name.length)); const lines = diagnosis.checks.map((c) => { const mark = c.status === "ok" ? "ok " : c.status === "warn" ? "warn" : "ERR "; @@ -97,5 +160,16 @@ export async function doctorReport(): Promise<{ diagnosis: Diagnosis; rendered: const summary = diagnosis.ok ? `studio binding healthy${diagnosis.warnings.length ? ` (${diagnosis.warnings.length} warning${diagnosis.warnings.length > 1 ? "s" : ""})` : ""}` : `studio binding has ${diagnosis.errors.length} error${diagnosis.errors.length > 1 ? "s" : ""}`; - return { diagnosis, rendered: `${summary}\n${lines.join("\n")}`, exit: diagnosis.ok ? 0 : 1 }; + const rendered = `${summary}\n${lines.join("\n")}\n\n${renderSurfacesTable(surfaces.surfaces)}`; + // the machine contract (panel + watchdog): canonical JSON, surfaces only — + // deep-sorted keys, 2-space indent, trailing newline (the vault-card form) + const json = parsed.args.json ? canonicalJson({ surfaces: surfaces.surfaces }) : null; + return { + diagnosis, + surfaces, + rendered, + json, + // surfaces never fail the report (they degrade individually) — exit stays v1's + exit: diagnosis.ok ? 0 : 1, + }; } diff --git a/packages/amico-run/src/doctor_schema.ts b/packages/amico-run/src/doctor_schema.ts new file mode 100644 index 00000000..498763de --- /dev/null +++ b/packages/amico-run/src/doctor_schema.ts @@ -0,0 +1,118 @@ +// doctor_schema.ts — validate `amico doctor --json` reports against the +// COMMITTED JSON Schema (schemas/doctor-report.schema.json, #525). A minimal +// JSON-Schema-subset engine covering exactly the keywords that schema uses +// (type incl. type arrays, enum, required, properties, items, minItems, +// minLength, additionalProperties) — the same zero-dependency approach as +// the extension's vault_card_validator. Errors name the violated path. +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type SchemaError = { path: string; message: string }; +export type SchemaValidation = { ok: boolean; errors: SchemaError[] }; + +type JsonSchema = Record; + +/** Load the committed doctor-report schema. Resolved from this module's + * location (src/ → ../schemas/); when running from the esbuild bundle + * (dist/), the package root is one level up from there too. */ +export function loadDoctorSchema(): JsonSchema { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "..", "schemas", "doctor-report.schema.json"), // src/ or dist/ + join(here, "schemas", "doctor-report.schema.json"), // package root + ]; + for (const p of candidates) { + try { + return JSON.parse(readFileSync(p, "utf8")) as JsonSchema; + } catch { + // try next candidate + } + } + throw new Error(`doctor-report.schema.json not found near ${here}`); +} + +function checkType(value: unknown, type: string): boolean { + switch (type) { + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "array": + return Array.isArray(value); + case "object": + return typeof value === "object" && value !== null && !Array.isArray(value); + case "null": + return value === null; + default: + return true; + } +} + +export function validateAgainstSchema(value: unknown, schema: JsonSchema, at = "$"): SchemaError[] { + const errors: SchemaError[] = []; + const push = (p: string, message: string) => errors.push({ path: p, message }); + + if (schema.const !== undefined && value !== schema.const) { + push(at, `must equal ${JSON.stringify(schema.const)}`); + } + if (Array.isArray(schema.enum) && !(schema.enum as unknown[]).includes(value)) { + push(at, `must be one of ${(schema.enum as unknown[]).map((v) => JSON.stringify(v)).join(", ")}`); + } + + const type = schema.type; + if (typeof type === "string" && !checkType(value, type)) { + push(at, `must be ${type}`); + return errors; + } + if (Array.isArray(type) && !(type as string[]).some((t) => checkType(value, t))) { + push(at, `must be one of ${(type as string[]).join(" | ")}`); + return errors; + } + + if (Array.isArray(value)) { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + push(at, `must have at least ${schema.minItems} items (found ${value.length})`); + } + if (schema.items && typeof schema.items === "object") { + value.forEach((v, i) => errors.push(...validateAgainstSchema(v, schema.items as JsonSchema, `${at}[${i}]`))); + } + return errors; + } + + if (value !== null && typeof value === "object") { + const obj = value as Record; + if (Array.isArray(schema.required)) { + for (const key of schema.required as string[]) { + if (!(key in obj)) push(at, `missing required field "${key}"`); + } + } + if (schema.properties && typeof schema.properties === "object") { + for (const [key, sub] of Object.entries(schema.properties as Record)) { + if (key in obj) errors.push(...validateAgainstSchema(obj[key], sub, `${at}.${key}`)); + } + } + if (schema.additionalProperties === false && schema.properties && typeof schema.properties === "object") { + const allowed = new Set(Object.keys(schema.properties as Record)); + for (const key of Object.keys(obj)) { + if (!allowed.has(key)) push(`${at}.${key}`, `additional property not allowed here`); + } + } + return errors; + } + + if (typeof value === "string" && typeof schema.minLength === "number" && value.length < schema.minLength) { + push(at, `must be at least ${schema.minLength} characters`); + } + return errors; +} + +/** Validate a doctor --json report object against the committed schema. */ +export function validateDoctorReport(value: unknown, schema: JsonSchema = loadDoctorSchema()): SchemaValidation { + const errors = validateAgainstSchema(value, schema); + return { ok: errors.length === 0, errors }; +} diff --git a/packages/amico-run/src/surfaces.ts b/packages/amico-run/src/surfaces.ts index ff98898b..5d77f6ec 100644 --- a/packages/amico-run/src/surfaces.ts +++ b/packages/amico-run/src/surfaces.ts @@ -81,7 +81,7 @@ export interface SurfaceContext { const GIT_TIMEOUT_MS = 30_000; -const realExec: Exec = (file, args) => +export const realExec: Exec = (file, args) => new Promise((resolve) => { execFile(file, args, { timeout: GIT_TIMEOUT_MS }, (err, stdout, stderr) => { const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; diff --git a/packages/amico-run/test/amico.test.ts b/packages/amico-run/test/amico.test.ts index c1873a30..674809da 100644 --- a/packages/amico-run/test/amico.test.ts +++ b/packages/amico-run/test/amico.test.ts @@ -8,7 +8,8 @@ import { execFile, execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { fakeJulia, hermeticOpsEnv, readToml, tmpRoot } from "./helpers.js"; +import { fakeJulia, hermeticOpsEnv, readToml, tmpRoot, buildDoctorWorld, cleanupTracked } from "./helpers.js"; +import { validateDoctorReport } from "../src/doctor_schema.js"; import { FakeCloud } from "./fake_cloud.js"; const BUNDLE = join(__dirname, "..", "dist", "amico.js"); @@ -269,3 +270,48 @@ describe("amico router — mcp-serve facade", () => { expect(out.tools).toEqual(expect.arrayContaining(["amico_catalog"])); }); }); + +describe("amico router — doctor v2 (surface inventory, #525)", () => { + it("doctor --json with injected roots emits the canonical machine contract", () => { + const w = buildDoctorWorld(); + const r = run([ + "doctor", + "--json", + "--root-server", w.server, + "--root-vscext", w.vscext, + "--root-config", w.config, + "--root-repo-amicode", w.repoAmicode, + "--root-repo-fork", w.repoFork, + "--root-staging", w.staging, + "--running-binary", w.running, + ]); + // exit reflects the v1 studio binding only (machine-dependent); the + // surfaces contract is asserted on stdout, which must be JSON-only + const report = JSON.parse(r.stdout); + expect(report.surfaces).toHaveLength(6); + expect(report.surfaces.every((s: { verdict: string }) => s.verdict === "current")).toBe(true); + expect(validateDoctorReport(report).ok).toBe(true); + // canonical form: 2-space indent + trailing newline + expect(r.stdout.endsWith("\n")).toBe(true); + expect(r.stdout.split("\n")[1]).toBe(' "surfaces": ['); + cleanupTracked(); + }); + + it("doctor (human) prints the v1 binding table plus the surfaces section", () => { + const w = buildDoctorWorld(); + const r = run([ + "doctor", + "--root-server", w.server, + "--root-vscext", w.vscext, + "--root-config", w.config, + "--root-repo-amicode", w.repoAmicode, + "--root-repo-fork", w.repoFork, + "--root-staging", w.staging, + "--running-binary", w.running, + ]); + expect(r.stdout).toMatch(/studio binding/); // v1 section intact + expect(r.stdout).toMatch(/^surfaces:$/m); // v2 section gained + expect(r.stdout).toMatch(/server-binary/); + cleanupTracked(); + }); +}); diff --git a/packages/amico-run/test/doctor.test.ts b/packages/amico-run/test/doctor.test.ts index 3dec3a40..5f777b77 100644 --- a/packages/amico-run/test/doctor.test.ts +++ b/packages/amico-run/test/doctor.test.ts @@ -6,7 +6,7 @@ import { describe, test, expect } from "vitest"; import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { diagnoseStudio } from "../src/doctor.js"; +import { diagnoseStudio, parseDoctorArgs } from "../src/doctor.js"; import type { StudioPaths } from "@amicode/schema"; import { legacyStudioPaths } from "@amicode/schema"; @@ -108,3 +108,46 @@ describe("diagnoseStudio", () => { }); }); +// ── v2 (#525): flag parsing + the composed report ──────────────────────────── +describe("parseDoctorArgs", () => { + test("no args = v1 behavior (no roots, human output)", () => { + const r = parseDoctorArgs([]); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.args.json).toBe(false); + expect(r.args.roots).toEqual({}); + expect(r.args.runningBinary).toBe(null); + }); + + test("every injectable root flag maps to its SurfaceContext key", () => { + const r = parseDoctorArgs([ + "--json", + "--root-vscext", "/v", + "--root-config", "/c", + "--root-server", "/s", + "--root-repo-amicode", "/a", + "--root-repo-fork", "/f", + "--root-staging", "/st", + "--running-binary", "/r/opencode", + ]); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.args.json).toBe(true); + expect(r.args.roots).toEqual({ + rootVscext: "/v", + rootConfig: "/c", + rootServer: "/s", + rootRepoAmicode: "/a", + rootRepoFork: "/f", + rootStaging: "/st", + runningBinary: "/r/opencode", + }); + }); + + test("unknown flag / missing value is a usage error", () => { + expect(parseDoctorArgs(["--nope"])).toMatchObject({ ok: false, message: /unknown doctor flag/ }); + expect(parseDoctorArgs(["--root-server"])).toMatchObject({ ok: false, message: /requires a path/ }); + expect(parseDoctorArgs(["--running-binary"])).toMatchObject({ ok: false, message: /requires a path/ }); + }); +}); + diff --git a/packages/amico-run/test/fixtures/surfaces/README.md b/packages/amico-run/test/fixtures/surfaces/README.md new file mode 100644 index 00000000..1f384d9a --- /dev/null +++ b/packages/amico-run/test/fixtures/surfaces/README.md @@ -0,0 +1,46 @@ +# Doctor v2 fixture suite — authorship record + +The verdict-matrix suite lives in `packages/amico-run/test/surfaces.test.ts` +(world builder: `test/helpers.ts`, `buildDoctorWorld`). This file records the +authorship split the issue's Testing Decisions pin: **implementer authors the +matrix cells; the reviewer adds adversarial variants.** + +## Implementer cells (this slice, #525) + +Every cell asserts its EXPECTED verdict, not mere record presence: + +| Cell | Fixture mechanism | +| --- | --- | +| current × 6 | healthy world; fake binaries print far-future build dates (`209901010000`), git commits pinned `2026-08-01T12:00:00Z` | +| server-binary stale (version-stale) | frozen binary prints far-past build date (`202601010000`) < pinned HEAD commit date | +| server-binary stale (running ≠ frozen) | `--running-binary` stub with different bytes → different sha | +| server-binary stale (server-down) | injected `discoverRunning: () => null` (never the live ps) | +| extension stale | version bump pushed to the bare remote from a throwaway clone — the checkout learns of it only via doctor's fetch | +| vendored-binary stale | new release tag `v1.18.12-amicode.1` pushed to the fork bare remote | +| staged-skills stale | one staged skill's content drifted (per-skill digest diff names it) | +| agent-cards stale | three variants: tampered deployed card · receipt missing (bytes match — the receipt is the staleness) · receipt source digests lie | +| integrity-failure | sidecar rewritten with a wrong digest | +| unknown × 6 | dead-remote stubs (`remote set-url` → nonexistent path) for server-binary fork / extension amicode remote / vendored release tag; missing-local-source for staged-skills + both agent-cards records | +| degradation proof | every source dead → six `unknown` records, report never fails | + +Determinism: no mtime is read anywhere; "newest" is version-sorted (the +current-world fixture writes the 0.2.4 VSIX dir AFTER 0.2.6 so its mtime is +newer — the probe must still pick 0.2.6). + +## Reviewer adversarial variants (to be added in review) + +Slots deliberately left open for the reviewer pass, per the house pattern: + +- server-binary: missing frozen binary / missing sidecar / unexecutable binary +- extension: installed AHEAD of origin/main; VSIX dirs with unparseable versions +- vendored: release-tag ordering (`-amicode.2` vs `-amicode.10`); binary + printing prerelease strings +- staged-skills: skill present in staging but absent from the VSIX set (extra + skill); staged dir entirely missing +- agent-cards: extra deployed cards not in sources; unparseable receipt +- version strings: `0.0.0-local/amicode-<12 digits>` variants that defeat the + build-date parser + +Hermeticity: every fixture injects temp roots (tracked + cleaned via +`cleanupTracked`); the real `~/.amico`, `~/.vscode`, and `~/armonia` are never +touched; git "remotes" are local bare repos inside the temp world. diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index 498d68c7..9343612a 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -1,7 +1,11 @@ -import { mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync, chmodSync, mkdirSync, copyFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { parse } from "smol-toml"; +import type { SurfaceContext } from "../src/surfaces.js"; +import { realExec } from "../src/surfaces.js"; export function tmpRoot(): string { return mkdtempSync(join(tmpdir(), "amico-run-test-")); @@ -30,3 +34,228 @@ export function fakeJulia(dir: string, name: string, body: string): string { chmodSync(p, 0o755); return p; } + +// ── doctor v2 fixture worlds (#525) ───────────────────────────────────────── +// Fully hermetic surface fixtures: temp roots, fake binaries (shell scripts +// printing PINNED version strings), fabricated sidecars, real git repos with +// PINNED commit dates whose "remotes" are local bare repos. The real ~/.amico, +// ~/.vscode and ~/armonia are never touched. Date determinism: current cells +// pin far-future printed build dates + far-past git commit dates; stale cells +// flip one side. + +/** Pinned git commit date for every fixture commit (far past vs build dates). */ +export const GIT_COMMIT_DATE = "2026-08-01T12:00:00Z"; +export const GIT_DATE_ENV = { + GIT_AUTHOR_DATE: GIT_COMMIT_DATE, + GIT_COMMITTER_DATE: GIT_COMMIT_DATE, + GIT_AUTHOR_NAME: "doctor fixture", + GIT_AUTHOR_EMAIL: "fixture@example.test", + GIT_COMMITTER_NAME: "doctor fixture", + GIT_COMMITTER_EMAIL: "fixture@example.test", +}; +/** Far-future / far-past printed build dates (embedded in fake binaries). */ +export const FUTURE_BUILD = "0.0.0-local/amicode-209901010000"; +export const PAST_BUILD = "0.0.0-local/amicode-202601010000"; + +let trackedDirs: string[] = []; + +/** A tracked temp dir — removed by cleanupTracked(). */ +export function trackTmp(prefix: string): string { + const d = mkdtempSync(join(tmpdir(), prefix)); + trackedDirs.push(d); + return d; +} + +export function cleanupTracked(): void { + for (const d of trackedDirs) rmSync(d, { recursive: true, force: true }); + trackedDirs = []; +} + +/** git with the pinned-date env (fixtures never depend on wall-clock time). */ +export function fixtureGit(dir: string, args: string[], env: Record = {}): void { + execFileSync("git", ["-C", dir, ...args], { + env: { ...process.env, ...GIT_DATE_ENV, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +/** A fake "binary": a shell script printing a pinned version string. */ +export function fakeBin(dir: string, name: string, versionLine: string): string { + mkdirSync(dir, { recursive: true }); + const p = join(dir, name); + writeFileSync(p, `#!/bin/sh\necho "${versionLine}"\n`); + chmodSync(p, 0o755); + return p; +} + +export const sha256File = (p: string): string => createHash("sha256").update(readFileSync(p)).digest("hex"); + +export function writeJsonFile(p: string, v: unknown): void { + writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); +} + +export interface DoctorWorld { + root: string; + server: string; + vscext: string; + config: string; + repoAmicode: string; + repoFork: string; + remoteAmicode: string; + remoteFork: string; + staging: string; + running: string; + frozenBin: string; +} + +export interface DoctorWorldOpts { + /** printed by the frozen server binary (build date embedded) */ + frozenVersion?: string; + /** running-process stub; undefined = byte-copy of frozen; null = no process */ + runningVersion?: string | null; +} + +/** The vendored-binary platform dir, derived from the LIVE platform — the + * default SurfaceContext resolves the same way, so the CLI-level tests are + * runner-portable (darwin-arm64 here, linux-x64 on CI). */ +export const LIVE_PLATFORM = `${process.platform}-${process.arch}`; + +/** The canonical CURRENT doctor-v2 world: every surface at its source of truth. */ +export function buildDoctorWorld(opts: DoctorWorldOpts = {}): DoctorWorld { + const root = trackTmp("doctor-v2-"); + const server = join(root, "server"); + const vscext = join(root, "vscode", "extensions"); + const config = join(root, "config", "opencode"); + const staging = join(root, "staging", "opencode-project"); + const repoAmicode = join(root, "repos", "amicode"); + const repoFork = join(root, "repos", "opencode"); + const remoteAmicode = join(root, "remotes", "amicode.git"); + const remoteFork = join(root, "remotes", "opencode.git"); + + // ── frozen server binary + sidecar + running-process stub ── + const frozenBin = fakeBin(join(server, "bin"), "opencode", opts.frozenVersion ?? FUTURE_BUILD); + writeFileSync(`${frozenBin}.sha256`, `${sha256File(frozenBin)} opencode\n`); + const running = opts.runningVersion === null ? "" : join(root, "running-opencode"); + if (opts.runningVersion === undefined) copyFileSync(frozenBin, running); + else if (opts.runningVersion !== null) fakeBin(root, "running-opencode", opts.runningVersion); + + // ── VSIX extension dirs: 0.2.6 NEWEST by version, 0.2.4 written AFTER it + // (newer mtime) — proves selection is version-sorted, never mtime ── + const extDir = join(vscext, "harmoniqs.amicode-0.2.6"); + const oldExtDir = join(vscext, "harmoniqs.amicode-0.2.4-darwin-arm64"); + for (const s of ["alpha", "beta"]) { + mkdirSync(join(extDir, "skills", s), { recursive: true }); + writeFileSync(join(extDir, "skills", s, "SKILL.md"), `# ${s}\nVSIX skill ${s} v0.2.6\n`); + } + for (const s of ["alpha", "beta"]) { + mkdirSync(join(oldExtDir, "skills", s), { recursive: true }); + writeFileSync(join(oldExtDir, "skills", s, "SKILL.md"), `# ${s}\nVSIX skill ${s} v0.2.4\n`); + } + + // ── staged skills: byte-identical to the newest VSIX set ── + for (const s of ["alpha", "beta"]) { + mkdirSync(join(staging, "skills", s), { recursive: true }); + copyFileSync(join(extDir, "skills", s, "SKILL.md"), join(staging, "skills", s, "SKILL.md")); + } + + // ── agent cards: source (amicode repo) + both deployments + receipt ── + const agentsSrc = join(repoAmicode, "packages", "extension", "agents"); + for (const c of ["autodev.md", "autoresearch.md"]) { + mkdirSync(agentsSrc, { recursive: true }); + writeFileSync(join(agentsSrc, c), `---\nmode: ${c.replace(".md", "")}\n---\n# ${c}\n`); + } + mkdirSync(join(config, "agents"), { recursive: true }); + mkdirSync(join(staging, ".opencode", "agents"), { recursive: true }); + for (const c of ["autodev.md", "autoresearch.md"]) { + copyFileSync(join(agentsSrc, c), join(config, "agents", c)); + copyFileSync(join(agentsSrc, c), join(staging, ".opencode", "agents", c)); + } + writeJsonFile(join(agentsSrc, ".deploy-receipt.json"), { + receipt_version: 1, + deployed_at: "2026-08-01T00:00:00.000Z", + dry_run: false, + sources: ["autodev.md", "autoresearch.md"].map((c) => ({ + card: c, + path: join(agentsSrc, c), + sha256: `sha256:${sha256File(join(agentsSrc, c))}`, + })), + destinations: [], + }); + + // ── amicode repo (git fixture): extension version 0.2.6 on main, vendored + // binary printing the fork release base version, agent-card sources ── + writeFileSync( + join(repoAmicode, "packages", "extension", "package.json"), + JSON.stringify({ name: "amicode", version: "0.2.6" }, null, 2) + "\n", + ); + fakeBin( + join(repoAmicode, "packages", "extension", "vendor", "opencode", LIVE_PLATFORM), + "opencode", + "1.18.10", + ); + fixtureGit(repoAmicode, ["init", "-b", "main"]); + fixtureGit(repoAmicode, ["add", "-A"]); + fixtureGit(repoAmicode, ["commit", "-m", "amicode fixture"]); + execFileSync("git", ["init", "--bare", "-b", "main", remoteAmicode]); + fixtureGit(repoAmicode, ["remote", "add", "origin", remoteAmicode]); + fixtureGit(repoAmicode, ["push", "-u", "origin", "main"]); + + // ── fork repo (git fixture): branch local/amicode, release tag on tip ── + mkdirSync(repoFork, { recursive: true }); + writeFileSync(join(repoFork, "README.md"), "fork fixture\n"); + fixtureGit(repoFork, ["init", "-b", "local/amicode"]); + fixtureGit(repoFork, ["add", "-A"]); + fixtureGit(repoFork, ["commit", "-m", "fork fixture"]); + fixtureGit(repoFork, ["tag", "v1.18.10-amicode.15"]); + execFileSync("git", ["init", "--bare", "-b", "local/amicode", remoteFork]); + fixtureGit(repoFork, ["remote", "add", "origin", remoteFork]); + fixtureGit(repoFork, ["push", "-u", "origin", "local/amicode"]); + fixtureGit(repoFork, ["push", "origin", "v1.18.10-amicode.15"]); + + return { root, server, vscext, config, repoAmicode, repoFork, remoteAmicode, remoteFork, staging, running, frozenBin }; +} + +export function ctxForWorld(w: DoctorWorld, over: Partial = {}): SurfaceContext { + const base: SurfaceContext = { + rootServer: w.server, + rootVscext: w.vscext, + rootConfig: w.config, + rootRepoAmicode: w.repoAmicode, + rootRepoFork: w.repoFork, + rootStaging: w.staging, + runningBinary: w.running || null, + platform: LIVE_PLATFORM, + run: realExec, + // hermetic default: a fixture that forgets to stub running-process + // discovery gets server-down, never the REAL machine's process + discoverRunning: async () => null, + }; + return { ...base, ...over }; +} + +/** Mutate a bare remote from a throwaway clone — the fixture's checkout learns + * of the change ONLY through doctor's fetch (source-of-truth movement). */ +export function withBareClone(bare: string, branch: string, fn: (clone: string) => void): void { + const clone = trackTmp("doctor-v2-clone-"); + execFileSync("git", ["clone", "--branch", branch, bare, clone], { stdio: ["ignore", "pipe", "pipe"] }); + fn(clone); + fixtureGit(clone, ["push", "origin", `HEAD:refs/heads/${branch}`]); +} + +export function bumpExtensionOnRemote(bare: string, version: string): void { + withBareClone(bare, "main", (clone) => { + writeFileSync( + join(clone, "packages", "extension", "package.json"), + JSON.stringify({ name: "amicode", version }, null, 2) + "\n", + ); + fixtureGit(clone, ["add", "-A"]); + fixtureGit(clone, ["commit", "-m", `bump extension to ${version}`]); + }); +} + +export function addReleaseTagOnRemote(bare: string, tag: string): void { + withBareClone(bare, "local/amicode", (clone) => { + fixtureGit(clone, ["tag", tag]); + fixtureGit(clone, ["push", "origin", tag]); + }); +} diff --git a/packages/amico-run/test/surfaces.test.ts b/packages/amico-run/test/surfaces.test.ts index 095b8529..e324d1ea 100644 --- a/packages/amico-run/test/surfaces.test.ts +++ b/packages/amico-run/test/surfaces.test.ts @@ -4,245 +4,44 @@ // fabricated next to them, git fixtures are real repos with PINNED commit dates // (env overrides at setup) whose "remotes" are local bare repos (or dead paths // for the unreachable-remote cells). The real ~/.amico, ~/.vscode and -// ~/armonia are NEVER touched. +// ~/armonia are NEVER touched. The world builder lives in test/helpers.ts +// (shared with the doctor unit + CLI tests). // // Date determinism (the spec's rule): current cells pin BOTH sides — fake // binaries print FAR-FUTURE build dates (2099…), git commits carry FAR-PAST // pinned dates (2026-08-01); stale cells flip one side. No mtime is ever read. +// +// Authorship (the house split): implementer authored these cells; the reviewer +// adds adversarial variants — recorded in test/fixtures/surfaces/README.md. import { describe, test, expect } from "vitest"; -import { - mkdtempSync, - mkdirSync, - writeFileSync, - chmodSync, - rmSync, - copyFileSync, - readFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; +import { writeFileSync, rmSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { surfaceInventory, type SurfaceContext } from "../src/surfaces.js"; - -// ── pinned dates ───────────────────────────────────────────────────────────── -// Git commit dates via the standard env overrides (far past); fake binaries -// print far-future build dates for current cells, far-past for stale ones. -const GIT_COMMIT_DATE = "2026-08-01T12:00:00Z"; -const GIT_DATE_ENV = { - GIT_AUTHOR_DATE: GIT_COMMIT_DATE, - GIT_COMMITTER_DATE: GIT_COMMIT_DATE, - GIT_AUTHOR_NAME: "doctor fixture", - GIT_AUTHOR_EMAIL: "fixture@example.test", - GIT_COMMITTER_NAME: "doctor fixture", - GIT_COMMITTER_EMAIL: "fixture@example.test", -}; -const FUTURE_BUILD = "0.0.0-local/amicode-209901010000"; // build date 2099-01-01 -const PAST_BUILD = "0.0.0-local/amicode-202601010000"; // build date 2026-01-01 - -// ── fixture helpers ────────────────────────────────────────────────────────── -let dirs: string[] = []; - -function tmp(): string { - const d = mkdtempSync(join(tmpdir(), "doctor-v2-")); - dirs.push(d); - return d; -} - -function cleanup(): void { - for (const d of dirs) rmSync(d, { recursive: true, force: true }); - dirs = []; -} - -function git(dir: string, args: string[], env: Record = {}): void { - execFileSync("git", ["-C", dir, ...args], { - env: { ...process.env, ...GIT_DATE_ENV, ...env }, - stdio: ["ignore", "pipe", "pipe"], - }); -} - -/** A fake "binary": a shell script printing a pinned version string. */ -function fakeBin(dir: string, name: string, versionLine: string): string { - mkdirSync(dir, { recursive: true }); - const p = join(dir, name); - writeFileSync(p, `#!/bin/sh\necho "${versionLine}"\n`); - chmodSync(p, 0o755); - return p; -} - -const sha256 = (p: string): string => createHash("sha256").update(readFileSync(p)).digest("hex"); - -function writeJson(p: string, v: unknown): void { - writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); -} - -interface World { - root: string; - server: string; - vscext: string; - config: string; - repoAmicode: string; - repoFork: string; - remoteAmicode: string; - remoteFork: string; - staging: string; - running: string; - frozenBin: string; -} - -interface WorldOpts { - /** printed by the frozen server binary (build date embedded) */ - frozenVersion?: string; - /** the running-process stub; null = fabricate a byte-copy of frozen */ - runningVersion?: string | null; -} - -/** The canonical CURRENT world: every surface at its source of truth. */ -function buildWorld(opts: WorldOpts = {}): World { - const root = tmp(); - const server = join(root, "server"); - const vscext = join(root, "vscode", "extensions"); - const config = join(root, "config", "opencode"); - const staging = join(root, "staging", "opencode-project"); - const repoAmicode = join(root, "repos", "amicode"); - const repoFork = join(root, "repos", "opencode"); - const remoteAmicode = join(root, "remotes", "amicode.git"); - const remoteFork = join(root, "remotes", "opencode.git"); - - // ── frozen server binary + sidecar + running-process stub ── - const frozenBin = fakeBin(join(server, "bin"), "opencode", opts.frozenVersion ?? FUTURE_BUILD); - writeFileSync(`${frozenBin}.sha256`, `${sha256(frozenBin)} opencode\n`); - const running = opts.runningVersion === null ? "" : join(root, "running-opencode"); - if (opts.runningVersion === undefined) copyFileSync(frozenBin, running); - else if (opts.runningVersion !== null) fakeBin(root, "running-opencode", opts.runningVersion); - - // ── VSIX extension dirs: 0.2.6 NEWEST by version, 0.2.4 written AFTER it - // (newer mtime) — proves selection is version-sorted, never mtime ── - const extDir = join(vscext, "harmoniqs.amicode-0.2.6"); - const oldExtDir = join(vscext, "harmoniqs.amicode-0.2.4-darwin-arm64"); - for (const s of ["alpha", "beta"]) { - mkdirSync(join(extDir, "skills", s), { recursive: true }); - writeFileSync(join(extDir, "skills", s, "SKILL.md"), `# ${s}\nVSIX skill ${s} v0.2.6\n`); - } - // (created after 0.2.6 → newer mtime; different skills so a mispick shows) - for (const s of ["alpha", "beta"]) { - mkdirSync(join(oldExtDir, "skills", s), { recursive: true }); - writeFileSync(join(oldExtDir, "skills", s, "SKILL.md"), `# ${s}\nVSIX skill ${s} v0.2.4\n`); - } - - // ── staged skills: byte-identical to the newest VSIX set ── - for (const s of ["alpha", "beta"]) { - mkdirSync(join(staging, "skills", s), { recursive: true }); - copyFileSync(join(extDir, "skills", s, "SKILL.md"), join(staging, "skills", s, "SKILL.md")); - } - - // ── agent cards: source (amicode repo) + both deployments + receipt ── - const agentsSrc = join(repoAmicode, "packages", "extension", "agents"); - for (const c of ["autodev.md", "autoresearch.md"]) { - mkdirSync(agentsSrc, { recursive: true }); - writeFileSync(join(agentsSrc, c), `---\nmode: ${c.replace(".md", "")}\n---\n# ${c}\n`); - } - mkdirSync(join(config, "agents"), { recursive: true }); - mkdirSync(join(staging, ".opencode", "agents"), { recursive: true }); - for (const c of ["autodev.md", "autoresearch.md"]) { - copyFileSync(join(agentsSrc, c), join(config, "agents", c)); - copyFileSync(join(agentsSrc, c), join(staging, ".opencode", "agents", c)); - } - writeJson(join(agentsSrc, ".deploy-receipt.json"), { - receipt_version: 1, - deployed_at: "2026-08-01T00:00:00.000Z", - dry_run: false, - sources: ["autodev.md", "autoresearch.md"].map((c) => ({ - card: c, - path: join(agentsSrc, c), - sha256: `sha256:${sha256(join(agentsSrc, c))}`, - })), - destinations: [], - }); - - // ── amicode repo (git fixture): extension version 0.2.6 on main, vendored - // binary printing the fork release base version, agent-card sources ── - writeFileSync( - join(repoAmicode, "packages", "extension", "package.json"), - JSON.stringify({ name: "amicode", version: "0.2.6" }, null, 2) + "\n", - ); - fakeBin( - join(repoAmicode, "packages", "extension", "vendor", "opencode", "darwin-arm64"), - "opencode", - "1.18.10", - ); - git(repoAmicode, ["init", "-b", "main"]); - git(repoAmicode, ["add", "-A"]); - git(repoAmicode, ["commit", "-m", "amicode fixture"]); - execFileSync("git", ["init", "--bare", "-b", "main", remoteAmicode]); - git(repoAmicode, ["remote", "add", "origin", remoteAmicode]); - git(repoAmicode, ["push", "-u", "origin", "main"]); - - // ── fork repo (git fixture): branch local/amicode, release tag on tip ── - mkdirSync(repoFork, { recursive: true }); - writeFileSync(join(repoFork, "README.md"), "fork fixture\n"); - git(repoFork, ["init", "-b", "local/amicode"]); - git(repoFork, ["add", "-A"]); - git(repoFork, ["commit", "-m", "fork fixture"]); - git(repoFork, ["tag", "v1.18.10-amicode.15"]); - execFileSync("git", ["init", "--bare", "-b", "local/amicode", remoteFork]); - git(repoFork, ["remote", "add", "origin", remoteFork]); - git(repoFork, ["push", "-u", "origin", "local/amicode"]); - git(repoFork, ["push", "origin", "v1.18.10-amicode.15"]); - - return { root, server, vscext, config, repoAmicode, repoFork, remoteAmicode, remoteFork, staging, running, frozenBin }; -} - -function ctxFor(w: World, over: Partial = {}): SurfaceContext { - return { - rootServer: w.server, - rootVscext: w.vscext, - rootConfig: w.config, - rootRepoAmicode: w.repoAmicode, - rootRepoFork: w.repoFork, - rootStaging: w.staging, - runningBinary: w.running || null, - platform: "darwin-arm64", - ...over, - }; -} - -const bySurface = (report: { surfaces: { surface: string }[] }, name: string) => +import { fileURLToPath } from "node:url"; +import { dirname, join as joinPath } from "node:path"; +import { surfaceInventory, canonicalJson, type SurfaceContext, type SurfaceRecord } from "../src/surfaces.js"; +import { loadDoctorSchema, validateDoctorReport } from "../src/doctor_schema.js"; +import { + buildDoctorWorld, + ctxForWorld, + cleanupTracked, + fixtureGit, + bumpExtensionOnRemote, + addReleaseTagOnRemote, + FUTURE_BUILD, + PAST_BUILD, + type DoctorWorld, +} from "./helpers.js"; + +const cleanup = cleanupTracked; + +const bySurface = (report: { surfaces: SurfaceRecord[] }, name: string): SurfaceRecord => report.surfaces.find((r) => r.surface === name)!; -// ── remote-side mutations (the source of truth moves WITHOUT the local -// checkout — commits/tags are pushed from throwaway clones only, so the -// fixture's checkout learns of them solely through doctor's fetch) ──────── -function withBareClone(bare: string, branch: string, fn: (clone: string) => void): void { - const clone = join(tmp(), "clone"); - execFileSync("git", ["clone", "--branch", branch, bare, clone], { stdio: ["ignore", "pipe", "pipe"] }); - fn(clone); - git(clone, ["push", "origin", `HEAD:refs/heads/${branch}`]); -} - -function bumpExtensionOnRemote(bare: string, version: string): void { - withBareClone(bare, "main", (clone) => { - writeFileSync( - join(clone, "packages", "extension", "package.json"), - JSON.stringify({ name: "amicode", version }, null, 2) + "\n", - ); - git(clone, ["add", "-A"]); - git(clone, ["commit", "-m", `bump extension to ${version}`]); - }); -} - -function addReleaseTagOnRemote(bare: string, tag: string): void { - withBareClone(bare, "local/amicode", (clone) => { - git(clone, ["tag", tag]); - git(clone, ["push", "origin", tag]); - }); -} - // ── the matrix ─────────────────────────────────────────────────────────────── describe("doctor v2 surface inventory — current cells", () => { test("current world: all six surfaces current, records complete and ordered", async () => { - const w = buildWorld(); - const report = await surfaceInventory(ctxFor(w)); + const w = buildDoctorWorld(); + const report = await surfaceInventory(ctxForWorld(w)); expect(report.surfaces.map((r) => [r.surface, r.verdict])).toEqual([ ["server-binary", "current"], ["extension", "current"], @@ -272,12 +71,116 @@ describe("doctor v2 surface inventory — current cells", () => { }); }); +describe("doctor v2 surface inventory — stale cells", () => { + test("server-binary stale (version): far-past build date < pinned HEAD commit date", async () => { + const w = buildDoctorWorld({ frozenVersion: PAST_BUILD }); // build 2026-01-01 < HEAD 2026-08-01 + const report = await surfaceInventory(ctxForWorld(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("stale"); + expect(sb.version).toBe(PAST_BUILD); + expect(sb.evidence.join(" ")).toMatch(/build date .* < HEAD commit date/); + cleanup(); + }); + + test("server-binary stale (restart pending): running binary sha ≠ frozen sha", async () => { + // different bytes (one-digit-different version line) → different sha + const w = buildDoctorWorld({ runningVersion: "0.0.0-local/amicode-209901010001" }); + const report = await surfaceInventory(ctxForWorld(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("stale"); + expect(sb.evidence.join(" ")).toMatch(/running .* sha256 .* ≠ frozen sha256 .* \(restart pending\)/); + cleanup(); + }); + + test("server-binary stale (server-down): absent process is stale with server-down evidence", async () => { + const w = buildDoctorWorld({ runningVersion: null }); + const report = await surfaceInventory(ctxForWorld(w, { discoverRunning: async () => null })); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("stale"); + expect(sb.evidence.join(" ")).toMatch(/server-down: no running opencode serve process/); + cleanup(); + }); + + test("extension stale: installed 0.2.6 behind fetched origin/main 0.2.7", async () => { + const w = buildDoctorWorld(); + bumpExtensionOnRemote(w.remoteAmicode, "0.2.7"); + const report = await surfaceInventory(ctxForWorld(w)); + const ext = bySurface(report, "extension"); + expect(ext.verdict).toBe("stale"); + expect(ext.version).toContain("0.2.6"); + expect(ext.source_version).toBe("0.2.7"); + expect(ext.evidence.join(" ")).toMatch(/behind/); + cleanup(); + }); + + test("vendored-binary stale: printed 1.18.10 behind new release tag base 1.18.12", async () => { + const w = buildDoctorWorld(); + addReleaseTagOnRemote(w.remoteFork, "v1.18.12-amicode.1"); + const report = await surfaceInventory(ctxForWorld(w)); + const vb = bySurface(report, "vendored-binary"); + expect(vb.verdict).toBe("stale"); + expect(vb.version).toBe("1.18.10"); + expect(vb.source_version).toBe("1.18.12"); + expect(vb.evidence.join(" ")).toMatch(/behind/); + cleanup(); + }); + + test("staged-skills stale: per-skill digest diff (changed skill named in evidence)", async () => { + const w = buildDoctorWorld(); + writeFileSync(join(w.staging, "skills", "beta", "SKILL.md"), "# beta\nDRIFTED staged copy\n"); + const report = await surfaceInventory(ctxForWorld(w)); + const sk = bySurface(report, "staged-skills"); + expect(sk.verdict).toBe("stale"); + expect(sk.evidence.join(" ")).toMatch(/skill beta changed/); + expect(sk.evidence.join(" ")).not.toMatch(/skill alpha/); // alpha still byte-matches + cleanup(); + }); + + test("agent-cards-global stale: deployed card tampered (per-card digest diff)", async () => { + const w = buildDoctorWorld(); + writeFileSync(join(w.config, "agents", "autodev.md"), "---\nmode: autodev\n---\n# TAMPERED\n"); + const report = await surfaceInventory(ctxForWorld(w)); + const g = bySurface(report, "agent-cards-global"); + expect(g.verdict).toBe("stale"); + expect(g.evidence.join(" ")).toMatch(/card autodev\.md changed/); + const st = bySurface(report, "agent-cards-staging"); + expect(st.verdict).toBe("current"); // the OTHER deployment is unaffected + cleanup(); + }); + + test("agent-cards-staging stale: source present + receipt missing is stale (digest diff governs, receipt secondary)", async () => { + const w = buildDoctorWorld(); + rmSync(join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json")); + const report = await surfaceInventory(ctxForWorld(w)); + for (const name of ["agent-cards-global", "agent-cards-staging"]) { + const r = bySurface(report, name); + expect(r.verdict).toBe("stale"); + expect(r.evidence.join(" ")).toMatch(/receipt missing/); + expect(r.evidence.join(" ")).toMatch(/byte-match/); // bytes agree — the receipt is the staleness + } + cleanup(); + }); + + test("agent-cards stale: receipt source digests ≠ current sources", async () => { + const w = buildDoctorWorld(); + const receiptPath = join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"); + const receipt = JSON.parse(readFileSync(receiptPath, "utf8")) as { sources: { card: string; sha256: string }[] }; + receipt.sources[0].sha256 = "sha256:" + "0".repeat(64); // lies about autodev.md + writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n"); + const report = await surfaceInventory(ctxForWorld(w)); + const g = bySurface(report, "agent-cards-global"); + expect(g.verdict).toBe("stale"); + expect(g.evidence.join(" ")).toMatch(/receipt source digest for autodev\.md ≠ current source/); + cleanup(); + }); +}); + describe("doctor v2 surface inventory — integrity-failure cell", () => { test("server-binary integrity-failure: tampered sidecar (frozen sha ≠ sidecar)", async () => { - const w = buildWorld(); + const w = buildDoctorWorld(); const sidecar = `${w.frozenBin}.sha256`; writeFileSync(sidecar, `${"0".repeat(64)} opencode\n`); // the sidecar lies - const report = await surfaceInventory(ctxFor(w)); + const report = await surfaceInventory(ctxForWorld(w)); const sb = bySurface(report, "server-binary"); expect(sb.verdict).toBe("integrity-failure"); expect(sb.evidence.join(" ")).toMatch(/frozen sha256 .* ≠ sidecar/); @@ -292,21 +195,21 @@ describe("doctor v2 surface inventory — unknown cells (every surface degrades const DEAD_REMOTE = "/nonexistent/doctors-fixture-remote.git"; test("server-binary unknown: unreachable fork remote", async () => { - const w = buildWorld(); - git(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); - const report = await surfaceInventory(ctxFor(w)); + const w = buildDoctorWorld(); + fixtureGit(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); + const report = await surfaceInventory(ctxForWorld(w)); const sb = bySurface(report, "server-binary"); expect(sb.verdict).toBe("unknown"); expect(sb.evidence.join(" ")).toMatch(/fork fetch failed/); // local facts still reported: integrity + running checks pass - expect(sb.evidence.join(" ")).toMatch(/running .* = frozen|local checks pass/); + expect(sb.evidence.join(" ")).toMatch(/local checks pass/); cleanup(); }); test("extension unknown: unreachable amicode remote", async () => { - const w = buildWorld(); - git(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]); - const report = await surfaceInventory(ctxFor(w)); + const w = buildDoctorWorld(); + fixtureGit(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]); + const report = await surfaceInventory(ctxForWorld(w)); const ext = bySurface(report, "extension"); expect(ext.verdict).toBe("unknown"); expect(ext.evidence.join(" ")).toMatch(/amicode fetch failed/); @@ -316,9 +219,9 @@ describe("doctor v2 surface inventory — unknown cells (every surface degrades }); test("vendored-binary unknown: unreachable fork remote (release tags not refreshable)", async () => { - const w = buildWorld(); - git(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); - const report = await surfaceInventory(ctxFor(w)); + const w = buildDoctorWorld(); + fixtureGit(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); + const report = await surfaceInventory(ctxForWorld(w)); const vb = bySurface(report, "vendored-binary"); expect(vb.verdict).toBe("unknown"); expect(vb.evidence.join(" ")).toMatch(/fork fetch failed/); @@ -326,9 +229,9 @@ describe("doctor v2 surface inventory — unknown cells (every surface degrades }); test("staged-skills unknown: missing local source (no VSIX skills set)", async () => { - const w = buildWorld(); + const w = buildDoctorWorld(); rmSync(w.vscext, { recursive: true, force: true }); - const report = await surfaceInventory(ctxFor(w)); + const report = await surfaceInventory(ctxForWorld(w)); const sk = bySurface(report, "staged-skills"); expect(sk.verdict).toBe("unknown"); expect(sk.evidence.join(" ")).toMatch(/missing local source/); @@ -336,9 +239,9 @@ describe("doctor v2 surface inventory — unknown cells (every surface degrades }); test("agent-cards-global unknown: missing source dir", async () => { - const w = buildWorld(); + const w = buildDoctorWorld(); rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true }); - const report = await surfaceInventory(ctxFor(w)); + const report = await surfaceInventory(ctxForWorld(w)); const g = bySurface(report, "agent-cards-global"); expect(g.verdict).toBe("unknown"); expect(g.evidence.join(" ")).toMatch(/missing local source/); @@ -346,9 +249,9 @@ describe("doctor v2 surface inventory — unknown cells (every surface degrades }); test("agent-cards-staging unknown: missing source dir", async () => { - const w = buildWorld(); + const w = buildDoctorWorld(); rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true }); - const report = await surfaceInventory(ctxFor(w)); + const report = await surfaceInventory(ctxForWorld(w)); const st = bySurface(report, "agent-cards-staging"); expect(st.verdict).toBe("unknown"); expect(st.evidence.join(" ")).toMatch(/missing local source/); @@ -356,12 +259,12 @@ describe("doctor v2 surface inventory — unknown cells (every surface degrades }); test("no report ever fails: all six records present even when every source is unreachable", async () => { - const w = buildWorld(); - git(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); - git(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]); + const w = buildDoctorWorld(); + fixtureGit(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); + fixtureGit(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]); rmSync(w.vscext, { recursive: true, force: true }); rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true }); - const report = await surfaceInventory(ctxFor(w)); + const report = await surfaceInventory(ctxForWorld(w)); expect(report.surfaces).toHaveLength(6); // every source of truth is dead → every surface degrades to unknown, and // the report still returns all six records — never a failed report @@ -371,106 +274,99 @@ describe("doctor v2 surface inventory — unknown cells (every surface degrades }); }); -describe("doctor v2 surface inventory — stale cells", () => { - test("server-binary stale (version): far-past build date < pinned HEAD commit date", async () => { - const w = buildWorld({ frozenVersion: PAST_BUILD }); // build 2026-01-01 < HEAD 2026-08-01 - const report = await surfaceInventory(ctxFor(w)); - const sb = bySurface(report, "server-binary"); - expect(sb.verdict).toBe("stale"); - expect(sb.version).toBe(PAST_BUILD); - expect(sb.evidence.join(" ")).toMatch(/build date .* < HEAD commit date/); - cleanup(); - }); +// ── the JSON contract (AC: schema + canonical form) ───────────────────────── +describe("doctor v2 JSON contract", () => { + const schemaPath = joinPath(dirname(fileURLToPath(import.meta.url)), "..", "schemas", "doctor-report.schema.json"); + + const representativeWorlds: { name: string; build: () => Promise<{ report: unknown; world: DoctorWorld }> }[] = [ + { + name: "current world", + build: async () => { + const w = buildDoctorWorld(); + return { report: await surfaceInventory(ctxForWorld(w)), world: w }; + }, + }, + { + name: "stale world (version-stale server binary)", + build: async () => { + const w = buildDoctorWorld({ frozenVersion: PAST_BUILD }); + return { report: await surfaceInventory(ctxForWorld(w)), world: w }; + }, + }, + { + name: "integrity-failure world (tampered sidecar)", + build: async () => { + const w = buildDoctorWorld(); + writeFileSync(`${w.frozenBin}.sha256`, `${"0".repeat(64)} opencode\n`); + return { report: await surfaceInventory(ctxForWorld(w)), world: w }; + }, + }, + { + name: "unknown world (dead fork remote)", + build: async () => { + const w = buildDoctorWorld(); + fixtureGit(w.repoFork, ["remote", "set-url", "origin", "/nonexistent/x.git"]); + return { report: await surfaceInventory(ctxForWorld(w)), world: w }; + }, + }, + ]; + + for (const world of representativeWorlds) { + test(`${world.name}: report validates against the committed schema and round-trips byte-equal under the canonical form`, async () => { + const { report } = await world.build(); + const v = validateDoctorReport(report); + expect(v.errors, JSON.stringify(v.errors)).toEqual([]); + expect(v.ok).toBe(true); + const once = canonicalJson(report); + expect(once.endsWith("\n")).toBe(true); // trailing newline + expect(once).toBe(canonicalJson(JSON.parse(once))); // round-trip byte-equal + expect(once.split("\n")[1]).toBe(' "surfaces": ['); // 2-space indent, sorted keys + cleanup(); + }); + } - test("server-binary stale (restart pending): running binary sha ≠ frozen sha", async () => { - // different bytes (one-digit-different version line) → different sha - const w = buildWorld({ runningVersion: "0.0.0-local/amicode-209901010001" }); - const report = await surfaceInventory(ctxFor(w)); - const sb = bySurface(report, "server-binary"); - expect(sb.verdict).toBe("stale"); - expect(sb.evidence.join(" ")).toMatch(/running .* sha256 .* ≠ frozen sha256 .* \(restart pending\)/); - cleanup(); + test("the committed schema file itself is canonical (deep-sorted keys, 2-space, trailing newline)", () => { + const raw = readFileSync(schemaPath, "utf8"); + expect(raw).toBe(canonicalJson(JSON.parse(raw))); }); - test("server-binary stale (server-down): absent process is stale with server-down evidence", async () => { - const w = buildWorld({ runningVersion: null }); - const report = await surfaceInventory(ctxFor(w, { discoverRunning: async () => null })); - const sb = bySurface(report, "server-binary"); - expect(sb.verdict).toBe("stale"); - expect(sb.evidence.join(" ")).toMatch(/server-down: no running opencode serve process/); - cleanup(); + test("the committed schema enforces minItems 6 and the required record fields", () => { + const schema = loadDoctorSchema(); + const surfaces = (schema.properties as Record).surfaces; + expect(surfaces.minItems).toBe(6); + const record = (schema.properties as Record).surfaces.items; + expect([...record.required].sort()).toEqual(["evidence", "surface", "verdict", "version"]); }); - test("extension stale: installed 0.2.6 behind fetched origin/main 0.2.7", async () => { - const w = buildWorld(); - bumpExtensionOnRemote(w.remoteAmicode, "0.2.7"); - const report = await surfaceInventory(ctxFor(w)); - const ext = bySurface(report, "extension"); - expect(ext.verdict).toBe("stale"); - expect(ext.version).toContain("0.2.6"); - expect(ext.source_version).toBe("0.2.7"); - expect(ext.evidence.join(" ")).toMatch(/behind/); - cleanup(); - }); + test("schema rejects: five surfaces, missing field, bad verdict, non-array evidence", () => { + const good = { + surfaces: [ + { surface: "server-binary", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] }, + { surface: "extension", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] }, + { surface: "vendored-binary", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] }, + { surface: "staged-skills", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] }, + { surface: "agent-cards-global", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] }, + { surface: "agent-cards-staging", version: "1", source_version: "1", verdict: "current", evidence: ["ok"] }, + ], + }; + expect(validateDoctorReport(good).ok).toBe(true); - test("vendored-binary stale: printed 1.18.10 behind new release tag base 1.18.12", async () => { - const w = buildWorld(); - addReleaseTagOnRemote(w.remoteFork, "v1.18.12-amicode.1"); - const report = await surfaceInventory(ctxFor(w)); - const vb = bySurface(report, "vendored-binary"); - expect(vb.verdict).toBe("stale"); - expect(vb.version).toBe("1.18.10"); - expect(vb.source_version).toBe("1.18.12"); - expect(vb.evidence.join(" ")).toMatch(/behind/); - cleanup(); - }); + const five = { surfaces: good.surfaces.slice(0, 5) }; + expect(validateDoctorReport(five).errors.some((e) => /at least 6 items/.test(e.message))).toBe(true); - test("staged-skills stale: per-skill digest diff (changed skill named in evidence)", async () => { - const w = buildWorld(); - writeFileSync(join(w.staging, "skills", "beta", "SKILL.md"), "# beta\nDRIFTED staged copy\n"); - const report = await surfaceInventory(ctxFor(w)); - const sk = bySurface(report, "staged-skills"); - expect(sk.verdict).toBe("stale"); - expect(sk.evidence.join(" ")).toMatch(/skill beta changed/); - expect(sk.evidence.join(" ")).not.toMatch(/skill alpha/); // alpha still byte-matches - cleanup(); - }); + const missingField = { surfaces: good.surfaces.map((s, i) => (i === 0 ? { surface: "server-binary", verdict: "current", evidence: ["x"] } : s)) }; + expect(validateDoctorReport(missingField).errors.some((e) => e.path === "$.surfaces[0]" && /version/.test(e.message))).toBe(true); - test("agent-cards-global stale: deployed card tampered (per-card digest diff)", async () => { - const w = buildWorld(); - writeFileSync(join(w.config, "agents", "autodev.md"), "---\nmode: autodev\n---\n# TAMPERED\n"); - const report = await surfaceInventory(ctxFor(w)); - const g = bySurface(report, "agent-cards-global"); - expect(g.verdict).toBe("stale"); - expect(g.evidence.join(" ")).toMatch(/card autodev\.md changed/); - const st = bySurface(report, "agent-cards-staging"); - expect(st.verdict).toBe("current"); // the OTHER deployment is unaffected - cleanup(); - }); + const badVerdict = { surfaces: good.surfaces.map((s, i) => (i === 1 ? { ...s, verdict: "borked" } : s)) }; + expect(validateDoctorReport(badVerdict).errors.some((e) => e.path === "$.surfaces[1].verdict")).toBe(true); - test("agent-cards-staging stale: source present + receipt missing is stale (digest diff governs, receipt secondary)", async () => { - const w = buildWorld(); - rmSync(join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json")); - const report = await surfaceInventory(ctxFor(w)); - for (const name of ["agent-cards-global", "agent-cards-staging"]) { - const r = bySurface(report, name); - expect(r.verdict).toBe("stale"); - expect(r.evidence.join(" ")).toMatch(/receipt missing/); - expect(r.evidence.join(" ")).toMatch(/byte-match/); // bytes agree — the receipt is the staleness - } - cleanup(); - }); + const badEvidence = { surfaces: good.surfaces.map((s, i) => (i === 2 ? { ...s, evidence: "ok" } : s)) }; + expect(validateDoctorReport(badEvidence).errors.some((e) => e.path === "$.surfaces[2].evidence")).toBe(true); - test("agent-cards stale: receipt source digests ≠ current sources", async () => { - const w = buildWorld(); - const receiptPath = join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"); - const receipt = JSON.parse(readFileSync(receiptPath, "utf8")) as { sources: { card: string; sha256: string }[] }; - receipt.sources[0].sha256 = "sha256:" + "0".repeat(64); // lies about autodev.md - writeJson(receiptPath, receipt); - const report = await surfaceInventory(ctxFor(w)); - const g = bySurface(report, "agent-cards-global"); - expect(g.verdict).toBe("stale"); - expect(g.evidence.join(" ")).toMatch(/receipt source digest for autodev\.md ≠ current source/); - cleanup(); + const emptyEvidence = { surfaces: good.surfaces.map((s, i) => (i === 3 ? { ...s, evidence: [] } : s)) }; + expect(validateDoctorReport(emptyEvidence).errors.some((e) => e.path === "$.surfaces[3].evidence")).toBe(true); + + const unknownSurface = { surfaces: good.surfaces.map((s, i) => (i === 4 ? { ...s, surface: "sidecar-bin" } : s)) }; + expect(validateDoctorReport(unknownSurface).errors.some((e) => e.path === "$.surfaces[4].surface")).toBe(true); }); }); From 77aefc0f6d26e5263f4265526bdcdb2e033bc8c1 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 23 Aug 2026 13:25:04 -0400 Subject: [PATCH 4/4] =?UTF-8?q?test(doctor):=20reviewer=20adversarial=20va?= =?UTF-8?q?riants=20=E2=80=94=20authorship=20gate=20discharged=20(#525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/amico-run/src/surfaces.ts | 18 ++ .../test/fixtures/surfaces/README.md | 63 ++++-- packages/amico-run/test/surfaces.test.ts | 180 +++++++++++++++++- 3 files changed, 247 insertions(+), 14 deletions(-) diff --git a/packages/amico-run/src/surfaces.ts b/packages/amico-run/src/surfaces.ts index 5d77f6ec..de80683c 100644 --- a/packages/amico-run/src/surfaces.ts +++ b/packages/amico-run/src/surfaces.ts @@ -481,6 +481,15 @@ async function probeStagedSkills(ctx: SurfaceContext): Promise { if (dst !== src) diffs.push(`skill ${skill} changed (staged ${dst.slice(0, 12)} ≠ VSIX ${src.slice(0, 12)})`); } } + // reverse direction (reviewer pass 2026-08-23): a skill staged but absent + // from the VSIX set is drift too — a leftover an older deployment dropped. + // Extras count toward the staged set digest, so version ≠ source when drifted. + for (const skill of stagedSkills) { + if (sourceSkills.includes(skill)) continue; + const dst = await dirDigest(join(stagedDir, skill)); + if (dst !== null) stagedDigests.set(skill, dst); + diffs.push(`skill ${skill} extra in staged set (absent from VSIX source set)`); + } const sourceSet = setDigest(sourceDigests); const stagedSet = setDigest(stagedDigests); if (diffs.length > 0) { @@ -526,6 +535,15 @@ async function probeAgentCards( if (dstSha !== srcSha) diffs.push(`card ${card} changed (deployed ${dstSha.slice(0, 12)} ≠ source ${srcSha.slice(0, 12)})`); } } + // reverse direction (reviewer pass 2026-08-23): a deployed card absent from + // the sources is drift — a leftover the next deploy would never refresh. + // Extras count toward the deployed set digest, so version ≠ source when drifted. + for (const card of deployedCards) { + if (sourceCards.includes(card)) continue; + const dstSha = await fileSha(join(deployedDir, card)); + if (dstSha !== null) deployedDigests.set(card, dstSha); + diffs.push(`card ${card} extra in deployed set (absent from sources)`); + } const deployedSet = setDigest(deployedDigests); // receipt — secondary evidence: the digest diff governs; a missing/lying diff --git a/packages/amico-run/test/fixtures/surfaces/README.md b/packages/amico-run/test/fixtures/surfaces/README.md index 1f384d9a..8d3b58f8 100644 --- a/packages/amico-run/test/fixtures/surfaces/README.md +++ b/packages/amico-run/test/fixtures/surfaces/README.md @@ -27,19 +27,56 @@ Determinism: no mtime is read anywhere; "newest" is version-sorted (the current-world fixture writes the 0.2.4 VSIX dir AFTER 0.2.6 so its mtime is newer — the probe must still pick 0.2.6). -## Reviewer adversarial variants (to be added in review) - -Slots deliberately left open for the reviewer pass, per the house pattern: - -- server-binary: missing frozen binary / missing sidecar / unexecutable binary -- extension: installed AHEAD of origin/main; VSIX dirs with unparseable versions -- vendored: release-tag ordering (`-amicode.2` vs `-amicode.10`); binary - printing prerelease strings -- staged-skills: skill present in staging but absent from the VSIX set (extra - skill); staged dir entirely missing -- agent-cards: extra deployed cards not in sources; unparseable receipt -- version strings: `0.0.0-local/amicode-<12 digits>` variants that defeat the - build-date parser +## Reviewer adversarial variants — pass 2026-08-23 (gate discharged) + +**reviewer pass 2026-08-23: 14 adversarial variants added; findings: 2 real +predicate gaps (extra staged skill / extra deployed card both judged +`current` by the one-directional digest loops), fixed minimally in +`src/surfaces.ts` and pinned; 1 prediction wrong (13-digit build stamp — the +parser's year guard rejects it), noted, not added.** + +### Findings — real predicate gaps, fixed + pinned + +1. **staged-skills extra skill**: a skill present in staging but absent from + the VSIX set was judged `current` — the digest loop ran source→staged + only, and the set digest silently excluded the extra (version equalled + source_version while the deployed set had drifted). Fix: extras are + flagged → `stale`, named in evidence, and count toward the staged set + digest. +2. **agent-cards extra deployed card**: the same one-directional loop; an + extra deployed `.md` card was judged `current`. Fixed identically. + + Direction ruling for both: extras are `stale`, not `unknown` — the source + of truth is fully readable and the drift is a local hard fact, repairable + by redeploy (the module's own "local facts outrank unknown" invariant). + +### Variants added (every one verified against the real probes first) + +| Variant | Pinned verdict | +| --- | --- | +| extra staged skill (reverse-direction drift) | stale — was `current` before the fix | +| extra deployed agent card (reverse-direction drift) | stale — was `current` before the fix | +| uppercase-hex sidecar digest | current (normalized, case-insensitive match) | +| build date exactly == HEAD commit date | current (staleness is strict `<`) | +| extension dirs 0.2.10 vs 0.2.9 | current via 0.2.10 (numeric sort; lexicographic flips) | +| release tags v1.18.10-amicode.2 vs v1.18.9-amicode.15 | current (numeric sort → base 1.18.10) | +| reachable fork remote, zero release tags | unknown (per-surface; server-binary stays current) | +| unexecutable frozen binary (chmod 644) | integrity-failure (`--version failed`) | +| extension installed ahead of origin/main | stale ("ahead" evidence) | +| vendored `--version` trailing whitespace | current (trimmed) | +| frozen binary missing | stale (absent surface = repairable) | +| sidecar missing while binary present | integrity-failure | +| staged skills dir entirely missing | stale | +| unparseable deploy receipt | stale (bytes match — the receipt is the staleness) | + +Probed and NOT added: a 13-digit build stamp (`…-2026080112000`) was +predicted to misparse as a year-260 date and flip the verdict to stale — it +does not: the parser's `y < 2000` guard rejects the shifted window, and the +verdict is `current` with honest "build date unparseable" evidence +(prediction wrong; out-of-contract input, no action). Ruled out without a +fixture: unparseable VSIX dir names (versionPrefix falls to "", sorts oldest +— or stale-behind when alone) and prerelease `--version` strings (judged +stale "ahead" of the release-tag base, which is defensible). Hermeticity: every fixture injects temp roots (tracked + cleaned via `cleanupTracked`); the real `~/.amico`, `~/.vscode`, and `~/armonia` are never diff --git a/packages/amico-run/test/surfaces.test.ts b/packages/amico-run/test/surfaces.test.ts index e324d1ea..1ff869ba 100644 --- a/packages/amico-run/test/surfaces.test.ts +++ b/packages/amico-run/test/surfaces.test.ts @@ -14,7 +14,7 @@ // Authorship (the house split): implementer authored these cells; the reviewer // adds adversarial variants — recorded in test/fixtures/surfaces/README.md. import { describe, test, expect } from "vitest"; -import { writeFileSync, rmSync, readFileSync } from "node:fs"; +import { writeFileSync, rmSync, readFileSync, chmodSync, mkdirSync, cpSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { dirname, join as joinPath } from "node:path"; @@ -27,6 +27,10 @@ import { fixtureGit, bumpExtensionOnRemote, addReleaseTagOnRemote, + fakeBin, + sha256File, + LIVE_PLATFORM, + GIT_COMMIT_DATE, FUTURE_BUILD, PAST_BUILD, type DoctorWorld, @@ -274,6 +278,180 @@ describe("doctor v2 surface inventory — unknown cells (every surface degrades }); }); +// ── reviewer adversarial variants (pass 2026-08-23) ────────────────────────── +// Born from the reviewer's scratch-probe pass against the real predicates +// (authorship gate, #525). Two probes found real gaps — the digest loops ran +// source→deployed only, so EXTRA staged/deployed content was judged current — +// fixed in src/surfaces.ts and pinned here (reverse-direction drift). The rest +// pin correct handling of adversarial inputs the implementer matrix never +// exercised. Full record: test/fixtures/surfaces/README.md. +describe("doctor v2 surface inventory — reviewer adversarial variants (2026-08-23)", () => { + test("staged-skills stale: extra staged skill absent from the VSIX set (reverse-direction drift)", async () => { + const w = buildDoctorWorld(); + mkdirSync(join(w.staging, "skills", "ghost"), { recursive: true }); + writeFileSync(join(w.staging, "skills", "ghost", "SKILL.md"), "# ghost\nleftover from an older deployment\n"); + const report = await surfaceInventory(ctxForWorld(w)); + const sk = bySurface(report, "staged-skills"); + expect(sk.verdict).toBe("stale"); + expect(sk.evidence.join(" ")).toMatch(/skill ghost extra in staged set/); + expect(sk.evidence.join(" ")).not.toMatch(/skill (alpha|beta) /); // shared skills still byte-match + expect(sk.version).not.toBe(sk.source_version); // deployed set identity includes the extra + cleanup(); + }); + + test("agent-cards-global stale: extra deployed card absent from sources (reverse-direction drift)", async () => { + const w = buildDoctorWorld(); + writeFileSync(join(w.config, "agents", "ghost.md"), "---\nmode: ghost\n---\n# ghost\n"); + const report = await surfaceInventory(ctxForWorld(w)); + const g = bySurface(report, "agent-cards-global"); + expect(g.verdict).toBe("stale"); + expect(g.evidence.join(" ")).toMatch(/card ghost\.md extra in deployed set/); + expect(g.version).not.toBe(g.source_version); + expect(bySurface(report, "agent-cards-staging").verdict).toBe("current"); // the other deployment is unaffected + cleanup(); + }); + + test("server-binary current: uppercase-hex sidecar digest is normalized", async () => { + const w = buildDoctorWorld(); + writeFileSync(`${w.frozenBin}.sha256`, `${sha256File(w.frozenBin).toUpperCase()} opencode\n`); + const report = await surfaceInventory(ctxForWorld(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("current"); + expect(sb.evidence.join(" ")).toMatch(/sha256 .* = sidecar/); + cleanup(); + }); + + test("server-binary current: build date exactly equal to HEAD commit date (boundary — equal is current)", async () => { + // stamp derived from the pinned commit date, not duplicated: equal minutes + const equalStamp = GIT_COMMIT_DATE.slice(0, 16).replace(/[-T:]/g, ""); + const w = buildDoctorWorld({ frozenVersion: `0.0.0-local/amicode-${equalStamp}` }); + const report = await surfaceInventory(ctxForWorld(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("current"); // staleness is strict <, so equal passes + expect(sb.evidence.join(" ")).toMatch(/build date .* ≥ HEAD commit date/); + cleanup(); + }); + + test("extension current: numeric version sort — 0.2.10 is newer than 0.2.9 (lexicographic would flip)", async () => { + const w = buildDoctorWorld(); + const skillSrc = join(w.vscext, "harmoniqs.amicode-0.2.6", "skills"); + for (const v of ["0.2.9", "0.2.10"]) { + cpSync(skillSrc, join(w.vscext, `harmoniqs.amicode-${v}`, "skills"), { recursive: true }); + } + rmSync(join(w.vscext, "harmoniqs.amicode-0.2.6"), { recursive: true, force: true }); + rmSync(join(w.vscext, "harmoniqs.amicode-0.2.4-darwin-arm64"), { recursive: true, force: true }); + bumpExtensionOnRemote(w.remoteAmicode, "0.2.10"); + const report = await surfaceInventory(ctxForWorld(w)); + const ext = bySurface(report, "extension"); + expect(ext.verdict).toBe("current"); + expect(ext.version).toBe("0.2.10"); + expect(bySurface(report, "staged-skills").verdict).toBe("current"); // compared against the 0.2.10 set + cleanup(); + }); + + test("vendored-binary unknown: reachable fork remote with NO release tags", async () => { + const w = buildDoctorWorld(); + fixtureGit(w.repoFork, ["tag", "-d", "v1.18.10-amicode.15"]); + fixtureGit(w.repoFork, ["push", "origin", ":refs/tags/v1.18.10-amicode.15"]); + const report = await surfaceInventory(ctxForWorld(w)); + const vb = bySurface(report, "vendored-binary"); + expect(vb.verdict).toBe("unknown"); + expect(vb.evidence.join(" ")).toMatch(/no fork release tags/); + // the remote is REACHABLE — only the tagless surface degrades + expect(bySurface(report, "server-binary").verdict).toBe("current"); + cleanup(); + }); + + test("vendored-binary current: release-tag sort is numeric (v1.18.10-amicode.2 > v1.18.9-amicode.15)", async () => { + const w = buildDoctorWorld(); + fixtureGit(w.repoFork, ["tag", "-d", "v1.18.10-amicode.15"]); + fixtureGit(w.repoFork, ["push", "origin", ":refs/tags/v1.18.10-amicode.15"]); + addReleaseTagOnRemote(w.remoteFork, "v1.18.9-amicode.15"); + addReleaseTagOnRemote(w.remoteFork, "v1.18.10-amicode.2"); + const report = await surfaceInventory(ctxForWorld(w)); + const vb = bySurface(report, "vendored-binary"); + expect(vb.verdict).toBe("current"); + expect(vb.source_version).toBe("1.18.10"); // string sort would crown v1.18.9-amicode.15 → stale "ahead" + expect(vb.evidence.join(" ")).toMatch(/latest fork release tag v1\.18\.10-amicode\.2/); + cleanup(); + }); + + test("server-binary integrity-failure: unexecutable frozen binary (--version fails)", async () => { + const w = buildDoctorWorld(); + chmodSync(w.frozenBin, 0o644); // bytes unchanged: sidecar still matches — the exec is what breaks + const report = await surfaceInventory(ctxForWorld(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("integrity-failure"); + expect(sb.evidence.join(" ")).toMatch(/--version failed/); + cleanup(); + }); + + test("extension stale: installed AHEAD of origin/main (source repo behind)", async () => { + const w = buildDoctorWorld(); + cpSync(join(w.vscext, "harmoniqs.amicode-0.2.6", "skills"), join(w.vscext, "harmoniqs.amicode-0.2.8", "skills"), { recursive: true }); + const report = await surfaceInventory(ctxForWorld(w)); + const ext = bySurface(report, "extension"); + expect(ext.verdict).toBe("stale"); + expect(ext.version).toBe("0.2.8"); + expect(ext.source_version).toBe("0.2.6"); + expect(ext.evidence.join(" ")).toMatch(/ahead of origin\/main/); + cleanup(); + }); + + test("vendored-binary current: --version output with trailing whitespace is trimmed", async () => { + const w = buildDoctorWorld(); + fakeBin(join(w.repoAmicode, "packages", "extension", "vendor", "opencode", LIVE_PLATFORM), "opencode", "1.18.10 "); + const report = await surfaceInventory(ctxForWorld(w)); + const vb = bySurface(report, "vendored-binary"); + expect(vb.verdict).toBe("current"); + expect(vb.version).toBe("1.18.10"); // untrimmed it would compare unequal to the tag base + cleanup(); + }); + + test("server-binary stale: frozen binary missing (absent surface is the repairable state)", async () => { + const w = buildDoctorWorld(); + rmSync(w.frozenBin, { force: true }); + const report = await surfaceInventory(ctxForWorld(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("stale"); + expect(sb.evidence.join(" ")).toMatch(/frozen binary missing/); + cleanup(); + }); + + test("server-binary integrity-failure: sidecar missing while binary present", async () => { + const w = buildDoctorWorld(); + rmSync(`${w.frozenBin}.sha256`, { force: true }); + const report = await surfaceInventory(ctxForWorld(w)); + const sb = bySurface(report, "server-binary"); + expect(sb.verdict).toBe("integrity-failure"); + expect(sb.evidence.join(" ")).toMatch(/sidecar missing/); + cleanup(); + }); + + test("staged-skills stale: staged dir entirely missing", async () => { + const w = buildDoctorWorld(); + rmSync(join(w.staging, "skills"), { recursive: true, force: true }); + const report = await surfaceInventory(ctxForWorld(w)); + const sk = bySurface(report, "staged-skills"); + expect(sk.verdict).toBe("stale"); + expect(sk.evidence.join(" ")).toMatch(/staged skills dir missing or empty/); + cleanup(); + }); + + test("agent-cards stale: unparseable deploy receipt (bytes match — the receipt is the staleness)", async () => { + const w = buildDoctorWorld(); + writeFileSync(join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"), "{not json"); + const report = await surfaceInventory(ctxForWorld(w)); + for (const name of ["agent-cards-global", "agent-cards-staging"]) { + const r = bySurface(report, name); + expect(r.verdict).toBe("stale"); + expect(r.evidence.join(" ")).toMatch(/receipt unparseable/); + expect(r.evidence.join(" ")).toMatch(/byte-match/); + } + cleanup(); + }); +}); + // ── the JSON contract (AC: schema + canonical form) ───────────────────────── describe("doctor v2 JSON contract", () => { const schemaPath = joinPath(dirname(fileURLToPath(import.meta.url)), "..", "schemas", "doctor-report.schema.json");