From 22f1d4774a7e09930570bf7a466031b418805dde Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 11:50:22 -0400 Subject: [PATCH 01/11] test(e2e): exercise MCP logs cutoff and typed failures against real Compose HACK-1134 (unit F13), HACK-1155 (P11). Add the docker-tier `mcp-logs-compose` scenario. It drives `hack mcp serve` over stdio against a real Compose project and checks the logs contract: complete, time cutoff (quiet and live), event cutoff, byte cutoff with a UTF-8-safe partial result, closed backend_error with an unreachable engine, a tail across a container restart, and no replay after reconnect. On the same stack it checks that `hack run -- false` / `exit 7` and the MCP `hack.project.run` envelope report the child's exit status. Add tests/run-child-exit-status.test.ts: the source CLI against a recording docker stub proves the exit status, argv after `--`, the pipe-filter negative control and the MCP envelope without a Docker engine. Update the HACK-P11 pattern card to cite the agent-surface-freeze negative control (#296) and narrow the missing contract; regenerate the plugin bundle. Co-Authored-By: Claude Opus 5.5 --- docs/reference/mcp-logs.md | 17 + .../skills/hack-cli/references/patterns.md | 8 +- src/agents/pattern-catalogue.ts | 6 +- tests/e2e/README.md | 43 + tests/e2e/run.ts | 2 + tests/e2e/scenarios/mcp-logs-compose.ts | 1005 +++++++++++++++++ tests/e2e/shard.ts | 3 + tests/run-child-exit-status.test.ts | 298 +++++ 8 files changed, 1375 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/scenarios/mcp-logs-compose.ts create mode 100644 tests/run-child-exit-status.test.ts diff --git a/docs/reference/mcp-logs.md b/docs/reference/mcp-logs.md index a734b9277..f6cab90d3 100644 --- a/docs/reference/mcp-logs.md +++ b/docs/reference/mcp-logs.md @@ -64,6 +64,23 @@ To continue a stream approximately, call again with `since` set to the last event's `ts`. `since` selects the Loki backend and cannot be combined with `compose`. Expect overlap at the boundary and remove duplicates on the client. +## Container restarts + +A restart of the logged container does not change the contract. A tail that +spans the restart still ends in one outcome within its bound. Compose may +reattach to the restarted container from a coarse timestamp, so a tail that +spans the restart can repeat a few lines from before it. A snapshot taken after +the restart holds each line once, in order, and a new tail starts from the +current output. + +## Verification + +`tests/mcp-logs-contract.test.ts` checks each outcome against a scripted stub +backend. The `mcp-logs-compose` scenario in the optional `docker-e2e` CI job +checks the same outcomes through `hack mcp serve` against a real Compose +project (see `tests/e2e/README.md`). It does not cover the Loki backend or the +native/authored logs path. + ## Decision: no handles, cursors or idempotency keys (no-go) Status: no-go for now (HACK-1134). This decision adds no cursor implementation. diff --git a/plugins/hack/skills/hack-cli/references/patterns.md b/plugins/hack/skills/hack-cli/references/patterns.md index b191f8189..3261fe85f 100644 --- a/plugins/hack/skills/hack-cli/references/patterns.md +++ b/plugins/hack/skills/hack-cli/references/patterns.md @@ -183,13 +183,13 @@ missing part with ad hoc scripts. ## HACK-P11: Stable agent guidance and typed operation results - Use when: Agents run project workflows and need versioned guidance plus machine-readable results. -- Disposition: Partial. Missing: A guidance freeze during execution and a typed terminal result for resumed background operations remain open. +- Disposition: Partial. Missing: A freeze that refuses explicit guidance updates while an agent runs, and a typed terminal result for resumed background operations, remain open. - Owner: WU19/WU21; tracked in HACK-1155. - Commands: `hack up --json`, `hack down --json`, `hack restart --json`, `hack doctor --json`, `hack run -- ` - Prerequisites: Generated guidance from Hack's canonical source with a content revision; `--json` on lifecycle and diagnostic commands. - Env ownership: Results report names and states, not values. -- Readiness: Pass child argv after `--` in `hack run -- `. Check the exit status or `--json` envelope directly rather than through a pipe filter. -- Cleanup: Guidance updates are explicit `hack setup` operations, never side effects of ordinary commands. +- Readiness: Pass child argv after `--` in `hack run -- `; its exit status, and the MCP run result's `exitCode`, are the child's own. Check the exit status or `--json` envelope directly rather than through a pipe filter. +- Cleanup: Guidance updates are explicit `hack setup` operations, never side effects of ordinary commands. The `agent-surface-freeze` E2E scenario checks that init, agent, doctor and setup-sync runs leave tracked guidance and client config byte-identical. - Provenance: 2 consumers (HACK-P11 research register). @@ -214,4 +214,4 @@ missing part with ad hoc scripts. --- -Content revision: `091b4e80a9b6`. Generated by Hack; edit the canonical source and regenerate the plugin. +Content revision: `f2c6f4c76304`. Generated by Hack; edit the canonical source and regenerate the plugin. diff --git a/src/agents/pattern-catalogue.ts b/src/agents/pattern-catalogue.ts index 2aa0b783a..76e8ed4e1 100644 --- a/src/agents/pattern-catalogue.ts +++ b/src/agents/pattern-catalogue.ts @@ -341,14 +341,14 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ "Generated guidance from Hack's canonical source with a content revision; `--json` on lifecycle and diagnostic commands.", envOwnership: "Results report names and states, not values.", readiness: - "Pass child argv after `--` in `hack run -- `. Check the exit status or `--json` envelope directly rather than through a pipe filter.", + "Pass child argv after `--` in `hack run -- `; its exit status, and the MCP run result's `exitCode`, are the child's own. Check the exit status or `--json` envelope directly rather than through a pipe filter.", cleanup: - "Guidance updates are explicit `hack setup` operations, never side effects of ordinary commands.", + "Guidance updates are explicit `hack setup` operations, never side effects of ordinary commands. The `agent-surface-freeze` E2E scenario checks that init, agent, doctor and setup-sync runs leave tracked guidance and client config byte-identical.", negativeControl: "Filtered shell text can hide a failed child, and historical dispatch, PR or gateway requests must not silently expand current product scope.", domainOwner: "WU19/WU21", missingContract: - "A guidance freeze during execution and a typed terminal result for resumed background operations remain open.", + "A freeze that refuses explicit guidance updates while an agent runs, and a typed terminal result for resumed background operations, remain open.", provenance: { consumers: 2, source: "HACK-P11 research register" }, example: null, }, diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 6ae45edea..323a218dd 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -247,6 +247,49 @@ fixture; host and provider proxy settings remain unchanged. In a real project, add its internal service names to the existing proxy exceptions rather than discarding the project's other entries. +## MCP logs on real Compose (F13) + +`mcp-logs-compose` (HACK-1134, HACK-1155) starts `hack mcp serve` over stdio +and calls the logs tools against a real Compose project. Every tool call runs +the CLI under test through `HACK_MCP_COMMAND`. The fixture uses cached +`alpine:3.20` with `pull_policy: never`, a private internal network, no +published ports, and Hack internal DNS/TLS turned off. Both log backends are set +to `compose`. It has three services: `ticker` prints a boot marker and then a +numbered line every 0.2 s, `quiet` prints one line, and `bulk` prints 1500 +lines of 2048 four-byte emoji (about 12 MiB) and then idles. + +The scenario requires these results: + +- A `quiet` snapshot completes with `cutoff: null`. A `quiet` tail stops at + `maxMs` with a `time` cutoff and only its one line. +- A `ticker` tail stops at `maxMs` with a `time` cutoff and live lines in + order. Another stops at exactly 5 log events with an `events` cutoff. +- A `bulk` snapshot stops at 8 MiB with a `bytes` cutoff and + `outputTruncated`. It keeps an ordered prefix of whole lines. No result + payload may contain U+FFFD, so a split code point fails the run. Where the + cut falls varies from run to run, so a single pass does not prove that this + run cut inside a code point; `tests/mcp-logs-contract.test.ts` covers that + case deterministically. +- With `DOCKER_HOST` pointing at a missing socket, a snapshot and a tail both + return the closed `backend_error`. The tail ends at end of stream, not at + `maxMs`. +- `docker restart` on the exact owned `ticker` container during a tail: the + tail still ends in one typed outcome within its bound. Lines repeated across + the restart are logged, not asserted (see `docs/reference/mcp-logs.md`). The + stored history then holds `BOOT 1` and `BOOT 2` once each, with ordered + lines. A new tail streams only the new boot. A new MCP session does not + replay lines older than the previous session's last line. +- `hack run quiet -- false` exits 1, and an `exit 7` child exits 7 with its + output. The MCP `hack.project.run` result reports `ok: false` and + `exitCode: 7`, and a `true` child reports `ok: true`. + +Cleanup removes only containers, networks and volumes with the fixture's +owner label. + +```sh +HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 bun tests/e2e/run.ts --only=mcp-logs-compose +``` + ## Native file qualification `native-config-files` requires the current compiled CLI, its matching compiler and diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 7fdfb7ff4..6601ed79a 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -22,6 +22,7 @@ import { execStaleEnvScenario } from "./scenarios/exec-stale-env.ts"; import { initScenario } from "./scenarios/init.ts"; import { lifecycleHostProcessScenario } from "./scenarios/lifecycle-host-process.ts"; import { lifecycleSessionRecoveryScenario } from "./scenarios/lifecycle-session-recovery.ts"; +import { mcpLogsComposeScenario } from "./scenarios/mcp-logs-compose.ts"; import { nativeComposeAdoptionJobWorktreesScenario } from "./scenarios/native-compose-adoption-job-worktrees.ts"; import { nativeComposeAdoptionRoutingWorktreesScenario } from "./scenarios/native-compose-adoption-routing-worktrees.ts"; import { nativeComposeAdoptionSourceBindWorktreesScenario } from "./scenarios/native-compose-adoption-source-bind-worktrees.ts"; @@ -98,6 +99,7 @@ export const ALL_SCENARIOS: readonly Scenario[] = [ execStaleEnvScenario, portableMultiserviceScenario, composeConfigHashDriftScenario, + mcpLogsComposeScenario, nativeConfigComposeScenario, nativeConfigBuildScenario, nativeConfigDownHooksScenario, diff --git a/tests/e2e/scenarios/mcp-logs-compose.ts b/tests/e2e/scenarios/mcp-logs-compose.ts new file mode 100644 index 000000000..1c29153bc --- /dev/null +++ b/tests/e2e/scenarios/mcp-logs-compose.ts @@ -0,0 +1,1005 @@ +import { chmod } from "node:fs/promises"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { createMonorepoFixture } from "../fixture.ts"; +import { + buildCliEnv, + type CliResult, + expect, + expectExit, + resolveCliSpawnArgs, + runCommand, + type Scenario, + type ScenarioContext, +} from "../harness.ts"; + +const OWNER = "hack.e2e.mcp-logs-compose-owner"; +const PROJECT = "com.docker.compose.project"; +const SERVICE = "com.docker.compose.service"; +const ONEOFF = "com.docker.compose.oneoff"; +const TIMEOUT = 180_000; +const IMAGE = "alpine:3.20"; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const CONTAINER_ID = /^[a-f0-9]{64}$/; +/** The MCP server's shared stdout+stderr capture budget (8 MiB). */ +const OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; +/** The closed post-spawn failure the logs tools report (src/mcp/logs-contract.ts). */ +const BACKEND_ERROR = { + code: "E_UNEXPECTED", + reason: "backend_error", + message: "Log backend failed. Run 'hack logs' for details.", +} as const; +const REPLACEMENT_CHAR = String.fromCodePoint(0xff_fd); +const TICK = /TICK boot=(\d+) seq=(\d+) →/; +const BOOT = /BOOT (\d+) →/; +/** Bulk lines are 2048 four-byte emoji (8 KiB); 1500 of them exceed 8 MiB. */ +const BULK_LINES = 1500; +const BULK_LINE = /BULK (\d+) ((?:😀)+)$/u; +const BULK_EMOJI_PER_LINE = 2048; +const RUNTIME_ENV = { + HACK_EXECUTION_MODE: "slim", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", +} as const; + +// Every service exits promptly on SIGTERM so restart and down stay fast. `$$` +// is Compose's escape for a literal `$` in the shell command. +const TICKER = [ + "trap 'exit 0' TERM", + // The writable layer survives a container restart, so this counts boots. + "b=$$(( $$(cat /tmp/boot 2>/dev/null || echo 0) + 1 ))", + "echo $$b > /tmp/boot", + 'echo "BOOT $$b →"', + "i=0", + 'while :; do i=$$((i + 1)); echo "TICK boot=$$b seq=$$i →"; sleep 0.2; done', +].join("; "); +const QUIET = [ + "trap 'exit 0' TERM", + "echo 'QUIET ready ✓'", + "while :; do sleep 1; done", +].join("; "); +const BULK = [ + "trap 'exit 0' TERM", + "s=😀", + "i=0", + "while [ $$i -lt 11 ]; do s=$$s$$s; i=$$((i + 1)); done", + "n=0", + `while [ $$n -lt ${BULK_LINES} ]; do n=$$((n + 1)); echo "BULK $$n $$s"; done`, + "echo BULK-DONE", + "while :; do sleep 1; done", +].join("; "); + +type ToolPayload = { + readonly isError: boolean; + readonly ok: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly outputTruncated: boolean; + readonly data: Readonly> | null; + readonly error: unknown; + /** The serialized result text, for whole-payload UTF-8 checks. */ + readonly text: string; +}; + +type Tick = { readonly boot: number; readonly seq: number }; + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function object(value: unknown): Record { + const parsed = record(value); + if (!parsed) { + throw new Error("Expected an object from Docker inspect"); + } + return parsed; +} + +/** Narrow one `tools/call` result to the documented logs/run payload. */ +function parsePayload(opts: { + readonly tool: string; + readonly result: unknown; +}): ToolPayload { + const result = record(opts.result); + const structured = record(result?.structuredContent); + if ( + !( + result && + structured && + typeof structured.ok === "boolean" && + typeof structured.exitCode === "number" && + typeof structured.stdout === "string" && + typeof structured.stderr === "string" + ) + ) { + throw new Error(`${opts.tool} returned no structured result payload`); + } + const content = Array.isArray(result.content) ? result.content : []; + const text = content + .map((part) => { + const entry = record(part); + return entry && typeof entry.text === "string" ? entry.text : ""; + }) + .join("\n"); + return { + isError: result.isError === true, + ok: structured.ok, + exitCode: structured.exitCode, + stdout: structured.stdout, + stderr: structured.stderr, + outputTruncated: structured.outputTruncated === true, + data: record(structured.data), + error: structured.error, + text, + }; +} + +function events(payload: ToolPayload): Record[] { + const list = payload.data?.events; + if (!Array.isArray(list)) { + return []; + } + return list.flatMap((event) => { + const parsed = record(event); + return parsed ? [parsed] : []; + }); +} + +/** The raw Compose line of every `type: "log"` event, in stream order. */ +function logLines(payload: ToolPayload): string[] { + return events(payload).flatMap((event) => { + if (event.type !== "log") { + return []; + } + const entry = record(event.entry); + return entry && typeof entry.raw === "string" ? [entry.raw] : []; + }); +} + +function ticksOf(lines: readonly string[]): Tick[] { + return lines.flatMap((line) => { + const match = TICK.exec(line); + return match ? [{ boot: Number(match[1]), seq: Number(match[2]) }] : []; + }); +} + +/** Strictly increasing (boot, seq): no line repeats or arrives out of order. */ +function strictlyOrdered(ticks: readonly Tick[]): boolean { + return ticks.every((tick, index) => { + const previous = ticks[index - 1]; + return ( + !previous || + tick.boot > previous.boot || + (tick.boot === previous.boot && tick.seq > previous.seq) + ); + }); +} + +/** The leading boot-1 ticks up to the first repeat, reorder or new boot. */ +function firstBootRun(ticks: readonly Tick[]): Tick[] { + const run: Tick[] = []; + for (const tick of ticks) { + const previous = run.at(-1); + if (tick.boot !== 1 || (previous && tick.seq <= previous.seq)) { + break; + } + run.push(tick); + } + return run; +} + +function cutoffOf(payload: ToolPayload): unknown { + return payload.data?.cutoff; +} + +function sameCutoff( + actual: unknown, + expected: { readonly bound: string; readonly limit: number } +): boolean { + const cutoff = record(actual); + return ( + cutoff !== null && + cutoff.bound === expected.bound && + cutoff.limit === expected.limit && + Object.keys(cutoff).length === 2 + ); +} + +function isClosedBackendError(error: unknown): boolean { + const parsed = record(error); + return ( + parsed !== null && + Object.keys(parsed).length === 3 && + parsed.code === BACKEND_ERROR.code && + parsed.reason === BACKEND_ERROR.reason && + parsed.message === BACKEND_ERROR.message + ); +} + +function summarize(payload: ToolPayload): string { + return JSON.stringify({ + isError: payload.isError, + ok: payload.ok, + exitCode: payload.exitCode, + outputTruncated: payload.outputTruncated, + cutoff: cutoffOf(payload) ?? null, + stop_reason: payload.data?.stop_reason ?? null, + duration_ms: payload.data?.duration_ms ?? null, + events: events(payload).length, + logs: logLines(payload).length, + error: payload.error ?? null, + stderr: payload.stderr.slice(-400), + }); +} + +/** Quote a path for a POSIX shell script. */ +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function composeDocument(opts: { + readonly name: string; + readonly image: string; +}): string { + const labels = { [OWNER]: opts.name }; + const service = (command: string) => ({ + image: opts.image, + pull_policy: "never", + labels, + stop_grace_period: "2s", + command: ["sh", "-c", command], + }); + return JSON.stringify({ + name: opts.name, + services: { + ticker: service(TICKER), + quiet: service(QUIET), + bulk: service(BULK), + }, + networks: { default: { internal: true, labels } }, + }); +} + +type Cutoff = { readonly bound: string; readonly limit: number }; + +type Session = { + readonly call: ( + tool: string, + input: Readonly> + ) => Promise; + readonly close: () => Promise; +}; + +type ResourceKind = "container" | "network" | "volume"; + +/** Owned effects and observations for one scenario run. */ +type Stack = { + readonly tempRoot: string; + readonly log: (message: string) => void; + readonly docker: (args: readonly string[]) => Promise; + readonly cli: (opts: { + readonly args: readonly string[]; + readonly codes: readonly number[]; + }) => Promise; + readonly list: ( + kind: ResourceKind, + filters: readonly string[] + ) => Promise; + readonly owned: (kind: ResourceKind, id: string) => Promise; + readonly serviceContainer: (service: string) => Promise; + readonly openSession: (opts: { + readonly label: string; + readonly env?: Readonly>; + }) => Promise; +}; + +/** Require a successful result and exactly the expected cutoff (or none). */ +function requireOk(opts: { + readonly step: string; + readonly payload: ToolPayload; + readonly cutoff: Cutoff | null; +}): void { + const { payload } = opts; + expect({ + that: payload.ok && !payload.isError && payload.error === undefined, + message: `${opts.step} must succeed without isError or error: ${summarize(payload)}`, + }); + expect({ + that: + opts.cutoff === null + ? cutoffOf(payload) === null && payload.exitCode === 0 + : sameCutoff(cutoffOf(payload), opts.cutoff), + message: `${opts.step} must report cutoff ${JSON.stringify(opts.cutoff)}: ${summarize(payload)}`, + }); +} + +/** The one typed outcome of a tail bounded by `maxMs`, or `untyped`. */ +function tailOutcome(opts: { + readonly payload: ToolPayload; + readonly maxMs: number; +}): "time-cutoff" | "complete" | "backend_error" | "untyped" { + const { payload } = opts; + if (!payload.ok) { + return isClosedBackendError(payload.error) && cutoffOf(payload) === null + ? "backend_error" + : "untyped"; + } + if (sameCutoff(cutoffOf(payload), { bound: "time", limit: opts.maxMs })) { + return "time-cutoff"; + } + return cutoffOf(payload) === null && + payload.data?.stop_reason === "eof" && + payload.exitCode === 0 + ? "complete" + : "untyped"; +} + +async function waitFor(opts: { + readonly step: string; + readonly probe: () => Promise; +}): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (await opts.probe()) { + return; + } + await Bun.sleep(1000); + } + throw new Error(`Timed out waiting for ${opts.step}`); +} + +/** Wait until the bulk writer finished and the ticker has several lines. */ +async function waitForOutput(session: Session): Promise { + await waitFor({ + step: "bulk output and ticks", + probe: async () => { + const bulk = await session.call("hack.project.logs.snapshot", { + service: "bulk", + tail: 1, + }); + const ticker = await session.call("hack.project.logs.snapshot", { + service: "ticker", + tail: 5, + }); + return ( + logLines(bulk).some((line) => line.endsWith("BULK-DONE")) && + ticksOf(logLines(ticker)).length >= 5 + ); + }, + }); +} + +/** Complete, quiet-wait, live time cutoff and event cutoff outcomes. */ +async function checkBoundedOutcomes(session: Session): Promise { + // Complete: a quiet service reaches end of stream with exit 0. + const quietSnapshot = await session.call("hack.project.logs.snapshot", { + service: "quiet", + }); + requireOk({ step: "quiet snapshot", payload: quietSnapshot, cutoff: null }); + expect({ + that: + logLines(quietSnapshot).filter((line) => line.endsWith("QUIET ready ✓")) + .length === 1 && + events(quietSnapshot).some( + (event) => event.type === "end" && event.reason === "eof" + ), + message: `quiet snapshot must hold its one line and a clean end event: ${summarize(quietSnapshot)}`, + }); + + // Time cutoff on a quiet stream: the wait ends at maxMs, not in a hang. + const quietTail = await session.call("hack.project.logs.tail", { + service: "quiet", + tail: 1, + maxMs: 2000, + }); + requireOk({ + step: "quiet tail", + payload: quietTail, + cutoff: { bound: "time", limit: 2000 }, + }); + expect({ + that: + quietTail.data?.stop_reason === "timeout" && + logLines(quietTail).length === 1, + message: `quiet tail must stop on time with only the replayed line: ${summarize(quietTail)}`, + }); + + // Time cutoff on a live stream: new lines arrive in order until maxMs. + const timeTail = await session.call("hack.project.logs.tail", { + service: "ticker", + tail: 1, + maxEvents: 10_000, + maxMs: 3000, + }); + requireOk({ + step: "ticker time tail", + payload: timeTail, + cutoff: { bound: "time", limit: 3000 }, + }); + const timeTicks = ticksOf(logLines(timeTail)); + const duration = Number(timeTail.data?.duration_ms); + expect({ + that: + timeTail.data?.stop_reason === "timeout" && + timeTicks.length >= 5 && + strictlyOrdered(timeTicks) && + duration >= 3000 && + duration < 30_000, + message: `ticker tail must follow live lines in order until maxMs: ${summarize(timeTail)}`, + }); + + // Event cutoff: exactly maxEvents log events; other events do not count. + const eventTail = await session.call("hack.project.logs.tail", { + service: "ticker", + tail: 50, + maxEvents: 5, + maxMs: 30_000, + }); + requireOk({ + step: "ticker event tail", + payload: eventTail, + cutoff: { bound: "events", limit: 5 }, + }); + const eventTicks = ticksOf(logLines(eventTail)); + expect({ + that: + eventTail.data?.stop_reason === "max_events" && + logLines(eventTail).length === 5 && + eventTicks.length === 5 && + strictlyOrdered(eventTicks) && + Number(eventTail.data?.duration_ms) < 30_000, + message: `ticker tail must stop at exactly 5 log events: ${summarize(eventTail)}`, + }); +} + +/** + * Byte cutoff: more than 8 MiB of four-byte emoji. The partial result keeps + * its parsed events, in order and whole, and the cut never splits a code + * point (the per-call check rejects any U+FFFD in the payload). + */ +async function checkByteCutoff(opts: { + readonly session: Session; + readonly log: (message: string) => void; +}): Promise { + const bytes = await opts.session.call("hack.project.logs.snapshot", { + service: "bulk", + tail: 10_000, + }); + requireOk({ + step: "bulk snapshot", + payload: bytes, + cutoff: { bound: "bytes", limit: OUTPUT_LIMIT_BYTES }, + }); + const bulkLines = logLines(bytes); + const bulkBodies = bulkLines.flatMap((line) => { + const match = BULK_LINE.exec(line); + return match ? [{ n: Number(match[1]), body: match[2] ?? "" }] : []; + }); + expect({ + that: + bytes.outputTruncated && + bulkBodies.length > 0 && + bulkBodies.length < BULK_LINES && + !bulkLines.some((line) => line.endsWith("BULK-DONE")) && + bulkBodies.every( + (entry, index) => + entry.n === index + 1 && + [...entry.body].length === BULK_EMOJI_PER_LINE + ), + message: `bulk snapshot must keep a complete, ordered prefix of whole lines: ${summarize(bytes)}`, + }); + expect({ + that: !bytes.stdout.includes(REPLACEMENT_CHAR), + message: "bulk snapshot stdout must not split a code point", + }); + opts.log( + `bulk byte cut: ${bulkBodies.length} whole lines, ${Buffer.byteLength(bytes.stdout)} stdout bytes kept (limit ${OUTPUT_LIMIT_BYTES})` + ); +} + +/** + * Backend failure: with the engine unreachable, both tools return the closed + * backend_error, never a truncated success or a time cutoff. + */ +async function checkBackendError(stack: Stack): Promise { + const broken = await stack.openSession({ + label: "unreachable-engine", + env: { DOCKER_HOST: `unix://${join(stack.tempRoot, "absent.sock")}` }, + }); + const snapshot = await broken.call("hack.project.logs.snapshot", { + service: "ticker", + }); + const tail = await broken.call("hack.project.logs.tail", { + service: "ticker", + maxEvents: 5, + maxMs: 30_000, + }); + for (const failed of [snapshot, tail]) { + expect({ + that: + !failed.ok && + failed.isError && + failed.exitCode !== 0 && + isClosedBackendError(failed.error) && + cutoffOf(failed) === null && + logLines(failed).every((line) => !TICK.test(line)) && + !JSON.stringify(failed.error).includes(stack.tempRoot), + message: `logs must return the closed backend_error when the engine is unreachable: ${summarize(failed)}`, + }); + } + expect({ + that: + tail.data?.stop_reason === "eof" && + Number(tail.data?.duration_ms) < 30_000, + message: `a failed tail must end at end of stream, not at maxMs: ${summarize(tail)}`, + }); + await broken.close(); +} + +/** + * Restart the backend container under an in-flight tail, then require both + * boots exactly once in the stored history and a new tail on the new boot. + * Returns the last tick sequence number the session saw. + */ +async function checkRestart(opts: { + readonly stack: Stack; + readonly session: Session; +}): Promise { + const { stack, session } = opts; + const tickerId = await stack.serviceContainer("ticker"); + const startedAt = async () => + await stack.docker([ + "inspect", + tickerId, + "--format", + "{{.State.StartedAt}}", + ]); + const startedBefore = await startedAt(); + const maxMs = 10_000; + const inFlight = session.call("hack.project.logs.tail", { + service: "ticker", + tail: 1, + maxEvents: 10_000, + maxMs, + }); + await Bun.sleep(2500); + await stack.owned("container", tickerId); + await stack.docker(["restart", "--time", "2", tickerId]); + const spanning = await inFlight; + const spanningTicks = ticksOf(logLines(spanning)); + const outcome = tailOutcome({ payload: spanning, maxMs }); + // Compose may re-attach to the restarted container from a coarse + // timestamp, so lines repeated across the restart are recorded, not + // asserted; the pre-restart run itself must be ordered. + stack.log( + `tail across restart: ${outcome}; boots seen ${[...new Set(spanningTicks.map((tick) => tick.boot))].join(",")}; ordered across restart: ${strictlyOrdered(spanningTicks)}` + ); + expect({ + that: + outcome !== "untyped" && + spanning.isError === !spanning.ok && + Number(spanning.data?.duration_ms) < 40_000 && + firstBootRun(spanningTicks).length >= 2, + message: `a tail across a restart must end in one typed outcome after ordered pre-restart lines: ${summarize(spanning)}`, + }); + expect({ + that: + (await stack.serviceContainer("ticker")) === tickerId && + (await startedAt()) !== startedBefore, + message: + "docker restart must restart the same ticker container (positive control)", + }); + + // The stored history holds both boots exactly once, in order. + const histories: ToolPayload[] = []; + await waitFor({ + step: "post-restart ticks", + probe: async () => { + const history = await session.call("hack.project.logs.snapshot", { + service: "ticker", + tail: 10_000, + }); + histories.push(history); + return ( + ticksOf(logLines(history)).filter((tick) => tick.boot === 2).length >= 3 + ); + }, + }); + const history = histories.at(-1); + if (!history) { + throw new Error("No post-restart snapshot"); + } + requireOk({ step: "post-restart snapshot", payload: history, cutoff: null }); + const historyLines = logLines(history); + const boots = historyLines.flatMap((line) => { + const match = BOOT.exec(line); + return match ? [Number(match[1])] : []; + }); + const historyTicks = ticksOf(historyLines); + expect({ + that: + JSON.stringify(boots) === "[1,2]" && + strictlyOrdered(historyTicks) && + historyTicks.some((tick) => tick.boot === 1) && + historyTicks.some((tick) => tick.boot === 2), + message: `snapshot after restart must hold BOOT 1 then BOOT 2 and ordered ticks: ${summarize(history)}`, + }); + + // A new tail starts from the current stream: its one replayed line and + // every later line belong to the new boot. + const fresh = await session.call("hack.project.logs.tail", { + service: "ticker", + tail: 1, + maxEvents: 3, + maxMs: 30_000, + }); + requireOk({ + step: "post-restart tail", + payload: fresh, + cutoff: { bound: "events", limit: 3 }, + }); + const freshTicks = ticksOf(logLines(fresh)); + expect({ + that: + freshTicks.length === 3 && + freshTicks.every((tick) => tick.boot === 2) && + strictlyOrdered(freshTicks), + message: `a tail after restart must stream only the new boot: ${summarize(fresh)}`, + }); + return freshTicks.at(-1)?.seq ?? 0; +} + +/** A new MCP session starts a new stream and never replays older lines. */ +async function checkReconnect(opts: { + readonly session: Session; + readonly lastSeen: number; +}): Promise { + const after = await opts.session.call("hack.project.logs.tail", { + service: "ticker", + tail: 1, + maxEvents: 2, + maxMs: 30_000, + }); + requireOk({ + step: "reconnected tail", + payload: after, + cutoff: { bound: "events", limit: 2 }, + }); + const afterTicks = ticksOf(logLines(after)); + expect({ + that: + afterTicks.length === 2 && + afterTicks.every( + (tick) => tick.boot === 2 && tick.seq >= opts.lastSeen + ) && + strictlyOrdered(afterTicks), + message: `a reconnected tail must not replay earlier lines (last seen seq ${opts.lastSeen}): ${summarize(after)}`, + }); +} + +/** + * HACK-1155 (P11): the child's exit status survives the CLI and the MCP + * envelope. `false` fails with 1; `exit 7` keeps 7 and its output. + */ +async function checkRunExitStatus(opts: { + readonly stack: Stack; + readonly session: Session; +}): Promise { + const { stack, session } = opts; + await stack.cli({ args: ["run", "quiet", "--", "false"], codes: [1] }); + const seven = await stack.cli({ + args: ["run", "quiet", "--", "sh", "-c", "printf p11-partial; exit 7"], + codes: [7], + }); + expect({ + that: seven.stdout.includes("p11-partial"), + message: "hack run must pass the failed child's output through", + result: seven, + }); + const envelope = await session.call("hack.project.run", { + service: "quiet", + cmd: ["sh", "-c", "printf p11-partial; exit 7"], + }); + expect({ + that: + !envelope.ok && + envelope.isError && + envelope.exitCode === 7 && + envelope.stdout.includes("p11-partial"), + message: `hack.project.run must report the child's exit 7 as a failure: ${summarize(envelope)}`, + }); + const passed = await session.call("hack.project.run", { + service: "quiet", + cmd: ["true"], + }); + expect({ + that: passed.ok && !passed.isError && passed.exitCode === 0, + message: `hack.project.run must report a successful child as ok (positive control): ${summarize(passed)}`, + }); + expect({ + that: (await stack.list("container", [`${ONEOFF}=True`])).length === 0, + message: "hack run must not leave a one-off container behind", + }); +} + +/** Require a Linux engine with Compose and the cached image; return its ID. */ +async function requireCachedImage(docker: Stack["docker"]): Promise { + expect({ + that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", + message: "A Linux Docker daemon is required", + }); + await docker(["compose", "version"]); + const image = await docker([ + "image", + "inspect", + IMAGE, + "--format", + "{{.Id}}", + ]); + expect({ + that: IMAGE_ID.test(image), + message: `${IMAGE} must already be cached with an immutable ID`, + }); + return image; +} + +/** Write the fixture and build the owned-effect helpers for one run. */ +async function prepareStack(opts: { + readonly ctx: ScenarioContext; + readonly sessions: Session[]; +}): Promise<{ readonly stack: Stack; readonly clean: () => Promise }> { + const { ctx } = opts; + const docker = async (args: readonly string[]) => { + const result = await runCommand({ + argv: ["docker", ...args], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + expectExit({ + result, + codes: [0], + message: `Docker ${args[0]} must succeed`, + }); + return result.stdout.trim(); + }; + const image = await requireCachedImage(docker); + const fixture = await createMonorepoFixture({ + parentDir: ctx.tempRoot, + withHackConfig: true, + lifecycle: { disableInternal: true }, + }); + const name = fixture.name; + await Bun.write( + join(fixture.hackDir, "hack.config.json"), + JSON.stringify({ + name, + internal: { dns: false, tls: false }, + logs: { + follow_backend: "compose", + snapshot_backend: "compose", + clear_on_down: false, + }, + }) + ); + await Bun.write( + join(fixture.hackDir, "docker-compose.yml"), + composeDocument({ name, image }) + ); + + // The MCP server runs `HACK_MCP_COMMAND` for every tool, so point it at the + // CLI under test (the compiled binary in CI, else the source CLI). + const mcpCommand = join(ctx.tempRoot, "hack-under-test"); + await Bun.write( + mcpCommand, + `#!/bin/sh\nexec ${resolveCliSpawnArgs([]).map(shellQuote).join(" ")} "$@"\n` + ); + await chmod(mcpCommand, 0o755); + + const list = async (kind: ResourceKind, filters: readonly string[]) => { + const args = kind === "container" ? ["ps", "-aq"] : [kind, "ls", "-q"]; + return ( + await docker([ + ...args, + "--filter", + `label=${OWNER}=${name}`, + ...filters.flatMap((filter) => ["--filter", `label=${filter}`]), + ]) + ) + .split(/\s+/) + .filter(Boolean); + }; + const owned = async (kind: ResourceKind, id: string) => { + const expression = + kind === "container" ? "{{json .Config.Labels}}" : "{{json .Labels}}"; + const actual = object( + JSON.parse(await docker([kind, "inspect", id, "--format", expression])) + ); + expect({ + that: actual[OWNER] === name && actual[PROJECT] === name, + message: `Refuse ${kind} mutation outside exact fixture ownership`, + }); + }; + const clean = async () => { + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await list(kind, [])) { + await owned(kind, id); + await docker([ + kind, + "rm", + ...(kind === "container" ? ["--force"] : []), + id, + ]); + } + expect({ + that: (await list(kind, [])).length === 0, + message: `Owned ${kind} cleanup must leave no resources`, + }); + } + }; + const stack: Stack = { + tempRoot: ctx.tempRoot, + log: ctx.log, + docker, + list, + owned, + cli: async ({ args, codes }) => { + const result = await ctx.cli({ + args, + cwd: fixture.root, + timeoutMs: TIMEOUT, + env: RUNTIME_ENV, + }); + expectExit({ result, codes, message: `hack ${args.join(" ")}` }); + return result; + }, + serviceContainer: async (service) => { + const ids = await list("container", [ + `${SERVICE}=${service}`, + `${ONEOFF}=False`, + ]); + const id = ids[0]; + if (ids.length !== 1 || !(id && CONTAINER_ID.test(id))) { + throw new Error(`Expected exactly one owned ${service} container`); + } + await owned("container", id); + return id; + }, + openSession: async (session) => { + const opened = await openMcpSession({ + ctx, + label: session.label, + cwd: fixture.root, + env: { ...RUNTIME_ENV, HACK_MCP_COMMAND: mcpCommand, ...session.env }, + }); + opts.sessions.push(opened); + return opened; + }, + }; + return { stack, clean }; +} + +/** + * Start `hack mcp serve` over stdio with the harness's isolated CLI + * environment. Every call targets the fixture by path and rejects a payload + * that contains U+FFFD, so no tool result may split a code point. + */ +async function openMcpSession(opts: { + readonly ctx: ScenarioContext; + readonly label: string; + readonly cwd: string; + readonly env: Readonly>; +}): Promise { + const [command, ...args] = resolveCliSpawnArgs(["mcp", "serve"]); + if (!command) { + throw new Error("No CLI command to serve MCP"); + } + const transport = new StdioClientTransport({ + command, + args, + cwd: opts.cwd, + env: buildCliEnv({ hackHome: opts.ctx.hackHome, extra: opts.env }), + stderr: "pipe", + }); + let stderrTail = ""; + transport.stderr?.on("data", (chunk: Buffer | string) => { + stderrTail = `${stderrTail}${String(chunk)}`.slice(-2000); + }); + const client = new Client({ name: "hack-e2e-mcp-logs", version: "0.0.0" }); + await client.connect(transport); + return { + close: async () => { + await client.close(); + }, + call: async (tool, input) => { + let raw: unknown; + try { + raw = await client.callTool( + { name: tool, arguments: { ...input } }, + undefined, + { timeout: TIMEOUT } + ); + } catch (error) { + throw new Error( + `${opts.label} ${tool} failed: ${String(error)}\nserver stderr: ${stderrTail}` + ); + } + const payload = parsePayload({ tool, result: raw }); + opts.ctx.log( + `${opts.label} ${tool} ${JSON.stringify(input)}: ${summarize(payload)}` + ); + expect({ + that: !payload.text.includes(REPLACEMENT_CHAR), + message: `${tool} must return valid UTF-8 (no U+FFFD in the payload)`, + }); + return payload; + }, + }; +} + +/** + * F13 / HACK-1134: the MCP `hack.project.logs.snapshot` and + * `hack.project.logs.tail` contract against a real Compose project, driven + * through `hack mcp serve` over stdio with the current CLI as the log backend. + * + * Each collection must end in exactly one typed outcome: + * - complete: a quiet snapshot reaches end of stream (`cutoff: null`); + * - time cutoff: a tail of a ticking service and of a quiet one stops at + * `maxMs` with `data.cutoff = {bound: "time"}`; + * - event cutoff: exactly `maxEvents` `type: "log"` events, `{bound: "events"}`; + * - byte cutoff: a snapshot of more than 8 MiB of four-byte emoji stops at the + * capture budget with `{bound: "bytes"}`, keeps the parsed events and never + * splits a code point (no U+FFFD anywhere in the returned payload); + * - backend_error: with the Docker engine unreachable, both tools return the + * closed `backend_error`, never a truncated success or a time cutoff. + * + * Across a container restart, an in-flight tail must still end in one typed + * outcome inside its bound, the backend keeps both boots' lines exactly once + * and in order, and a new tail starts from the current stream. A new MCP + * session never replays events older than the previous session saw. + * + * HACK-1155 (P11) on the same stack: `hack run -- false` and an `exit 7` + * child return the child's status from the CLI and from the MCP + * `hack.project.run` envelope (`ok: false`, `exitCode: 7`). + */ +export const mcpLogsComposeScenario: Scenario = { + name: "mcp-logs-compose", + tier: "docker", + summary: + "MCP logs cutoffs, backend_error, restart and UTF-8 cut on real Compose (F13)", + run: async (ctx) => { + const sessions: Session[] = []; + const { stack, clean } = await prepareStack({ ctx, sessions }); + try { + await stack.cli({ args: ["up", "--detach"], codes: [0] }); + const session = await stack.openSession({ label: "A" }); + await waitForOutput(session); + await checkBoundedOutcomes(session); + await checkByteCutoff({ session, log: ctx.log }); + await checkBackendError(stack); + const lastSeen = await checkRestart({ stack, session }); + await session.close(); + const reconnected = await stack.openSession({ label: "B" }); + await checkReconnect({ session: reconnected, lastSeen }); + await checkRunExitStatus({ stack, session: reconnected }); + await reconnected.close(); + + await stack.cli({ args: ["down"], codes: [0] }); + expect({ + that: (await stack.list("container", [])).length === 0, + message: "hack down must remove all fixture containers", + }); + } finally { + for (const session of sessions) { + try { + await session.close(); + } catch { + // Already closed. + } + } + await clean(); + } + }, +}; diff --git a/tests/e2e/shard.ts b/tests/e2e/shard.ts index 907b364b6..2d6434b99 100644 --- a/tests/e2e/shard.ts +++ b/tests/e2e/shard.ts @@ -54,6 +54,9 @@ export const E2E_DURATION_HINTS_SECONDS: Readonly> = { "portable-multiservice": 30, // Estimate (no measured run yet): two cached-alpine ups plus four run/exec probes. "compose-config-hash-drift": 25, + // Estimate (no measured run yet): one up, ~15 s of bounded tails, a 12 MiB + // log snapshot, one container restart and two one-off runs. + "mcp-logs-compose": 60, "dependency-cache-package": 30, "native-compose-adoption-worktrees": 28, "native-compose-adoption-string-worktrees": 28, diff --git a/tests/run-child-exit-status.test.ts b/tests/run-child-exit-status.test.ts new file mode 100644 index 000000000..7c76bf490 --- /dev/null +++ b/tests/run-child-exit-status.test.ts @@ -0,0 +1,298 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcpServer } from "../src/mcp/server.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +// --------------------------------------------------------------------------- +// HACK-1155 (P11): `hack run -- ` reports the child's own +// exit status, from the CLI and from the MCP `hack.project.run` envelope, and +// passes the child argv after `--` through unchanged. A pipe filter can hide +// that status only from the pipeline, never from hack's own exit code. +// +// The source CLI runs with a private HOME and HACK_HOME against a recording +// `docker` stub on PATH. The stub plays `docker compose run`: it runs the argv +// after the service name on the host and exits with its status. No Docker +// engine, client config or real home directory is touched. +// --------------------------------------------------------------------------- + +const cli = resolve(import.meta.dir, "../index.ts"); +/** Each test runs the source CLI several times; the default 5 s is too short. */ +const TEST_TIMEOUT_MS = 90_000; +const roots: string[] = []; +const clients: Client[] = []; + +afterEach(async () => { + for (const client of clients.splice(0)) { + await client.close(); + } + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +type RunResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "hack-run-exit-")); + roots.push(root); + const project = join(root, "project"); + const bin = join(root, "bin"); + const home = join(root, "home"); + for (const path of [join(project, ".hack"), bin, home]) { + await mkdir(path, { recursive: true }); + } + await writeFile( + join(project, ".hack", "docker-compose.yml"), + "name: run-exit\nservices:\n app:\n image: alpine:3.20\n" + ); + await writeFile( + join(project, ".hack", "hack.config.json"), + '{"name":"run-exit","dev_host":"run-exit.hack"}\n' + ); + const log = join(root, "docker-log.jsonl"); + await writeFile(log, ""); + const docker = join(bin, "docker"); + await writeFile( + docker, + `#!${process.execPath} +import { appendFileSync } from "node:fs"; +import { YAML } from "bun"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + "\\n"); +const run = args.indexOf("run"); +if (args[0] === "compose" && run >= 0) { + const service = args.indexOf("app", run + 1); + const child = Bun.spawnSync(args.slice(service + 1), { + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + process.exit(child.exitCode ?? 1); +} +if (args.includes("config")) { + const merged = { services: {} }; + for (let i = 0; i < args.length; i++) { + if (args[i] === "-f") { + const doc = YAML.parse(await Bun.file(args[++i]).text()); + Object.assign(merged.services, doc.services ?? {}); + } + } + console.log(JSON.stringify(merged)); +} +` + ); + await chmod(docker, 0o755); + const env = privateCliEnv({ + root, + home, + env: { + HACK_HOME: join(home, ".hack"), + PATH: `${bin}:${process.env.PATH ?? ""}`, + HACK_NO_INTERACTIVE: "1", + }, + }); + const collect = async (child: ReturnType) => { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout as ReadableStream).text(), + new Response(child.stderr as ReadableStream).text(), + child.exited, + ]); + return { exitCode, stdout, stderr } satisfies RunResult; + }; + /** `hack run --path app -- ` from the source CLI. */ + const run = async (cmd: readonly string[]) => + await collect( + Bun.spawn( + [process.execPath, cli, "run", "--path", project, "app", "--", ...cmd], + { + cwd: project, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + } + ) + ); + /** The same command piped through `cat`, with and without pipefail. */ + const piped = async (opts: { + readonly cmd: readonly string[]; + readonly pipefail: boolean; + }) => + await collect( + Bun.spawn( + [ + "bash", + "-c", + `set ${opts.pipefail ? "-o" : "+o"} pipefail; "$@" | cat; echo "pipeline=$? hack=\${PIPESTATUS[0]}"`, + "hack-pipe", + process.execPath, + cli, + "run", + "--path", + project, + "app", + "--", + ...opts.cmd, + ], + { + cwd: project, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + } + ) + ); + /** The argv each `docker compose run` received after the service name. */ + const runArgv = async () => + (await readFile(log, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]) + .filter((args) => args[0] === "compose" && args.includes("run")) + .map((args) => args.slice(args.indexOf("app", args.indexOf("run")) + 1)); + /** An in-process MCP session whose tools run this source CLI. */ + const mcp = async () => { + const wrapper = join(root, "hack-under-test"); + await writeFile( + wrapper, + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} "$@"\n` + ); + await chmod(wrapper, 0o755); + const server = createMcpServer({ + cwd: project, + env: { ...env, HACK_MCP_COMMAND: wrapper }, + }); + const client = new Client({ name: "hack-run-exit", version: "1" }); + clients.push(client); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; + }; + return { run, piped, runArgv, mcp }; +} + +function envelope(result: unknown): { + readonly isError: boolean; + readonly ok: unknown; + readonly exitCode: unknown; + readonly stdout: unknown; +} { + const record = result as { + readonly isError?: boolean; + readonly structuredContent?: Record; + }; + return { + isError: record.isError === true, + ok: record.structuredContent?.ok, + exitCode: record.structuredContent?.exitCode, + stdout: record.structuredContent?.stdout, + }; +} + +test( + "hack run exits with the child's status and keeps its argv after --", + async () => { + const f = await fixture(); + const failed = await f.run(["false"]); + expect({ exitCode: failed.exitCode, stdout: failed.stdout }).toEqual({ + exitCode: 1, + stdout: "", + }); + const seven = await f.run(["sh", "-c", "printf partial; exit 7"]); + expect({ exitCode: seven.exitCode, stdout: seven.stdout }).toEqual({ + exitCode: 7, + stdout: "partial", + }); + // Positive control: a successful child is a successful run. + const passed = await f.run(["true"]); + expect(passed.exitCode).toBe(0); + // Hack-looking words after `--` reach the child, not hack's parser. + const flags = await f.run(["sh", "-c", 'exit "$1"', "sh", "--json"]); + expect(flags.exitCode).not.toBe(0); + expect(await f.runArgv()).toEqual([ + ["false"], + ["sh", "-c", "printf partial; exit 7"], + ["true"], + ["sh", "-c", 'exit "$1"', "sh", "--json"], + ]); + }, + TEST_TIMEOUT_MS +); + +test( + "a pipe filter hides a failed child only from the pipeline, never from hack", + async () => { + const f = await fixture(); + // Negative control: without pipefail the filtered pipeline reports success. + const filtered = await f.piped({ cmd: ["false"], pipefail: false }); + expect(filtered.stdout.trim()).toBe("pipeline=0 hack=1"); + const failed = await f.piped({ cmd: ["false"], pipefail: true }); + expect(failed.stdout.trim()).toBe("pipeline=1 hack=1"); + const seven = await f.piped({ + cmd: ["sh", "-c", "printf partial; exit 7"], + pipefail: false, + }); + expect(seven.stdout.trim()).toBe("partialpipeline=0 hack=7"); + }, + TEST_TIMEOUT_MS +); + +test( + "the MCP hack.project.run envelope reports the child's exit status", + async () => { + const f = await fixture(); + const client = await f.mcp(); + const call = async (cmd: readonly string[]) => + envelope( + await client.callTool({ + name: "hack.project.run", + arguments: { service: "app", cmd: [...cmd] }, + }) + ); + expect(await call(["false"])).toEqual({ + isError: true, + ok: false, + exitCode: 1, + stdout: "", + }); + expect(await call(["sh", "-c", "printf partial; exit 7"])).toEqual({ + isError: true, + ok: false, + exitCode: 7, + stdout: "partial", + }); + // Positive control: a successful child is an ok envelope. + expect(await call(["true"])).toEqual({ + isError: false, + ok: true, + exitCode: 0, + stdout: "", + }); + expect((await f.runArgv()).slice(-3)).toEqual([ + ["false"], + ["sh", "-c", "printf partial; exit 7"], + ["true"], + ]); + }, + TEST_TIMEOUT_MS +); From 4babce5b8fa65cc3a881b9b9e01cdd28e5313fad Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 11:51:05 -0400 Subject: [PATCH 02/11] test(e2e): add headless bootstrap negative controls Extend the portable bootstrap fixture (#146/#147) with a hosted docker-e2e scenario, `portable-bootstrap-negatives`, for HACK-1143. On the slim path it drives `hack up --detach app` through eleven ordered stages against a locked-v1 dependency cache with a frozen installer and an app-scoped encrypted synthetic token: - missing key, wrong key and an overlay sealed under another key refuse before any owned container, network or volume exists; - two identical bootstraps keep the same consumer, cache and index, initialize once and leave no worktree diff; - a wrong key, a stale pinned image, a lockfile that no longer matches its manifest and a corrupted ready marker each refuse without touching the running consumer, pulling, rewriting the lockfile or reinstalling; - a changed lockfile and a new cache generation select new caches and keep the previous ones. Each stage records a value-free receipt (duration, output bytes, tracked bytes, mounted-cache bytes, cache-volume count, consumer transition). Pure stage expectations and receipt validation live in tests/e2e/portable-bootstrap-controls.ts with unit tests. A local test runs the source CLI on the same fixture against a recording Docker stub to show the env refusals precede any Compose mutation. WU12 storage admission, retention and archives remain out of scope. Co-Authored-By: Claude Opus 5.5 --- .../portable-bootstrap-qualification.md | 12 + scripts/portable-bootstrap-smoke.ts | 5 +- tests/e2e-portable-bootstrap-controls.test.ts | 221 ++++++ tests/e2e/README.md | 58 ++ tests/e2e/portable-bootstrap-controls.ts | 385 +++++++++ tests/e2e/run.ts | 2 + .../scenarios/portable-bootstrap-negatives.ts | 742 ++++++++++++++++++ tests/e2e/shard.ts | 2 + tests/portable-bootstrap-env-refusal.test.ts | 211 +++++ 9 files changed, 1637 insertions(+), 1 deletion(-) create mode 100644 tests/e2e-portable-bootstrap-controls.test.ts create mode 100644 tests/e2e/portable-bootstrap-controls.ts create mode 100644 tests/e2e/scenarios/portable-bootstrap-negatives.ts create mode 100644 tests/portable-bootstrap-env-refusal.test.ts diff --git a/docs/guides/portable-bootstrap-qualification.md b/docs/guides/portable-bootstrap-qualification.md index 709490027..218c32162 100644 --- a/docs/guides/portable-bootstrap-qualification.md +++ b/docs/guides/portable-bootstrap-qualification.md @@ -56,6 +56,18 @@ available container capabilities, and any skips. Review the tested Install script and Start skill before publishing the private environment. A local or ordinary Linux-container result does not qualify Codex Cloud by itself. +## Negative controls in Docker CI + +The hosted `docker-e2e` job also runs the `portable-bootstrap-negatives` scenario. +On the slim path, it checks that a missing key, a wrong key, an overlay sealed +under another key, a stale pinned image, a lockfile that no longer matches its +manifest and a corrupted dependency cache each stop the bootstrap without +changing a running app. It also checks that a second identical bootstrap leaves +no diff and does not reinstall. Each stage records its time and byte counts in a +value-free receipt. See +[the e2e harness guide](../../tests/e2e/README.md#portable-bootstrap-negative-controls) +for the stages and what they do not cover. + ## Repeat maintenance `scripts/maintain-codex-slim.sh` installs dependencies with diff --git a/scripts/portable-bootstrap-smoke.ts b/scripts/portable-bootstrap-smoke.ts index 204b102ec..7ba1ac508 100644 --- a/scripts/portable-bootstrap-smoke.ts +++ b/scripts/portable-bootstrap-smoke.ts @@ -204,7 +204,10 @@ function isRecord(value: unknown): value is Record { * v1:base64(iv):base64(tag):base64(ciphertext), AES-256-GCM, SHA-256(key text). * The synthetic key and plaintext never become fixture files or CLI arguments. */ -function encryptSynthetic(opts: { key: string; secret: string }): string { +export function encryptSynthetic(opts: { + key: string; + secret: string; +}): string { const iv = randomBytes(12); const cipher = createCipheriv( "aes-256-gcm", diff --git a/tests/e2e-portable-bootstrap-controls.test.ts b/tests/e2e-portable-bootstrap-controls.test.ts new file mode 100644 index 000000000..bda7f0687 --- /dev/null +++ b/tests/e2e-portable-bootstrap-controls.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; +import { createDecipheriv, createHash } from "node:crypto"; +import { encryptSynthetic } from "../scripts/portable-bootstrap-smoke.ts"; +import { + type AppIdentity, + checkStageExpectation, + classifyAppTransition, + containsForbiddenValue, + PORTABLE_BOOTSTRAP_EXPECTATIONS, + PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + PORTABLE_BOOTSTRAP_STAGES, + renderPortableBootstrapReceipt, + type StageReceipt, + unexpectedWorktreeChanges, +} from "./e2e/portable-bootstrap-controls.ts"; +import { ALL_SCENARIOS } from "./e2e/run.ts"; +import { E2E_DURATION_HINTS_SECONDS } from "./e2e/shard.ts"; + +const running = (id: string, startedAt = "t1"): AppIdentity => ({ + id, + startedAt, + running: true, +}); + +function receipt( + stage: (typeof PORTABLE_BOOTSTRAP_STAGES)[number] +): StageReceipt { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[stage]; + return { + stage, + outcome: expected.outcome, + app: expected.app, + durationMs: 1200, + stdoutBytes: 10, + stderrBytes: 20, + trackedBytes: 4096, + cacheBytes: expected.app === "absent" ? null : 128, + cacheVolumes: 1, + }; +} + +const complete = () => PORTABLE_BOOTSTRAP_STAGES.map(receipt); + +describe("portable bootstrap negative controls", () => { + test("is registered for the hosted Docker tier with a shard hint", () => { + const scenario = ALL_SCENARIOS.find( + (entry) => entry.name === "portable-bootstrap-negatives" + ); + expect(scenario?.tier).toBe("docker"); + expect(scenario?.requiresExplicitSelection).toBeUndefined(); + expect( + Object.hasOwn(E2E_DURATION_HINTS_SECONDS, "portable-bootstrap-negatives") + ).toBe(true); + }); + + test("every refusal leaves the consumer absent or unchanged", () => { + for (const stage of PORTABLE_BOOTSTRAP_STAGES) { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[stage]; + expect(stage.startsWith("refuse-")).toBe(expected.outcome === "refused"); + if (expected.outcome === "refused") { + expect(["absent", "unchanged"]).toContain(expected.app); + } + } + expect(PORTABLE_BOOTSTRAP_EXPECTATIONS["bootstrap-repeat"].app).toBe( + "unchanged" + ); + }); + + test("classifies consumer identity by container ID and start time", () => { + expect(classifyAppTransition({ before: null, after: null })).toBe("absent"); + expect(classifyAppTransition({ before: null, after: running("a") })).toBe( + "created" + ); + expect( + classifyAppTransition({ before: running("a"), after: running("a") }) + ).toBe("unchanged"); + expect( + classifyAppTransition({ before: running("a"), after: running("b") }) + ).toBe("replaced"); + // A restart in place is not "unchanged": the refusal touched the consumer. + expect( + classifyAppTransition({ + before: running("a"), + after: running("a", "t2"), + }) + ).toBe("replaced"); + expect(classifyAppTransition({ before: running("a"), after: null })).toBe( + "removed" + ); + }); + + test("checks exit, consumer transition and liveness per stage", () => { + const ok = { + stage: "refuse-stale-image" as const, + exitCode: 1, + timedOut: false, + transition: "unchanged" as const, + running: true, + }; + expect(checkStageExpectation(ok)).toBeNull(); + expect(checkStageExpectation({ ...ok, exitCode: 0 })).toBe( + "expected refused, observed bootstrapped" + ); + expect(checkStageExpectation({ ...ok, transition: "replaced" })).toBe( + "expected consumer unchanged, observed replaced" + ); + expect(checkStageExpectation({ ...ok, running: false })).toBe( + "consumer must be running after the stage" + ); + expect(checkStageExpectation({ ...ok, timedOut: true })).toBe( + "command timed out" + ); + expect( + checkStageExpectation({ + stage: "refuse-missing-key", + exitCode: 1, + timedOut: false, + transition: "absent", + running: false, + }) + ).toBeNull(); + expect( + checkStageExpectation({ + stage: "bootstrap-repeat", + exitCode: 0, + timedOut: false, + transition: "replaced", + running: true, + }) + ).toBe("expected consumer unchanged, observed replaced"); + }); + + test("reports worktree paths outside the allowed set", () => { + const porcelain = [ + "?? .hack/.gitignore", + " M package.json", + "R old.txt -> new.txt", + "", + ].join("\n"); + expect(unexpectedWorktreeChanges({ porcelain: "", allowed: [] })).toEqual( + [] + ); + expect( + unexpectedWorktreeChanges({ + porcelain, + allowed: [".hack/.gitignore", "package.json", "new.txt"], + }) + ).toEqual([]); + expect( + unexpectedWorktreeChanges({ + porcelain, + allowed: [".hack/.gitignore"], + }) + ).toEqual(["new.txt", "package.json"]); + }); + + test("detects forbidden values and ignores empty entries", () => { + expect( + containsForbiddenValue({ texts: ["ok", "has-key-x"], forbidden: ["key"] }) + ).toBe(true); + expect( + containsForbiddenValue({ texts: ["ok"], forbidden: ["", "absent"] }) + ).toBe(false); + }); + + test("renders a complete value-free receipt", () => { + const parsed: unknown = JSON.parse( + renderPortableBootstrapReceipt({ stages: complete(), imageBytes: 7 }) + ); + expect(parsed).toEqual({ + schema: PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + imageBytes: 7, + stages: complete(), + }); + }); + + test("refuses incomplete, reordered or open-ended receipts", () => { + const render = + (stages: readonly unknown[], imageBytes: unknown = 1) => + () => + renderPortableBootstrapReceipt({ stages, imageBytes }); + const message = /Invalid portable bootstrap receipt/; + expect(render(complete().slice(1))).toThrow(message); + expect(render(complete().reverse())).toThrow(message); + expect(render(complete(), -1)).toThrow(message); + expect(render(complete(), 1.5)).toThrow(message); + const mutate = (patch: Record) => + complete().map((entry, index) => + index === 4 ? { ...entry, ...patch } : entry + ); + expect(render(mutate({ outcome: "skipped" }))).toThrow(message); + expect(render(mutate({ app: "restarted" }))).toThrow(message); + expect(render(mutate({ durationMs: -1 }))).toThrow(message); + expect(render(mutate({ stdoutBytes: 0.5 }))).toThrow(message); + expect(render(mutate({ cacheBytes: "128" }))).toThrow(message); + expect(render(mutate({ detail: "free text" }))).toThrow(message); + const missing = complete().map(({ cacheVolumes: _, ...entry }) => entry); + expect(render(missing)).toThrow(message); + expect(render([...complete().slice(0, -1), null])).toThrow(message); + }); + + test("synthetic env values use the project v1 envelope", () => { + const key = "synthetic-key"; + const sealed = encryptSynthetic({ key, secret: "synthetic-value" }); + const [prefix, iv, tag, ciphertext] = sealed.split(":"); + expect(prefix).toBe("v1"); + const decipher = createDecipheriv( + "aes-256-gcm", + createHash("sha256").update(key).digest(), + Buffer.from(iv ?? "", "base64") + ); + decipher.setAuthTag(Buffer.from(tag ?? "", "base64")); + expect( + Buffer.concat([ + decipher.update(Buffer.from(ciphertext ?? "", "base64")), + decipher.final(), + ]).toString("utf8") + ).toBe("synthetic-value"); + expect(sealed).not.toContain("synthetic-value"); + }); +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 6ae45edea..332dec79a 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -205,6 +205,64 @@ data survive that cleanup. The canary is then removed separately. PostgreSQL use fixture-only trust on this unexposed network. This qualifies the actual execution host, not Codex Cloud capabilities, browser access or native VM support elsewhere. +## Portable bootstrap negative controls + +`portable-bootstrap-negatives` (HACK-1143) adds negative controls to the portable +bootstrap fixture. It runs the selected CLI in slim mode with cached `alpine:3.20` +pinned by immutable ID, `network_mode: none`, no published ports and no pulls. +A `deps` producer fills a `locked-v1` dependency cache. Its install is frozen: it +fails when the lockfile's manifest digest no longer matches `package.json`, and the +workspace is mounted read-only. The `app` consumer mounts the cache read-only. It +copies the cache artifact only after a synthetic encrypted token, scoped to `app`, +matches the expected digest, so a loaded artifact proves env injection without the +scenario reading the value. + +Every stage runs `hack up --detach app` and must give this result: + +| Stage | Change before the stage | Required result | +| --- | --- | --- | +| `refuse-missing-key` | no key | refused; no owned container, network or volume; only the managed `.hack/.gitignore` may appear | +| `refuse-wrong-key` | wrong key | refused; nothing created; no worktree diff | +| `refuse-wrong-overlay` | `--env drift`, an overlay sealed under another key | refused; nothing created; no worktree diff | +| `bootstrap-cold` | correct key | consumer created; one initialization; one ready cache; no diff | +| `bootstrap-repeat` | none | same consumer ID and start time; same cache; still one initialization; same index; no diff | +| `refuse-wrong-key-running` | wrong key | refused; consumer unchanged | +| `refuse-stale-image` | both services pinned to an absent image ID | refused; consumer unchanged; the stale ID is not pulled and the cached tag keeps its ID | +| `refuse-frozen-lockfile` | `package.json` no longer matches the lockfile | refused; consumer unchanged; lockfile bytes kept; the new cache has an attempt but no ready marker | +| `bootstrap-changed-lockfile` | committed lockfile revision | consumer replaced on a new cache initialized once; the previous ready cache is kept | +| `refuse-corrupted-cache` | the current ready marker is overwritten | refused; consumer unchanged; no reinstall; the corrupted marker is not rewritten | +| `bootstrap-new-generation` | `hack.dependencies.cache-generation` bumped | consumer replaced on a new cache; the corrupted cache is kept for inspection | + +Unchanged means the same full container ID and `State.StartedAt`. CLI output and +Git-tracked files must never contain the keys or token values, and the scenario +reads only fixed container fields because the consumer's environment holds the +token. Cleanup checks exact owner and Compose project labels, and an adjacent +canary volume must survive it. + +The scenario ends with one value-free line, `portable-bootstrap receipt {...}`. For +each stage it records the outcome, the consumer transition, the command duration, +stdout/stderr bytes, Git-tracked fixture bytes, the bytes in the consumer's mounted +cache and the number of owned cache volumes. It also records the pinned image +size. It contains no env values, keys, resource IDs, image IDs or paths; +`tests/e2e/portable-bootstrap-controls.ts` refuses any other shape. + +```sh +HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 bun tests/e2e/run.ts --only=portable-bootstrap-negatives +``` + +Two local tests need no Docker engine. `tests/portable-bootstrap-env-refusal.test.ts` +runs the source CLI on the same fixture against a recording Docker stub: a missing +key, a wrong key and a wrong overlay key must fail before any Compose `create`, +`run`, `start` or `up`, and the correct key must reach the `deps` bootstrap first. +`tests/e2e-portable-bootstrap-controls.test.ts` covers the stage expectations, +consumer classification, worktree diff check and receipt validation. + +This does not cover WU12 storage: mount and free-space admission, generation holds, +reclamation and archives. Old cache volumes are kept, never reclaimed. A stale +image here is a pinned ID that is absent locally, not a baked-tool manifest +mismatch. A pass qualifies the hosted Linux Docker runner, not native VM support or +a cloud provider. + ## Compose config-hash drift (F03) `compose-config-hash-drift` (HACK-1124) checks that `hack run` and `hack exec` diff --git a/tests/e2e/portable-bootstrap-controls.ts b/tests/e2e/portable-bootstrap-controls.ts new file mode 100644 index 000000000..da949d027 --- /dev/null +++ b/tests/e2e/portable-bootstrap-controls.ts @@ -0,0 +1,385 @@ +/** + * Pure decisions and value-free receipts for the `portable-bootstrap-negatives` + * Docker scenario. Everything here is free of I/O so the stage expectations, + * app-identity classification, worktree-diff checks and receipt shape can be + * exercised (and mutation-tested) without a Docker engine. + * + * Receipts carry only closed stage/outcome/transition names and non-negative + * integers. They never include env values, keys, secrets, command output, + * resource IDs, image IDs or file paths. + */ + +/** Canonical stage order; a receipt must contain each stage exactly once. */ +export const PORTABLE_BOOTSTRAP_STAGES = [ + "refuse-missing-key", + "refuse-wrong-key", + "refuse-wrong-overlay", + "bootstrap-cold", + "bootstrap-repeat", + "refuse-wrong-key-running", + "refuse-stale-image", + "refuse-frozen-lockfile", + "bootstrap-changed-lockfile", + "refuse-corrupted-cache", + "bootstrap-new-generation", +] as const; + +export type PortableBootstrapStage = (typeof PORTABLE_BOOTSTRAP_STAGES)[number]; + +export type StageOutcome = "refused" | "bootstrapped"; + +/** How the single owned consumer container changed across one stage. */ +export type AppTransition = + | "absent" + | "created" + | "unchanged" + | "replaced" + | "removed"; + +export type AppIdentity = { + readonly id: string; + readonly startedAt: string; + readonly running: boolean; +} | null; + +type StageExpectation = { + readonly outcome: StageOutcome; + readonly app: Exclude; +}; + +/** + * Every refusal must leave the consumer exactly as it was: absent before the + * first successful bootstrap, otherwise the same running container. A repeat + * bootstrap must not recreate it; only a changed cache identity may replace it. + */ +export const PORTABLE_BOOTSTRAP_EXPECTATIONS: Readonly< + Record +> = { + "refuse-missing-key": { outcome: "refused", app: "absent" }, + "refuse-wrong-key": { outcome: "refused", app: "absent" }, + "refuse-wrong-overlay": { outcome: "refused", app: "absent" }, + "bootstrap-cold": { outcome: "bootstrapped", app: "created" }, + "bootstrap-repeat": { outcome: "bootstrapped", app: "unchanged" }, + "refuse-wrong-key-running": { outcome: "refused", app: "unchanged" }, + "refuse-stale-image": { outcome: "refused", app: "unchanged" }, + "refuse-frozen-lockfile": { outcome: "refused", app: "unchanged" }, + "bootstrap-changed-lockfile": { outcome: "bootstrapped", app: "replaced" }, + "refuse-corrupted-cache": { outcome: "refused", app: "unchanged" }, + "bootstrap-new-generation": { outcome: "bootstrapped", app: "replaced" }, +}; + +/** Same container ID and start time means the consumer was not touched. */ +export function classifyAppTransition(opts: { + readonly before: AppIdentity; + readonly after: AppIdentity; +}): AppTransition { + const { before, after } = opts; + if (before === null) { + return after === null ? "absent" : "created"; + } + if (after === null) { + return "removed"; + } + return before.id === after.id && before.startedAt === after.startedAt + ? "unchanged" + : "replaced"; +} + +/** + * Compare one observed stage with its fixed expectation. Returns a fixed, + * value-free failure reason, or null when the stage behaved as required. + */ +export function checkStageExpectation(opts: { + readonly stage: PortableBootstrapStage; + readonly exitCode: number; + readonly timedOut: boolean; + readonly transition: AppTransition; + readonly running: boolean; +}): string | null { + const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[opts.stage]; + if (opts.timedOut) { + return "command timed out"; + } + const outcome: StageOutcome = + opts.exitCode === 0 ? "bootstrapped" : "refused"; + if (outcome !== expected.outcome) { + return `expected ${expected.outcome}, observed ${outcome}`; + } + if (opts.transition !== expected.app) { + return `expected consumer ${expected.app}, observed ${opts.transition}`; + } + if (expected.app !== "absent" && !opts.running) { + return "consumer must be running after the stage"; + } + return null; +} + +/** + * Paths from `git status --porcelain=v1 --untracked-files=all` that are not in + * `allowed`. Renames report their destination. An empty result means the + * worktree differs from HEAD only by the allowed paths. + */ +export function unexpectedWorktreeChanges(opts: { + readonly porcelain: string; + readonly allowed: readonly string[]; +}): readonly string[] { + const unexpected: string[] = []; + for (const line of opts.porcelain.split("\n")) { + if (line.trim().length === 0) { + continue; + } + const entry = line.slice(3); + const arrow = entry.lastIndexOf(" -> "); + const path = arrow >= 0 ? entry.slice(arrow + 4) : entry; + if (!opts.allowed.includes(path)) { + unexpected.push(path); + } + } + return unexpected.sort(); +} + +/** True when any non-empty forbidden value occurs in any text. */ +export function containsForbiddenValue(opts: { + readonly texts: readonly string[]; + readonly forbidden: readonly string[]; +}): boolean { + return opts.forbidden.some( + (value) => + value.length > 0 && opts.texts.some((text) => text.includes(value)) + ); +} + +export type StageReceipt = { + readonly stage: PortableBootstrapStage; + readonly outcome: StageOutcome; + readonly app: AppTransition; + readonly durationMs: number; + readonly stdoutBytes: number; + readonly stderrBytes: number; + /** Bytes of Git-tracked fixture files after the stage. */ + readonly trackedBytes: number; + /** Content bytes in the consumer's mounted cache; null without a consumer. */ + readonly cacheBytes: number | null; + /** Owned dependency-cache volumes after the stage. */ + readonly cacheVolumes: number; +}; + +export const PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA = + "hack.e2e.portable-bootstrap-negatives.v1"; + +const RECEIPT_KEYS = [ + "stage", + "outcome", + "app", + "durationMs", + "stdoutBytes", + "stderrBytes", + "trackedBytes", + "cacheBytes", + "cacheVolumes", +] as const; + +const OUTCOMES: readonly string[] = ["refused", "bootstrapped"]; +const TRANSITIONS: readonly string[] = [ + "absent", + "created", + "unchanged", + "replaced", + "removed", +]; + +function count(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function invalidReceipt(): Error { + return new Error( + "Invalid portable bootstrap receipt: require each canonical stage once, closed names and non-negative integer measurements" + ); +} + +/** + * Validate a complete receipt and render it as one JSON line. Unknown keys, + * missing or reordered stages, open-ended strings and non-integer + * measurements are refused with one fixed error, so a receipt can carry only + * value-free data. + */ +export function renderPortableBootstrapReceipt(opts: { + readonly stages: readonly unknown[]; + readonly imageBytes: unknown; +}): string { + if ( + !count(opts.imageBytes) || + opts.stages.length !== PORTABLE_BOOTSTRAP_STAGES.length + ) { + throw invalidReceipt(); + } + const stages: StageReceipt[] = []; + for (const [index, value] of opts.stages.entries()) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw invalidReceipt(); + } + const keys = Object.keys(value); + if ( + keys.length !== RECEIPT_KEYS.length || + !RECEIPT_KEYS.every((key) => Object.hasOwn(value, key)) + ) { + throw invalidReceipt(); + } + const record = value as Record<(typeof RECEIPT_KEYS)[number], unknown>; + const stage = PORTABLE_BOOTSTRAP_STAGES[index]; + if ( + stage === undefined || + record.stage !== stage || + typeof record.outcome !== "string" || + !OUTCOMES.includes(record.outcome) || + typeof record.app !== "string" || + !TRANSITIONS.includes(record.app) || + !count(record.durationMs) || + !count(record.stdoutBytes) || + !count(record.stderrBytes) || + !count(record.trackedBytes) || + !(record.cacheBytes === null || count(record.cacheBytes)) || + !count(record.cacheVolumes) + ) { + throw invalidReceipt(); + } + stages.push({ + stage, + outcome: record.outcome as StageOutcome, + app: record.app as AppTransition, + durationMs: record.durationMs, + stdoutBytes: record.stdoutBytes, + stderrBytes: record.stderrBytes, + trackedBytes: record.trackedBytes, + cacheBytes: record.cacheBytes, + cacheVolumes: record.cacheVolumes, + }); + } + return JSON.stringify({ + schema: PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA, + imageBytes: opts.imageBytes, + stages, + }); +} + +/** Owner label on every fixture container, volume and probe. */ +export const PORTABLE_BOOTSTRAP_OWNER = "hack.e2e.portable-bootstrap-owner"; + +/** + * Frozen install: refuse a lockfile whose manifest digest no longer matches + * `package.json`. The workspace is mounted read-only, so the installer cannot + * repair the lockfile by rewriting it. + */ +export const PORTABLE_BOOTSTRAP_INSTALL_SCRIPT = [ + "set -eu", + "manifest=$(sha256sum /workspace/package.json | cut -d ' ' -f1)", + 'grep -qx "manifest=$manifest" /workspace/bun.lock || { echo "frozen lockfile mismatch" >&2; exit 23; }', + "echo initialized >> /deps/initializations", + "sha256sum /workspace/bun.lock | cut -d ' ' -f1 > /deps/artifact", + "", +].join("\n"); + +export const PORTABLE_BOOTSTRAP_VERIFY_SCRIPT = [ + "set -eu", + 'test "$(cat /deps/artifact)" = "$(sha256sum /workspace/bun.lock | cut -d \' \' -f1)"', + 'test "$(wc -l < /deps/initializations)" -eq 1', + "", +].join("\n"); + +export function sha256Hex(data: string): string { + return new Bun.CryptoHasher("sha256").update(data).digest("hex"); +} + +/** Lockfile bound to one manifest digest; a new revision changes the cache key. */ +export function renderBootstrapLock(opts: { + readonly manifest: string; + readonly revision: number; +}): string { + return `manifest=${sha256Hex(opts.manifest)}\nrevision=${opts.revision}\n`; +} + +/** Project env file whose only value is an app-scoped sealed token. */ +export function renderBootstrapEnvFile(opts: { + readonly environment: string; + readonly sealed: string; +}): string { + return [ + "version: 1", + `environment: ${opts.environment}`, + "secretsprovider: project_key", + "values:", + " app:", + " BOOTSTRAP_TOKEN:", + ` secure: ${opts.sealed}`, + "", + ].join("\n"); +} + +/** + * A locked-v1 producer (`deps`) and a read-only consumer (`app`) on one pinned + * image, with no network, ports or pulls. The consumer exits 41 unless the + * injected token matches `tokenDigest`, and copies the cache artifact only + * after that check, so a loaded artifact proves env injection succeeded + * without the scenario ever reading the value. + */ +export function renderBootstrapCompose(opts: { + readonly name: string; + readonly ref: string; + readonly platform: string; + readonly generation: string; + readonly tokenDigest: string; +}): string { + const base = { + image: opts.ref, + platform: opts.platform, + pull_policy: "never", + network_mode: "none", + }; + const app = [ + "set -eu", + `test "$(printf %s "\${BOOTSTRAP_TOKEN:-}" | sha256sum | cut -d ' ' -f1)" = ${opts.tokenDigest} || exit 41`, + "cp /deps/artifact /tmp/loaded", + "exec sleep 3600", + ].join("\n"); + const labels = { [PORTABLE_BOOTSTRAP_OWNER]: opts.name }; + return `${JSON.stringify( + { + name: opts.name, + services: { + deps: { + ...base, + entrypoint: [], + command: ["sh", "/workspace/deps/install.sh"], + labels: { + ...labels, + "hack.dependencies.cache-volume": "deps", + "hack.dependencies.lockfiles": "bun.lock", + "hack.dependencies.runtime-files": + "package.json,deps/install.sh,deps/verify.sh", + "hack.dependencies.cache-protocol": "locked-v1", + "hack.dependencies.cache-generation": opts.generation, + "hack.dependencies.cache-verify": JSON.stringify([ + "sh", + "/workspace/deps/verify.sh", + ]), + }, + volumes: ["deps:/deps", "..:/workspace:ro"], + }, + app: { + ...base, + labels, + // Compose must pass shell expansions to the container unchanged. + command: ["sh", "-c", app.replaceAll("$", () => "$$")], + depends_on: { + deps: { condition: "service_completed_successfully" }, + }, + volumes: ["deps:/deps:ro"], + }, + }, + volumes: { deps: { labels } }, + }, + null, + 2 + )}\n`; +} diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 7fdfb7ff4..0b207d71e 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -48,6 +48,7 @@ import { nativeConfigProcessPolicyScenario } from "./scenarios/native-config-pro import { nativeConfigProtectedFilesScenario } from "./scenarios/native-config-protected-files.ts"; import { nativeConfigRoutingScenario } from "./scenarios/native-config-routing.ts"; import { nativeConfigVmFilesScenario } from "./scenarios/native-config-vm-files.ts"; +import { portableBootstrapNegativesScenario } from "./scenarios/portable-bootstrap-negatives.ts"; import { portableMultiserviceScenario } from "./scenarios/portable-multiservice.ts"; import { upDownScenario } from "./scenarios/up-down.ts"; import { worktreeBranchDefaultScenario } from "./scenarios/worktree-branch-default.ts"; @@ -97,6 +98,7 @@ export const ALL_SCENARIOS: readonly Scenario[] = [ upDownScenario, execStaleEnvScenario, portableMultiserviceScenario, + portableBootstrapNegativesScenario, composeConfigHashDriftScenario, nativeConfigComposeScenario, nativeConfigBuildScenario, diff --git a/tests/e2e/scenarios/portable-bootstrap-negatives.ts b/tests/e2e/scenarios/portable-bootstrap-negatives.ts new file mode 100644 index 000000000..35646de44 --- /dev/null +++ b/tests/e2e/scenarios/portable-bootstrap-negatives.ts @@ -0,0 +1,742 @@ +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { encryptSynthetic } from "../../../scripts/portable-bootstrap-smoke.ts"; +import { commitAll, createMonorepoFixture } from "../fixture.ts"; +import { + type CliResult, + expect, + expectExit, + runCommand, + type Scenario, +} from "../harness.ts"; +import { + type AppIdentity, + checkStageExpectation, + classifyAppTransition, + containsForbiddenValue, + PORTABLE_BOOTSTRAP_OWNER as OWNER, + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + type PortableBootstrapStage, + renderBootstrapCompose, + renderBootstrapEnvFile, + renderBootstrapLock, + renderPortableBootstrapReceipt, + type StageReceipt, + sha256Hex, + unexpectedWorktreeChanges, +} from "../portable-bootstrap-controls.ts"; + +const PROJECT = "com.docker.compose.project"; +const TIMEOUT = 180_000; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const META = "/deps/.hack-dependency-cache-v1"; +const OVERLAY = "drift"; +const ARCHITECTURES: Readonly> = { + aarch64: "arm64", + arm64: "arm64", + amd64: "amd64", + x86_64: "amd64", +}; +type ResourceKind = "container" | "network" | "volume"; + +function words(text: string): string[] { + return text.split(/\s+/).filter(Boolean); +} + +/** + * Headless bootstrap negative controls on the portable slim path (HACK-1143). + * + * One app consumes a locked-v1 dependency cache initialized by a frozen-install + * producer, with an encrypted synthetic env value scoped to the app. Missing, + * wrong and wrong-overlay keys refuse before any Docker resource exists. Two + * identical bootstraps produce one initialization, one consumer and no + * worktree diff. A wrong key, a stale pinned image, a lockfile that no longer + * matches its manifest and a corrupted ready marker each refuse without + * touching the running consumer, pulling, rewriting the lockfile or + * reinstalling. A changed lockfile and a new cache generation select new + * caches and keep the old ones. Each stage records value-free time and bytes. + * + * No published ports, networks, pulls or real credentials. WU12 storage + * admission, mounts and retention are outside this fixture. + */ +export const portableBootstrapNegativesScenario: Scenario = { + name: "portable-bootstrap-negatives", + tier: "docker", + summary: + "bootstrap refusals: key/overlay, stale image, lockfile, corrupted cache; no-diff repeat", + run: async (ctx) => { + const docker = async (args: readonly string[]) => { + const result = await runCommand({ + argv: ["docker", ...args], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + expectExit({ + result, + codes: [0], + message: `Docker ${args[0]} must succeed`, + }); + return result.stdout.trim(); + }; + expect({ + that: (await docker(["info", "--format", "{{.OSType}}"])) === "linux", + message: "A Linux Docker daemon is required", + }); + await docker(["compose", "version"]); + const arch = + ARCHITECTURES[await docker(["info", "--format", "{{.Architecture}}"])]; + expect({ + that: arch !== undefined, + message: "A supported Docker architecture is required", + }); + const image = await docker([ + "image", + "inspect", + "alpine:3.20", + "--format", + "{{.Id}}", + ]); + expect({ + that: IMAGE_ID.test(image), + message: "alpine:3.20 must already be cached with an immutable ID", + }); + const imageBytes = Number( + await docker(["image", "inspect", image, "--format", "{{.Size}}"]) + ); + // A pinned ID that is not present: what a toolchain bump leaves behind. + const staleImage = `sha256:${sha256Hex(`hack-e2e-stale-${randomBytes(16).toString("hex")}`)}`; + const imagePresent = async (ref: string) => + ( + await runCommand({ + argv: ["docker", "image", "inspect", ref, "--format", "{{.Id}}"], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }) + ).exitCode === 0; + expect({ + that: !(await imagePresent(staleImage)), + message: "The stale pinned image must be absent before the control", + }); + + const fixture = await createMonorepoFixture({ + parentDir: ctx.tempRoot, + withHackConfig: true, + lifecycle: { disableInternal: true }, + }); + const name = fixture.name; + const canary = `${name}-canary`; + const cachePrefix = `hack-cache-${name}-deps-`; + // Synthetic values only; never logged, written in plaintext or passed as arguments. + const key = randomBytes(32).toString("base64url"); + const wrongKey = randomBytes(32).toString("base64url"); + const driftKey = randomBytes(32).toString("base64url"); + const secret = randomBytes(24).toString("base64url"); + const driftSecret = randomBytes(24).toString("base64url"); + const forbidden = [key, wrongKey, driftKey, secret, driftSecret]; + const compose = (opts: { + readonly ref: string; + readonly generation: string; + }) => + renderBootstrapCompose({ + name, + ref: opts.ref, + platform: `linux/${arch}`, + generation: opts.generation, + tokenDigest: sha256Hex(secret), + }); + const composePath = join(fixture.hackDir, "docker-compose.yml"); + const lockPath = join(fixture.root, "bun.lock"); + const manifestPath = join(fixture.root, "package.json"); + const manifest = await Bun.file(manifestPath).text(); + const lock = (revision: number) => + renderBootstrapLock({ manifest, revision }); + await Bun.write( + join(fixture.hackDir, "hack.config.json"), + `${JSON.stringify( + { + name, + internal: { dns: false, tls: false }, + logs: { + follow_backend: "compose", + snapshot_backend: "compose", + clear_on_down: false, + }, + }, + null, + 2 + )}\n` + ); + await Bun.write(composePath, compose({ ref: image, generation: "0" })); + await Bun.write( + join(fixture.hackDir, "hack.env.default.yaml"), + renderBootstrapEnvFile({ + environment: "default", + sealed: encryptSynthetic({ key, secret }), + }) + ); + // The overlay is valid YAML but sealed under a key the runner never has. + await Bun.write( + join(fixture.hackDir, `hack.env.${OVERLAY}.yaml`), + renderBootstrapEnvFile({ + environment: OVERLAY, + sealed: encryptSynthetic({ key: driftKey, secret: driftSecret }), + }) + ); + await Bun.write( + join(fixture.root, "deps/install.sh"), + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT + ); + await Bun.write( + join(fixture.root, "deps/verify.sh"), + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT + ); + await Bun.write(lockPath, lock(1)); + await commitAll({ root: fixture.root, message: "bootstrap negatives" }); + + const git = async (args: readonly string[]) => { + const result = await runCommand({ + argv: ["git", "-C", fixture.root, ...args], + cwd: fixture.root, + }); + expectExit({ result, codes: [0], message: `git ${args[0]}` }); + return result.stdout; + }; + const worktree = async (allowed: readonly string[], message: string) => { + const unexpected = unexpectedWorktreeChanges({ + porcelain: await git([ + "status", + "--porcelain=v1", + "--untracked-files=all", + ]), + allowed, + }); + expect({ + that: unexpected.length === 0, + message: `${message}; unexpected paths: ${unexpected.join(", ")}`, + }); + }; + const trackedFiles = async () => + (await git(["ls-files", "-z"])).split("\0").filter(Boolean); + const trackedBytes = async () => { + let total = 0; + for (const path of await trackedFiles()) { + total += Bun.file(join(fixture.root, path)).size; + } + return total; + }; + + const list = async (kind: ResourceKind, owner: string) => { + const args = kind === "container" ? ["ps", "-aq"] : [kind, "ls", "-q"]; + return words( + await docker([...args, "--filter", `label=${OWNER}=${owner}`]) + ); + }; + const owned = async (kind: ResourceKind, id: string, owner: string) => { + const expression = + kind === "container" ? "{{json .Config.Labels}}" : "{{json .Labels}}"; + const labels: unknown = JSON.parse( + await docker([kind, "inspect", id, "--format", expression]) + ); + expect({ + that: + typeof labels === "object" && + labels !== null && + (labels as Record)[OWNER] === owner && + (labels as Record)[PROJECT] === owner, + message: `Refuse ${kind} access outside exact fixture ownership`, + }); + }; + const clean = async (owner: string) => { + for (const kind of ["container", "network", "volume"] as const) { + for (const id of await list(kind, owner)) { + await owned(kind, id, owner); + await docker([ + kind, + "rm", + ...(kind === "container" ? ["--force"] : []), + id, + ]); + } + expect({ + that: (await list(kind, owner)).length === 0, + message: `Owned ${kind} cleanup must leave no resources`, + }); + } + }; + const cacheVolumes = async () => { + const names = (await list("volume", name)).filter((volume) => + volume.startsWith(cachePrefix) + ); + for (const volume of names) { + await owned("volume", volume, name); + } + return names.sort(); + }; + // Inspect only fixed fields: the consumer's Config.Env holds the secret. + const appIdentity = async (): Promise => { + const ids = words( + await docker([ + "ps", + "-aq", + "--filter", + `label=${OWNER}=${name}`, + "--filter", + "label=com.docker.compose.service=app", + ]) + ); + const id = ids[0]; + if (id === undefined) { + return null; + } + expect({ + that: ids.length === 1, + message: "Expected at most one owned consumer container", + }); + await owned("container", id, name); + const [fullId, running, startedAt] = words( + await docker([ + "inspect", + id, + "--format", + "{{.Id}} {{.State.Running}} {{.State.StartedAt}}", + ]) + ); + if (!(fullId && running && startedAt)) { + throw new Error("Consumer identity is incomplete"); + } + return { id: fullId, running: running === "true", startedAt }; + }; + const appExec = async (id: string, script: string) => + await runCommand({ + argv: ["docker", "exec", id, "sh", "-c", script], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }); + // The consumer copies the artifact only after its env digest matched. + const loaded = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const result = await appExec(identity.id, "cat /tmp/loaded"); + if (result.exitCode === 0) { + return result.stdout.trim(); + } + const state = await docker([ + "inspect", + identity.id, + "--format", + "{{.State.Running}}", + ]); + expect({ + that: state === "true", + message: + "Consumer exited before loading the cache (env digest or artifact missing)", + }); + await Bun.sleep(250); + } + throw new Error("Consumer did not load its dependency cache in time"); + }; + const mountedCache = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const volume = await docker([ + "inspect", + identity.id, + "--format", + '{{range .Mounts}}{{if eq .Destination "/deps"}}{{.Name}} {{.RW}}{{end}}{{end}}', + ]); + const [mounted, rw] = words(volume); + expect({ + that: Boolean(mounted?.startsWith(cachePrefix)) && rw === "false", + message: "Consumer must mount a fingerprinted cache read-only", + }); + return mounted as string; + }; + const cacheFacts = async (identity: AppIdentity) => { + if (identity === null) { + throw new Error("Consumer container missing"); + } + const result = await appExec( + identity.id, + "wc -l < /deps/initializations; find /deps -type f -exec cat {} + | wc -c" + ); + expectExit({ result, codes: [0], message: "Read mounted cache facts" }); + const [initializations, bytes] = words(result.stdout).map(Number); + if (initializations === undefined || bytes === undefined) { + throw new Error("Cache facts are incomplete"); + } + return { initializations, bytes }; + }; + // Read-only, network-less probe of one exact owned volume. + const probeVolume = async (volume: string, script: string) => { + await owned("volume", volume, name); + return ( + await runCommand({ + argv: [ + "docker", + "run", + "--rm", + "--pull=never", + "--network=none", + "--label", + `${OWNER}=${name}`, + "--label", + `${PROJECT}=${name}`, + "--volume", + `${volume}:/deps:ro`, + image, + "sh", + "-c", + script, + ], + cwd: ctx.tempRoot, + timeoutMs: TIMEOUT, + }) + ).exitCode; + }; + const readyMarker = (volume: string) => + `printf 'locked-v1\\n%s\\n' '${volume.slice(cachePrefix.length)}' | cmp -s - ${META}/ready`; + + const cli = async (opts: { + readonly args: readonly string[]; + readonly key: string | null; + }): Promise => { + const result = await ctx.cli({ + args: opts.args, + cwd: fixture.root, + timeoutMs: TIMEOUT, + env: { + HACK_EXECUTION_MODE: "slim", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + ...(opts.key === null ? {} : { HACK_ENV_SECRET_KEY: opts.key }), + }, + }); + // Checked before any assertion can attach this output to a failure. + expect({ + that: !containsForbiddenValue({ + texts: [result.stdout, result.stderr], + forbidden, + }), + message: `hack ${opts.args[0]} output must not contain keys or env values`, + }); + return result; + }; + const receipts: StageReceipt[] = []; + let consumer: AppIdentity = null; + let expectedArtifact = ""; + const stage = async (opts: { + readonly stage: PortableBootstrapStage; + readonly key: string | null; + readonly overlay?: string; + }) => { + const before = await appIdentity(); + const result = await cli({ + args: [ + "up", + "--detach", + ...(opts.overlay ? ["--env", opts.overlay] : []), + "app", + ], + key: opts.key, + }); + const after = await appIdentity(); + const transition = classifyAppTransition({ before, after }); + const failure = checkStageExpectation({ + stage: opts.stage, + exitCode: result.exitCode, + timedOut: result.timedOut, + transition, + running: after?.running === true, + }); + expect({ + that: failure === null, + message: `${opts.stage}: ${failure}`, + result, + }); + if (after !== null) { + // A new consumer must load its cache and stay the same container. + await loaded(after); + expect({ + that: + classifyAppTransition({ + before: after, + after: await appIdentity(), + }) === "unchanged", + message: `${opts.stage}: consumer must stay running without a restart`, + }); + } + const facts = after === null ? null : await cacheFacts(after); + receipts.push({ + stage: opts.stage, + outcome: result.exitCode === 0 ? "bootstrapped" : "refused", + app: transition, + durationMs: result.durationMs, + stdoutBytes: Buffer.byteLength(result.stdout), + stderrBytes: Buffer.byteLength(result.stderr), + trackedBytes: await trackedBytes(), + cacheBytes: facts?.bytes ?? null, + cacheVolumes: (await cacheVolumes()).length, + }); + ctx.log( + `${opts.stage}: ${result.exitCode === 0 ? "bootstrapped" : "refused"}, consumer ${transition}, ${result.durationMs} ms` + ); + consumer = after; + return { result, facts }; + }; + // Refusals and repeats must leave the consumer reading the same artifact. + const consumerKeeps = async (message: string) => { + expect({ + that: (await loaded(consumer)) === expectedArtifact, + message, + }); + }; + const nothingCreated = async (message: string) => { + for (const kind of ["container", "network", "volume"] as const) { + expect({ + that: (await list(kind, name)).length === 0, + message: `${message}: no owned ${kind} may exist`, + }); + } + }; + + try { + await docker([ + "volume", + "create", + "--label", + `${OWNER}=${canary}`, + "--label", + `${PROJECT}=${canary}`, + canary, + ]); + + // 1-3: env refusals happen before any Docker resource exists. + await stage({ stage: "refuse-missing-key", key: null }); + await nothingCreated("Missing key refusal"); + // The first lifecycle command converges Hack's managed ignore block. + await worktree( + [".hack/.gitignore"], + "A refused bootstrap may only add the managed .hack ignore block" + ); + if ((await git(["status", "--porcelain=v1"])).trim().length > 0) { + await commitAll({ + root: fixture.root, + message: "managed ignore block", + }); + } + await stage({ stage: "refuse-wrong-key", key: wrongKey }); + await nothingCreated("Wrong key refusal"); + await worktree([], "Wrong key refusal must leave no diff"); + await stage({ stage: "refuse-wrong-overlay", key, overlay: OVERLAY }); + await nothingCreated("Wrong overlay refusal"); + await worktree([], "Wrong overlay refusal must leave no diff"); + + // 4-5: two identical bootstraps; one initialization and no diff. + const cold = await stage({ stage: "bootstrap-cold", key }); + expectedArtifact = sha256Hex(lock(1)); + await consumerKeeps("Cold bootstrap must load the locked artifact"); + expect({ + that: cold.facts?.initializations === 1, + message: "Cold bootstrap must initialize the cache exactly once", + }); + const firstCache = await mountedCache(consumer); + expect({ + that: + (await probeVolume(firstCache, readyMarker(firstCache))) === 0 && + (await cacheVolumes()).length === 1, + message: "Cold bootstrap must publish one ready cache", + }); + await worktree([], "Cold bootstrap must leave no diff"); + const trackedBefore = await git(["ls-files", "-s"]); + const repeat = await stage({ stage: "bootstrap-repeat", key }); + await consumerKeeps("Repeat bootstrap must keep the loaded artifact"); + expect({ + that: + repeat.facts?.initializations === 1 && + (await mountedCache(consumer)) === firstCache && + (await cacheVolumes()).length === 1, + message: + "Repeat bootstrap must reuse the ready cache without reinstalling", + }); + await worktree([], "Repeat bootstrap must leave no diff"); + expect({ + that: (await git(["ls-files", "-s"])) === trackedBefore, + message: "Repeat bootstrap must leave the tracked index unchanged", + }); + + // 6: a wrong key cannot disturb a running generation. + await stage({ stage: "refuse-wrong-key-running", key: wrongKey }); + await consumerKeeps("Wrong key refusal must keep the consumer"); + await worktree([], "Wrong key refusal must leave no diff"); + + // 7: a stale pinned image is refused, never pulled or retagged. + const composeBytes = await Bun.file(composePath).text(); + await Bun.write( + composePath, + compose({ ref: staleImage, generation: "0" }) + ); + await stage({ stage: "refuse-stale-image", key }); + await consumerKeeps("Stale image refusal must keep the consumer"); + expect({ + that: + !(await imagePresent(staleImage)) && + (await docker([ + "image", + "inspect", + "alpine:3.20", + "--format", + "{{.Id}}", + ])) === image, + message: "Stale image refusal must not pull, build or retag images", + }); + await worktree( + [".hack/docker-compose.yml"], + "Stale image refusal must change only the edited compose file" + ); + await Bun.write(composePath, composeBytes); + await worktree([], "Restored compose must match HEAD"); + + // 8: a lockfile that no longer matches its manifest is never rewritten + // and never publishes a ready cache. + const lockBytes = await Bun.file(lockPath).text(); + const quarantineBefore = await cacheVolumes(); + const unfrozen = manifest.replace( + /\n\}\n?$/, + ',\n "hackE2eFrozenProbe": true\n}\n' + ); + expect({ + that: unfrozen !== manifest, + message: "The frozen lockfile control must change the manifest", + }); + await Bun.write(manifestPath, unfrozen); + await stage({ stage: "refuse-frozen-lockfile", key }); + await consumerKeeps("Frozen lockfile refusal must keep the consumer"); + const quarantined = (await cacheVolumes()).filter( + (volume) => !quarantineBefore.includes(volume) + ); + expect({ + that: + (await Bun.file(lockPath).text()) === lockBytes && + quarantined.length === 1 && + (await probeVolume( + quarantined[0] as string, + `test -f ${META}/attempt && test ! -e ${META}/ready && test ! -e /deps/artifact` + )) === 0, + message: + "Frozen refusal must keep the lockfile and quarantine its cache without a ready marker", + }); + await worktree( + ["package.json"], + "Frozen lockfile refusal must change only the edited manifest" + ); + await Bun.write(manifestPath, manifest); + await worktree([], "Restored manifest must match HEAD"); + + // 9: a changed lockfile selects and initializes a new cache. + await Bun.write(lockPath, lock(2)); + await commitAll({ root: fixture.root, message: "lockfile revision 2" }); + const changed = await stage({ + stage: "bootstrap-changed-lockfile", + key, + }); + expectedArtifact = sha256Hex(lock(2)); + await consumerKeeps("Changed lockfile must load the new artifact"); + const secondCache = await mountedCache(consumer); + expect({ + that: + changed.facts?.initializations === 1 && + secondCache !== firstCache && + (await probeVolume(firstCache, readyMarker(firstCache))) === 0, + message: + "Changed lockfile must initialize a new cache once and retain the previous ready cache", + }); + await worktree([], "Changed lockfile bootstrap must leave no diff"); + + // 10: a corrupted ready marker fails closed without reinstalling. + const corrupt = `printf 'locked-v1\\n%s\\n' '${"0".repeat(16)}'`; + await owned("volume", secondCache, name); + await docker([ + "run", + "--rm", + "--pull=never", + "--network=none", + "--label", + `${OWNER}=${name}`, + "--label", + `${PROJECT}=${name}`, + "--volume", + `${secondCache}:/deps`, + image, + "sh", + "-c", + `${corrupt} > ${META}/ready`, + ]); + const corrupted = await stage({ stage: "refuse-corrupted-cache", key }); + await consumerKeeps("Corrupted cache refusal must keep the consumer"); + expect({ + that: + corrupted.facts?.initializations === 1 && + (await probeVolume( + secondCache, + `${corrupt} | cmp -s - ${META}/ready` + )) === 0, + message: + "Corrupted cache refusal must neither reinstall nor rewrite the marker", + }); + await worktree([], "Corrupted cache refusal must leave no diff"); + + // 11: the documented recovery is a new generation, not in-place repair. + await Bun.write(composePath, compose({ ref: image, generation: "1" })); + await commitAll({ root: fixture.root, message: "cache generation 1" }); + const recovered = await stage({ + stage: "bootstrap-new-generation", + key, + }); + await consumerKeeps("New generation must load the current artifact"); + const thirdCache = await mountedCache(consumer); + expect({ + that: + recovered.facts?.initializations === 1 && + thirdCache !== secondCache && + (await cacheVolumes()).includes(secondCache), + message: + "New generation must initialize once and retain the corrupted cache for inspection", + }); + await worktree([], "New generation bootstrap must leave no diff"); + + const tracked: string[] = []; + for (const path of await trackedFiles()) { + tracked.push(await Bun.file(join(fixture.root, path)).text()); + } + expect({ + that: !containsForbiddenValue({ texts: tracked, forbidden }), + message: "Tracked fixture files must not contain keys or env values", + }); + + expectExit({ + result: await cli({ args: ["down"], key }), + codes: [0], + message: "hack down must succeed", + }); + expect({ + that: (await list("container", name)).length === 0, + message: "hack down must remove the fixture containers", + }); + await clean(name); + await owned("volume", canary, canary); + ctx.log( + `portable-bootstrap receipt ${renderPortableBootstrapReceipt({ stages: receipts, imageBytes })}` + ); + } finally { + try { + await clean(name); + } finally { + await clean(canary); + } + } + }, +}; diff --git a/tests/e2e/shard.ts b/tests/e2e/shard.ts index 907b364b6..4041ee065 100644 --- a/tests/e2e/shard.ts +++ b/tests/e2e/shard.ts @@ -52,6 +52,8 @@ export const E2E_DURATION_HINTS_SECONDS: Readonly> = { "native-compose-adoption-plural-network-worktrees": 33, "native-compose-adoption-branch-worktrees": 33, "portable-multiservice": 30, + // Estimate until a hosted docker-e2e summary measures it: eleven cached-alpine stages. + "portable-bootstrap-negatives": 45, // Estimate (no measured run yet): two cached-alpine ups plus four run/exec probes. "compose-config-hash-drift": 25, "dependency-cache-package": 30, diff --git a/tests/portable-bootstrap-env-refusal.test.ts b/tests/portable-bootstrap-env-refusal.test.ts new file mode 100644 index 000000000..78ead7e3b --- /dev/null +++ b/tests/portable-bootstrap-env-refusal.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { encryptSynthetic } from "../scripts/portable-bootstrap-smoke.ts"; +import { + PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + renderBootstrapCompose, + renderBootstrapEnvFile, + renderBootstrapLock, + sha256Hex, +} from "./e2e/portable-bootstrap-controls.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const cli = resolve(import.meta.dir, "../index.ts"); +// Synthetic, process-local values; never real credentials. +const KEY = "portable-refusal-key-0123456789abcdef"; +const WRONG_KEY = "portable-refusal-wrong-key-0123456789"; +const DRIFT_KEY = "portable-refusal-drift-key-0123456789"; +const SECRET = "portable-refusal-secret-value"; +const DRIFT_SECRET = "portable-refusal-drift-value"; +const FORBIDDEN = [KEY, WRONG_KEY, DRIFT_KEY, SECRET, DRIFT_SECRET]; +const MUTATIONS = new Set(["create", "run", "start", "up"]); + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +/** + * The hosted `portable-bootstrap-negatives` fixture against a recording Docker + * stub: env refusals must happen before Hack asks Compose to create, run or + * start anything. The stub refuses every Compose mutation, so the positive + * control shows the same command reaches the dependency bootstrap with a + * valid key. + */ +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "hack-portable-refusal-")); + roots.push(root); + const project = join(root, "project"); + const bin = join(root, "bin"); + const home = join(root, "home"); + for (const path of [ + join(project, ".hack"), + join(project, "deps"), + bin, + home, + ]) { + await mkdir(path, { recursive: true }); + } + const manifest = '{\n "name": "portable-refusal",\n "private": true\n}\n'; + const files: Record = { + ".gitignore": ".hack/.internal/\n.hack.secret.key\n", + "package.json": manifest, + "bun.lock": renderBootstrapLock({ manifest, revision: 1 }), + "deps/install.sh": PORTABLE_BOOTSTRAP_INSTALL_SCRIPT, + "deps/verify.sh": PORTABLE_BOOTSTRAP_VERIFY_SCRIPT, + ".hack/hack.config.json": `${JSON.stringify({ + name: "portable-refusal", + internal: { dns: false, tls: false }, + })}\n`, + ".hack/docker-compose.yml": renderBootstrapCompose({ + name: "portable-refusal", + ref: `sha256:${"a".repeat(64)}`, + platform: "linux/arm64", + generation: "0", + tokenDigest: sha256Hex(SECRET), + }), + ".hack/hack.env.default.yaml": renderBootstrapEnvFile({ + environment: "default", + sealed: encryptSynthetic({ key: KEY, secret: SECRET }), + }), + ".hack/hack.env.drift.yaml": renderBootstrapEnvFile({ + environment: "drift", + sealed: encryptSynthetic({ key: DRIFT_KEY, secret: DRIFT_SECRET }), + }), + }; + for (const [path, content] of Object.entries(files)) { + await writeFile(join(project, path), content); + } + const git = (args: string[]) => { + const result = Bun.spawnSync(["git", "-C", project, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).toBe(0); + }; + git(["init", "-q", "-b", "main"]); + git(["add", "."]); + git([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-qm", + "fixture", + ]); + const log = join(root, "docker.jsonl"); + await writeFile(log, ""); + const docker = join(bin, "docker"); + await writeFile( + docker, + `#!${process.execPath} +import { appendFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + "\\n"); +const mutations = ${JSON.stringify([...MUTATIONS])}; +if (args[0] === "compose" && args.some((arg) => mutations.includes(arg))) process.exit(97); +` + ); + await chmod(docker, 0o755); + const up = async (opts: { + readonly key: string | null; + readonly overlay?: string; + }) => { + const child = Bun.spawn( + [ + process.execPath, + cli, + "up", + "--detach", + ...(opts.overlay ? ["--env", opts.overlay] : []), + "app", + ], + { + cwd: project, + timeout: 20_000, + env: privateCliEnv({ + root, + home, + env: { + HACK_HOME: join(home, ".hack"), + PATH: `${bin}:${process.env.PATH ?? ""}`, + HACK_NO_INTERACTIVE: "1", + HACK_EXECUTION_MODE: "slim", + HACK_DAEMON_DISABLE_DOCKER_EVENTS: "1", + NO_COLOR: "1", + ...(opts.key === null ? {} : { HACK_ENV_SECRET_KEY: opts.key }), + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const calls = (await readFile(log, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + await writeFile(log, ""); + return { exitCode, stdout, stderr, calls }; + }; + return { up }; +} + +function composeMutations(calls: readonly (readonly string[])[]) { + return calls.filter( + (args) => args[0] === "compose" && args.some((arg) => MUTATIONS.has(arg)) + ); +} + +describe("portable bootstrap env refusals", () => { + test.each([ + { name: "missing key", key: null, reason: ".hack.secret.key" }, + { name: "wrong key", key: WRONG_KEY, reason: "Invalid secure env value." }, + { + name: "overlay sealed under another key", + key: KEY, + overlay: "drift", + reason: "Invalid secure env value.", + }, + ])("$name refuses before any Compose mutation", async (entry) => { + const { up } = await fixture(); + const result = await up(entry); + expect(result.exitCode).not.toBe(0); + expect(composeMutations(result.calls)).toEqual([]); + expect(`${result.stdout}${result.stderr}`).toContain(entry.reason); + for (const value of FORBIDDEN) { + expect(`${result.stdout}${result.stderr}`).not.toContain(value); + } + }, 60_000); + + test("the valid key reaches the dependency bootstrap first", async () => { + const { up } = await fixture(); + const result = await up({ key: KEY }); + expect(result.exitCode).not.toBe(0); + const mutations = composeMutations(result.calls); + expect(mutations.length).toBe(1); + expect(mutations[0]).toContain("run"); + expect(mutations[0]?.at(-1)).toBe("deps"); + for (const value of FORBIDDEN) { + expect(`${result.stdout}${result.stderr}`).not.toContain(value); + } + }, 60_000); +}); From cc140c87e2de4a3a29aef9d54e4636795ea0a7ba Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 11:57:55 -0400 Subject: [PATCH 03/11] feat(agents): shared AX target and result contract with a read-only inspector Add a provisional target/result decision record and a read-only target inspector with CLI and MCP parity (HACK-1144, WU31). - `hack project target [--json]` resolves the exact project, checkout, runtime instance, optional service, env overlay selection and a Hack config generation from local files only. It performs no registry write, engine call or Git mutation, and reports runtime as not probed. - `hack.project.target` (read-only MCP tool) spawns the same command and passes selection through unresolved, so every resolution refusal is the CLI's. Its data is the CLI envelope re-validated against the contract. - Refusals are a closed, value-free catalogue (reason, stage, retry) that reuses existing error codes. `--expect-generation` refuses stale config; a config change during the read is refused instead of snapshotted. - Extract the Compose project name resolver from commands/project.ts so the inspector and commands share one implementation. - Paired CLI/MCP fixtures: two projects, a linked-worktree branch and an explicit instance, conflicting selectors, stale generation, ambiguous equal names, a moved checkout, detached worktree and no-write checks. Names, fields and reasons are provisional pending compatibility review. Co-Authored-By: Claude Opus 5.5 --- docs/reference.md | 1 + docs/reference/ax-target-result-contract.md | 215 ++++ docs/reference/cli.md | 26 + docs/reference/mcp-tools.md | 19 + src/commands/project-admin.ts | 5 +- src/commands/project-target.ts | 114 ++ src/commands/project.ts | 38 +- src/lib/compose-project-name.ts | 58 + src/lib/target-inspect.ts | 1081 +++++++++++++++++++ src/mcp/server.ts | 83 ++ tests/mcp.test.ts | 5 + tests/project-target-inspect.test.ts | 654 +++++++++++ 12 files changed, 2260 insertions(+), 39 deletions(-) create mode 100644 docs/reference/ax-target-result-contract.md create mode 100644 src/commands/project-target.ts create mode 100644 src/lib/compose-project-name.ts create mode 100644 src/lib/target-inspect.ts create mode 100644 tests/project-target-inspect.test.ts diff --git a/docs/reference.md b/docs/reference.md index ee490977b..83ef637bb 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -29,3 +29,4 @@ - [Integrations](./integrations.md) - [MCP tool contract](./reference/mcp-tools.md) — every MCP tool: annotations, inputs, argv, results, and the value-free env tools - [MCP logs contract](./reference/mcp-logs.md) — cutoff versus backend failure for the MCP log tools, and the cursor/handle decision +- [AX target and result contract](./reference/ax-target-result-contract.md) — provisional target schema, closed refusals and the read-only `hack project target` inspector diff --git a/docs/reference/ax-target-result-contract.md b/docs/reference/ax-target-result-contract.md new file mode 100644 index 000000000..b0b7c11ba --- /dev/null +++ b/docs/reference/ax-target-result-contract.md @@ -0,0 +1,215 @@ +# AX target and result contract (provisional) + +Status: **provisional decision record** (HACK-1144, WU31). Every command name, +tool name, field, reason and retry value on this page can still change in the +agent-experience (AX) compatibility review. It is not a stable interface +promise. Fields and codes that the review must confirm are marked +_provisional_ below. + +This page records how Hack names the thing an agent wants to act on (the +**target**), how a read-only inspector reports it, and how adapters (CLI and +MCP) share one result shape. The first implementation is the read-only +inspector `hack project target` and its MCP tool `hack.project.target`. + +## Decisions + +1. **A target is an exact tuple, not a name.** It is the project, the checkout, + the runtime instance, an optional service, the env overlay selection and a + config generation, plus the selector kind and the observation time. A + project name alone is never a target. +2. **Git branch is not runtime identity.** The checkout reports its current Git + branch, but the runtime instance is a separate field. An explicit instance + selector (`--branch`) wins over the branch-derived default, and the + inspector never creates, switches or deletes Git branches or worktrees. +3. **References grant no authority.** A resolved target identifies scope only + (`authority: "none"`). A caller that acts on it must revalidate at its own + effect boundary, for example by passing the generation it inspected. +4. **Equal names are not merged.** Registration ownership is decided by the + exact checkout root (or a recorded linked worktree of a registration), never + by name. Two registrations that share a name are refused as + `ambiguous_project` when selected by name, and are never renamed. +5. **Generation covers Hack config only.** The generation fingerprints + `.hack/hack.config.json`, `.hack/hack.toml` and `.hack/docker-compose.yml`. + Ordinary source edits do not change it. Qualifying an immutable source + revision is a separate, explicit concern and is not part of this contract. +6. **No fake atomic snapshot.** The inspector reads the generation before and + after resolving the other fields. If it changed during the read, the result + is the `generation_changed_during_read` refusal, not a mixed snapshot. +7. **Snapshot reads have no side effects.** No registry write (not even the + last-seen touch other commands perform), no engine or Docker call, no + startup, repair or global sync, and no Git mutation. The only child + processes are read-only Git queries that existing project commands already + run. +8. **Readiness is not claimed.** The snapshot does not probe the runtime. It + reports `runtime: { observed: false, reason: "not_probed" }` instead of + guessing whether containers exist or are healthy. +9. **One semantics across adapters.** The MCP tool spawns the same CLI command + and passes selection through unresolved, so every resolution refusal comes + from the same code. There is no MCP-only selection policy. The MCP server + only checks input grammar before spawning; any value it accepts is then + judged by the CLI. +10. **Errors reuse existing codes.** Refusals reuse the stable CLI error codes + in `HACK_ERROR_CODES`. The closed `detail.reason` is the discriminator. + Adding a dedicated code (for example for staleness) is left to the + compatibility review. + +## Commands + +| Surface | Name (_provisional_) | Class | +| --- | --- | --- | +| CLI | `hack project target [--project=…\|--path=…] [--branch=…] [--service=…] [--env=…] [--expect-generation=…] [--json]` | read-only | +| MCP | `hack.project.target` (inputs `projectName`, `path`, `branch`, `service`, `env`, `expectGeneration`) | read-only | + +The MCP tool runs `hack project target --json` with the same options. Its +`data` is the CLI envelope, re-validated against this contract (`null` when +the CLI output does not match). `stdout` is withheld because `data` carries the +same envelope. + +## Result envelope + +The CLI prints exactly one envelope with `--json` and exits 0 on success and 1 +on a refusal. + +```json +{ + "ok": true, + "data": { + "schema": "hack.target.v0", + "provisional": true, + "observed_at": "2026-10-19T12:00:00.000Z", + "authority": "none", + "selector": { + "kind": "path", + "branch_explicit": false, + "env_explicit": false, + "expected_generation": null + }, + "project": { + "name": "alpha", + "registration": { "id": "alpha", "name": "alpha", "match": "worktree" }, + "project_dir": "/work/alpha-login/.hack" + }, + "checkout": { + "root": "/work/alpha-login", + "kind": "linked", + "git_branch": "feature/login" + }, + "instance": { + "branch": "feature-login", + "source": "worktree", + "compose_project": "alpha--feature-login" + }, + "service": null, + "services": ["api", "web"], + "env": { "overlay": null, "source": "base" }, + "generation": { + "id": "cfg-v0:0123456789abcdef0123456789abcdef", + "scope": "hack_config", + "inputs": [ + { "file": "hack.config.json", "present": true }, + { "file": "hack.toml", "present": false }, + { "file": "docker-compose.yml", "present": true } + ] + }, + "runtime": { "observed": false, "reason": "not_probed" } + } +} +``` + +| Field | Meaning | +| --- | --- | +| `selector.kind` | `project` (by registered name), `path` or `cwd` | +| `project.registration` | The registration that owns this exact checkout root (`match: root`) or lists it as a linked worktree (`match: worktree`); `null` when unregistered | +| `checkout.kind` | `primary`, `linked` (a linked Git worktree) or `not_git` | +| `instance.source` | `explicit` (`--branch`), `worktree` (derived from a linked worktree's branch) or `default` | +| `instance.compose_project` | The Compose project name commands use for this instance (`--` for a branch instance) | +| `services` | Service names declared in the Compose file, sorted | +| `env.source` | `explicit`, `project_default` (configured default overlay) or `base`; selection only, overlay existence is not checked | +| `generation.id` | `cfg-v0:` plus 32 hex characters; pass it back as `--expect-generation` | + +All fields are _provisional_. + +## Refusals + +A refusal is `{ ok: false, error: { code, message, detail } }`. `message` is +fixed text that never repeats caller input, paths or registry values. +`detail` is `{ reason, stage, retry, provisional: true }`, plus +`observed_generation` for `stale_generation` only. + +| `reason` (_provisional_) | `code` | `stage` | `retry` | +| --- | --- | --- | --- | +| `conflicting_selectors` | `E_USAGE` | `selector` | `fix_selector` | +| `invalid_selector` | `E_USAGE` | `selector` | `fix_selector` | +| `project_not_found` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | +| `ambiguous_project` | `E_USAGE` | `project` | `fix_selector` | +| `registered_root_missing` | `E_PROJECT_NOT_FOUND` | `project` | `fix_project` | +| `path_not_found` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | +| `no_project_context` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | +| `project_inputs_unsupported` | `E_CONFIG_INVALID` | `project` | `fix_project` | +| `config_parse_failed` | `E_CONFIG_PARSE` | `project` | `fix_project` | +| `detached_checkout` | `E_USAGE` | `instance` | `fix_selector` | +| `service_not_found` | `E_SERVICE_NOT_FOUND` | `service` | `fix_selector` | +| `stale_generation` | `E_USAGE` | `generation` | `reinspect` | +| `generation_changed_during_read` | `E_USAGE` | `generation` | `reinspect` | +| `generation_input_too_large` | `E_CONFIG_INVALID` | `generation` | `fix_project` | + +`retry` is advice for the caller. Like suggested actions and application logs, +it carries no execution authority. + +The CLI parser refuses a malformed `--project` or `--env` value before the +inspector runs, as it does for every command. That refusal is +`{ ok: false, error: { code: "E_USAGE", message } }` with no `detail`. The MCP +server refuses grammar-invalid `branch`, `service`, `env`, `projectName` length +or `expectGeneration` before spawning, as `invalid_arguments` with +`spawned: false`. These are input-shape refusals; every resolution refusal +above is identical on both surfaces. + +## Result semantics for future operations + +These rules are recorded for the operations that will consume targets (WU20, +WU21). The inspector itself only produces snapshots. + +- Separate **accepted work**, **terminal result** and **bounded observation**. + Work that failed but was observed successfully is not task success. +- Report partial results and freshness windows honestly; never present a + stitched read as an atomic snapshot. +- Preserve the compatible `ok`/`data`/`error`/`detail` envelope and documented + child exit codes. +- Never include secret values or ciphertext in inspect or env metadata. + +## Compatibility matrix + +| Concern | CLI `hack project target` | MCP `hack.project.target` | Evidence | +| --- | --- | --- | --- | +| Two projects, selection by name | yes | yes | paired fixture | +| Two branches (linked worktree default, explicit instance) | yes | yes | paired fixture | +| Conflicting selectors | `conflicting_selectors` | same | paired fixture | +| Stale generation | `stale_generation` | same | paired fixture | +| Source edit keeps generation | yes | yes | paired fixture | +| Ambiguous equal names, moved checkout, unknown project | closed refusals | same | paired fixture | +| Detached linked worktree, no project, missing path | closed refusals | same | paired fixture | +| Unknown service | `service_not_found` | same | paired fixture | +| Config changed during read | `generation_changed_during_read` | same code path | domain test | +| No registry, state or Git writes | yes | yes (audit log only) | paired fixture | +| Malformed selector | `invalid_selector` / parser `E_USAGE` | `invalid_arguments`, not spawned | paired fixture | + +The fixtures live in `tests/project-target-inspect.test.ts`. + +## Known gaps + +- **Runtime readback.** The snapshot never observes containers, so it does not + prove an instance exists, is running or is healthy. Actual container and env + readback for two projects and two branches needs a host with an engine and + is not covered here. +- **Concurrent sessions and slow or failed probes.** Not exercised: the + inspector has no runtime probe yet. +- **Env overlays.** Selection only; overlay existence and resolved keys are not + checked. Use `hack env list` or `hack.project.env.keys` for key metadata. +- **Generation scope.** Worktree-local overrides, env files and native + `hack.project.json` inputs are not part of the generation. Native projects + are refused as `project_inputs_unsupported`. +- **Pagination and byte limits.** The result is small and unpaginated; the + rule that critical blockers survive pagination applies to later list + operations. +- **Capability-limited clients and unknown provenance.** Not modeled. +- WU20, WU21 and WU28 runtime and AX gates remain open. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index dd1f96c8c..376b3ee91 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -23,6 +23,7 @@ hack project [options] | Command | Summary | | --- | --- | | `hack project owner` | Inspect project ownership | +| `hack project target` | Inspect the exact project, checkout, instance and service a command would target (read-only, provisional) | ### Options @@ -56,6 +57,31 @@ hack project owner [options] | `--help, -h` | Show help | | `--version, -v` | Show version | +## `hack project target` + +Inspect the exact project, checkout, instance and service a command would target (read-only, provisional) + +### Usage + +```bash +hack project target [options] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--path, -p ` | Run a project command against a repo path (overrides cwd search) | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--branch ` | Run against a branch-specific instance (compose name + hostnames) | +| `--service ` | Also resolve one Compose service of the target | +| `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | +| `--expect-generation ` | Refuse unless the project's config generation still equals this id | +| `--json` | Output JSON (machine-readable) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + ## `hack init` diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 237bf124c..78f557921 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -38,6 +38,7 @@ Every tool sets `openWorldHint: false`. Mutating tools set `idempotentHint: fals | `hack.project.env.explain` | read-only | `hack env explain --json -- ` | | `hack.project.env.keys` | read-only | `hack env list --json` | | `hack.project.instances` | read-only | `hack projects --json --project=` | +| `hack.project.target` | read-only | `hack project target --json` (provisional) | ## Project selection @@ -195,6 +196,24 @@ projects and carries container labels, mounts and networks, which `data` omits. `stderr` is returned as captured. `found` is `false` when the project is registered but the CLI listing does not include it. +## `hack.project.target` + +Resolves the exact target a command would act on: `hack project target --json +[--project=…] [--path=…] [--branch=…] [--service=…] [--env=…] +[--expect-generation=…]`. The tool, its inputs and the result schema are +provisional; see the [AX target and result contract](./ax-target-result-contract.md). + +- Inputs: `projectName`, `path`, `branch`, `service`, `env` and + `expectGeneration`, all optional. +- Unlike the other project tools, selection is not resolved by the MCP server. + `projectName` and `path` are passed through, both of them when both are + given, so the CLI owns every resolution refusal (for example + `conflicting_selectors`) and the MCP result matches the CLI exactly. +- `data` is the CLI's JSON envelope, re-validated against the contract, or + `null` when the output does not match it. `stdout` is always `""`, because + `data` carries the same envelope. `stderr` is returned as captured. +- The command never probes the engine, writes the registry or changes Git. + ## Audit log Every spawned command appends one JSON line to `$HACK_HOME/mcp-audit.log` (by diff --git a/src/commands/project-admin.ts b/src/commands/project-admin.ts index c0d40bf0c..521ea778d 100644 --- a/src/commands/project-admin.ts +++ b/src/commands/project-admin.ts @@ -10,6 +10,7 @@ import { sanitizeProjectSlug, } from "../lib/project.ts"; import { display } from "../ui/display.ts"; +import { targetCommand, targetSpec } from "./project-target.ts"; const ownerShowOptions = [optPath, optProject, optJson] as const; @@ -37,7 +38,7 @@ const projectSpec = defineCommand({ group: "Project", options: [], positionals: [], - subcommands: [ownerSpec], + subcommands: [ownerSpec, targetSpec], } as const); const handleOwnerShow: CommandHandlerFor = async ({ @@ -90,7 +91,7 @@ const ownerCommand = { export const projectCommand = { ...projectSpec, - subcommands: [ownerCommand], + subcommands: [ownerCommand, targetCommand], } as const; async function resolveProjectTarget(input: { diff --git a/src/commands/project-target.ts b/src/commands/project-target.ts new file mode 100644 index 000000000..a98c46b07 --- /dev/null +++ b/src/commands/project-target.ts @@ -0,0 +1,114 @@ +import type { CommandHandlerFor } from "../cli/command.ts"; +import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; +import { + optBranch, + optEnv, + optJson, + optPath, + optProject, +} from "../cli/options.ts"; +import { + emitCliResult, + exitCodeForResult, + HackCliError, +} from "../lib/cli-result.ts"; +import { + inspectTarget, + type TargetInspectData, +} from "../lib/target-inspect.ts"; +import { display } from "../ui/display.ts"; + +const optTargetService = defineOption({ + name: "service", + type: "string", + long: "--service", + valueHint: "", + description: "Also resolve one Compose service of the target", +} as const); + +const optExpectGeneration = defineOption({ + name: "expectGeneration", + type: "string", + long: "--expect-generation", + valueHint: "", + description: + "Refuse unless the project's config generation still equals this id", +} as const); + +const targetOptions = [ + optPath, + optProject, + optBranch, + optTargetService, + optEnv, + optExpectGeneration, + optJson, +] as const; + +/** + * `hack project target`: read-only target inspector (HACK-1144). The name, + * options and result schema are provisional until the AX compatibility + * review; see docs/reference/ax-target-result-contract.md. + */ +export const targetSpec = defineCommand({ + name: "target", + summary: + "Inspect the exact project, checkout, instance and service a command would target (read-only, provisional)", + group: "Project", + options: targetOptions, + positionals: [], + subcommands: [], +} as const); + +const handleTarget: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + const options = args.options; + const result = await inspectTarget({ + cwd: ctx.cwd, + selector: { + ...(options.project === undefined ? {} : { project: options.project }), + ...(options.path === undefined ? {} : { path: options.path }), + ...(options.branch === undefined ? {} : { branch: options.branch }), + ...(options.service === undefined ? {} : { service: options.service }), + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.expectGeneration === undefined + ? {} + : { expectGeneration: options.expectGeneration }), + }, + }); + + if (options.json === true) { + emitCliResult({ result }); + return exitCodeForResult({ result }); + } + if (!result.ok) { + throw new HackCliError(result.error); + } + await display.kv({ title: "Target", entries: targetEntries(result.data) }); + return 0; +}; + +function targetEntries( + data: TargetInspectData +): readonly (readonly [string, string])[] { + return [ + ["Project", data.project.name], + [ + "Registration", + data.project.registration + ? `${data.project.registration.id} (${data.project.registration.match})` + : "unregistered", + ], + ["Checkout", `${data.checkout.root} (${data.checkout.kind})`], + ["Git branch", data.checkout.git_branch ?? "-"], + ["Instance", `${data.instance.compose_project} (${data.instance.source})`], + ["Service", data.service?.name ?? "-"], + ["Env overlay", `${data.env.overlay ?? "base"} (${data.env.source})`], + ["Generation", data.generation.id], + ["Runtime", "not probed"], + ]; +} + +export const targetCommand = withHandler(targetSpec, handleTarget); diff --git a/src/commands/project.ts b/src/commands/project.ts index 073ce8ee2..0874ae3a0 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -153,6 +153,7 @@ import { } from "../lib/cli-result.ts"; import { withComposeServiceEnvFingerprints } from "../lib/compose-env-fingerprint.ts"; import { renderManagedComposeEnvOverride } from "../lib/compose-managed-env.ts"; +import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS, resolveComposeStartupTimeoutMs, @@ -1008,43 +1009,6 @@ function emitRemoteLifecycleJson(opts: { }); } -async function resolveComposeProjectName(opts: { - readonly project: Awaited>; - readonly cfg?: Awaited>; -}): Promise { - const composeName = await readComposeProjectName(opts.project.composeFile); - if (composeName) { - return composeName; - } - - const derived = defaultProjectSlugFromPath(opts.project.projectRoot); - const cfgName = (opts.cfg?.name ?? derived).trim(); - return cfgName.length > 0 ? cfgName : derived; -} - -async function readComposeProjectName( - composeFile: string -): Promise { - const text = await readTextFile(composeFile); - if (!text) { - return null; - } - - let parsed: unknown; - try { - parsed = YAML.parse(text); - } catch { - return null; - } - - if (!isRecord(parsed)) { - return null; - } - const name = getString(parsed, "name"); - const trimmed = name?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - async function buildBranchComposeOverride(opts: { readonly project: Awaited>; readonly branch: string; diff --git a/src/lib/compose-project-name.ts b/src/lib/compose-project-name.ts new file mode 100644 index 000000000..09d891664 --- /dev/null +++ b/src/lib/compose-project-name.ts @@ -0,0 +1,58 @@ +import { YAML } from "bun"; +import { readTextFile } from "./fs.ts"; +import { getString, isRecord } from "./guards.ts"; +import type { ProjectConfig, ProjectContext } from "./project.ts"; +import { defaultProjectSlugFromPath } from "./project.ts"; + +/** + * Resolve the base Compose project name for a project's default instance. + * + * Precedence: the Compose file's top-level `name:`, then the project config + * `name`, then the slug derived from the project root directory. A branch + * instance appends `--` to this base (see + * {@link branchComposeProjectName}). The read is side-effect free. + */ +export async function resolveComposeProjectName(opts: { + readonly project: Pick; + readonly cfg?: Pick; +}): Promise { + const composeName = await readComposeProjectName(opts.project.composeFile); + if (composeName) { + return composeName; + } + + const derived = defaultProjectSlugFromPath(opts.project.projectRoot); + const cfgName = (opts.cfg?.name ?? derived).trim(); + return cfgName.length > 0 ? cfgName : derived; +} + +/** Compose project name of a branch instance: `--`. */ +export function branchComposeProjectName(opts: { + readonly base: string; + readonly branch: string; +}): string { + return `${opts.base}--${opts.branch}`; +} + +async function readComposeProjectName( + composeFile: string +): Promise { + const text = await readTextFile(composeFile); + if (!text) { + return null; + } + + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch { + return null; + } + + if (!isRecord(parsed)) { + return null; + } + const name = getString(parsed, "name"); + const trimmed = name?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/lib/target-inspect.ts b/src/lib/target-inspect.ts new file mode 100644 index 000000000..941feb77b --- /dev/null +++ b/src/lib/target-inspect.ts @@ -0,0 +1,1081 @@ +import { createHash } from "node:crypto"; +import { readFile, realpath, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, +} from "../constants.ts"; +import { resolveEffectiveBranch } from "./branches.ts"; +import { HACK_ERROR_CODES, type HackErrorCode } from "./cli-result.ts"; +import { + branchComposeProjectName, + resolveComposeProjectName, +} from "./compose-project-name.ts"; +import { + isLinkedGitWorktree, + resolveGitCurrentBranch, +} from "./git-worktree.ts"; +import { isRecord } from "./guards.ts"; +import { + defaultProjectSlugFromPath, + findProjectContext, + findProjectContextAtRoot, + type ProjectContext, + parseEnvConfigSelection, + readProjectConfig, + readProjectDefaultEnvConfig, + resolveWorktreeAutoBranch, + sanitizeBranchSlug, + sanitizeProjectSlug, +} from "./project.ts"; +import { discoverComposeServiceNames } from "./project-env-config.ts"; +import { ProjectInputSelectionError } from "./project-input-selection.ts"; +import { + AmbiguousProjectNameError, + normalizeProjectName, + PROJECT_SELECTOR_PATTERN, +} from "./project-name.ts"; +import { + AmbiguousProjectIdError, + type RegisteredProject, + readProjectsRegistry, + selectRegisteredProjectByName, +} from "./projects-registry.ts"; + +/** + * Read-only target inspector shared by `hack project target` and the MCP + * `hack.project.target` tool (HACK-1144 / WU31). + * + * The inspector resolves one exact target (project, checkout, runtime + * instance, optional service, env overlay selection and a config generation) + * from local files only. It never starts, repairs, registers or touches + * anything: no registry write, no Docker/engine call, no Git mutation. The + * only child processes are read-only `git rev-parse`/`git branch + * --show-current` lookups that existing project commands already run. + * + * Contract status: every schema name, field, reason and retry value here is + * PROVISIONAL until the AX compatibility review. See + * docs/reference/ax-target-result-contract.md. + * + * A resolved target is a reference: it identifies scope and grants no + * authority. Callers that act on it must revalidate at their own effect + * boundary (for example by passing `expectGeneration`). + */ + +/** Provisional schema id carried by every successful result. */ +export const TARGET_INSPECT_SCHEMA = "hack.target.v0" as const; + +/** Prefix of a provisional config generation id. */ +const GENERATION_PREFIX = "cfg-v0:" as const; + +/** Exact grammar of a generation id accepted as an expectation. */ +export const TARGET_GENERATION_PATTERN = /^cfg-v0:[0-9a-f]{32}$/; + +/** + * Runtime instance selector grammar. Identical to the MCP branch identifier + * rule, so no value can start with `-` or carry `=`. + */ +export const TARGET_BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_./-]*$/; + +/** Compose service grammar, identical to the MCP service identifier rule. */ +export const TARGET_SERVICE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +/** Longest accepted identifier (branch, service). */ +export const TARGET_IDENTIFIER_MAX_LENGTH = 253; + +/** Hack config files a generation covers, in hashing order. */ +const GENERATION_INPUTS = [ + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, + PROJECT_COMPOSE_FILENAME, +] as const; + +/** Bound on one generation input; a larger file is refused, not truncated. */ +const GENERATION_INPUT_MAX_BYTES = 4 * 1024 * 1024; + +/** Which resolution stage refused. Provisional. */ +export type TargetStage = + | "selector" + | "project" + | "instance" + | "service" + | "generation"; + +/** What a caller should do next. Provisional; carries no execution authority. */ +export type TargetRetry = "fix_selector" | "fix_project" | "reinspect"; + +type RefusalSpec = { + readonly code: HackErrorCode; + readonly stage: TargetStage; + readonly retry: TargetRetry; + readonly message: string; +}; + +/** + * Closed catalogue of inspector refusals. Messages are fixed, value-free text: + * they never repeat selectors, paths, names or registry values. Codes reuse + * existing CLI error codes; the `reason` is the stable discriminator. + */ +export const TARGET_REFUSALS = Object.freeze({ + conflicting_selectors: { + code: "E_USAGE", + stage: "selector", + retry: "fix_selector", + message: "Use only one of --project or --path.", + }, + invalid_selector: { + code: "E_USAGE", + stage: "selector", + retry: "fix_selector", + message: + "Invalid target selector. Check the project, branch, service and expected generation values.", + }, + project_not_found: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "Unknown project. Run 'hack projects' to see registered projects.", + }, + ambiguous_project: { + code: "E_USAGE", + stage: "project", + retry: "fix_selector", + message: + "Ambiguous project selection: multiple registrations match. Select by path and inspect 'hack projects' before renaming or pruning an entry.", + }, + registered_root_missing: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_project", + message: + "The registered project root no longer exists. Select the checkout by path or run 'hack projects' to inspect the registration.", + }, + path_not_found: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "Path not found.", + }, + no_project_context: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "No hack project found at the selected location.", + }, + project_inputs_unsupported: { + code: "E_CONFIG_INVALID", + stage: "project", + retry: "fix_project", + message: + "The selected project's inputs are not supported by this inspector (native or conflicting project files).", + }, + config_parse_failed: { + code: "E_CONFIG_PARSE", + stage: "project", + retry: "fix_project", + message: "The project config could not be parsed.", + }, + detached_checkout: { + code: "E_USAGE", + stage: "instance", + retry: "fix_selector", + message: + "Detached linked worktree cannot select an instance implicitly. Pass a branch, or set worktree.auto_branch=false to target the base instance.", + }, + service_not_found: { + code: "E_SERVICE_NOT_FOUND", + stage: "service", + retry: "fix_selector", + message: + "The selected service is not declared in the project's Compose file.", + }, + stale_generation: { + code: "E_USAGE", + stage: "generation", + retry: "reinspect", + message: + "The project config generation differs from the expected generation. Inspect the target again before acting.", + }, + generation_changed_during_read: { + code: "E_USAGE", + stage: "generation", + retry: "reinspect", + message: + "The project config changed while the target was being read. Inspect the target again.", + }, + generation_input_too_large: { + code: "E_CONFIG_INVALID", + stage: "generation", + retry: "fix_project", + message: "A project config input exceeds the inspector size bound.", + }, +} as const satisfies Record); + +export type TargetRefusalReason = keyof typeof TARGET_REFUSALS; + +export const TARGET_REFUSAL_REASONS = Object.keys(TARGET_REFUSALS) as [ + TargetRefusalReason, + ...TargetRefusalReason[], +]; + +/** Caller selectors. All optional; `project` and `path` are exclusive. */ +export type TargetSelector = { + readonly project?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}; + +export type TargetInspectData = { + readonly schema: typeof TARGET_INSPECT_SCHEMA; + readonly provisional: true; + readonly observed_at: string; + /** References identify scope only; they never authorize an action. */ + readonly authority: "none"; + readonly selector: { + readonly kind: "project" | "path" | "cwd"; + readonly branch_explicit: boolean; + readonly env_explicit: boolean; + readonly expected_generation: string | null; + }; + readonly project: { + readonly name: string; + readonly registration: { + readonly id: string; + readonly name: string; + readonly match: "root" | "worktree"; + } | null; + readonly project_dir: string; + }; + readonly checkout: { + readonly root: string; + readonly kind: "primary" | "linked" | "not_git"; + /** Git branch is reported, never used as runtime identity by itself. */ + readonly git_branch: string | null; + }; + readonly instance: { + readonly branch: string | null; + readonly source: "explicit" | "worktree" | "default"; + readonly compose_project: string; + }; + readonly service: { readonly name: string } | null; + readonly services: readonly string[]; + readonly env: { + readonly overlay: string | null; + readonly source: "explicit" | "project_default" | "base"; + }; + readonly generation: { + readonly id: string; + readonly scope: "hack_config"; + readonly inputs: readonly { + readonly file: string; + readonly present: boolean; + }[]; + }; + /** The snapshot never probes the engine; readiness is not claimed. */ + readonly runtime: { readonly observed: false; readonly reason: "not_probed" }; +}; + +export type TargetRefusalDetail = { + readonly reason: TargetRefusalReason; + readonly stage: TargetStage; + readonly retry: TargetRetry; + readonly provisional: true; + /** Present only for `stale_generation`: the generation actually observed. */ + readonly observed_generation?: string; +}; + +export type TargetInspectEnvelope = + | { readonly ok: true; readonly data: TargetInspectData } + | { + readonly ok: false; + readonly error: { + readonly code: HackErrorCode; + readonly message: string; + readonly detail?: TargetRefusalDetail; + }; + }; + +class TargetRefusal extends Error { + readonly reason: TargetRefusalReason; + readonly observedGeneration: string | undefined; + + constructor(reason: TargetRefusalReason, observedGeneration?: string) { + super(TARGET_REFUSALS[reason].message); + this.reason = reason; + this.observedGeneration = observedGeneration; + } +} + +/** Build the closed refusal envelope for one reason. */ +export function targetRefusalEnvelope(opts: { + readonly reason: TargetRefusalReason; + readonly observedGeneration?: string; +}): TargetInspectEnvelope { + const spec = TARGET_REFUSALS[opts.reason]; + return { + ok: false, + error: { + code: spec.code, + message: spec.message, + detail: { + reason: opts.reason, + stage: spec.stage, + retry: spec.retry, + provisional: true, + ...(opts.observedGeneration === undefined + ? {} + : { observed_generation: opts.observedGeneration }), + }, + }, + }; +} + +/** + * Inspect one target. Returns a refusal envelope for every closed failure; + * only unexpected faults (for example an unreadable directory) throw. + * + * `afterResolve` is a test seam that runs after the target is resolved and + * before the generation is re-read; production callers never pass it. + */ +export async function inspectTarget(opts: { + readonly cwd: string; + readonly selector: TargetSelector; + readonly registryPath?: string; + readonly now?: () => Date; + readonly afterResolve?: () => Promise; +}): Promise { + try { + const data = await resolveTarget(opts); + return { ok: true, data }; + } catch (error: unknown) { + if (error instanceof TargetRefusal) { + return targetRefusalEnvelope({ + reason: error.reason, + ...(error.observedGeneration === undefined + ? {} + : { observedGeneration: error.observedGeneration }), + }); + } + throw error; + } +} + +type ValidatedSelector = { + readonly project: string | null; + readonly path: string | null; + readonly branch: string | null; + readonly service: string | null; + /** undefined: use the project default; null: base layers only. */ + readonly env: string | null | undefined; + readonly expectGeneration: string | null; +}; + +function validateSelector(selector: TargetSelector): ValidatedSelector { + if (selector.project !== undefined && selector.path !== undefined) { + throw new TargetRefusal("conflicting_selectors"); + } + let project: string | null = null; + if (selector.project !== undefined) { + const trimmed = selector.project.trim(); + project = PROJECT_SELECTOR_PATTERN.test(trimmed) + ? normalizeProjectName(trimmed) + : null; + if (!project) { + throw new TargetRefusal("invalid_selector"); + } + } + let path: string | null = null; + if (selector.path !== undefined) { + if (selector.path.trim().length === 0) { + throw new TargetRefusal("invalid_selector"); + } + path = selector.path; + } + const branch = validateIdentifier(selector.branch, TARGET_BRANCH_PATTERN); + const service = validateIdentifier(selector.service, TARGET_SERVICE_PATTERN); + let env: string | null | undefined; + if (selector.env !== undefined) { + env = parseEnvConfigSelection(selector.env); + if (env === undefined) { + throw new TargetRefusal("invalid_selector"); + } + } + let expectGeneration: string | null = null; + if (selector.expectGeneration !== undefined) { + if (!TARGET_GENERATION_PATTERN.test(selector.expectGeneration)) { + throw new TargetRefusal("invalid_selector"); + } + expectGeneration = selector.expectGeneration; + } + return { project, path, branch, service, env, expectGeneration }; +} + +function validateIdentifier( + value: string | undefined, + pattern: RegExp +): string | null { + if (value === undefined) { + return null; + } + if (value.length > TARGET_IDENTIFIER_MAX_LENGTH || !pattern.test(value)) { + throw new TargetRefusal("invalid_selector"); + } + return value; +} + +type SelectedProject = { + readonly context: ProjectContext; + readonly kind: TargetInspectData["selector"]["kind"]; +}; + +async function resolveTarget(opts: { + readonly cwd: string; + readonly selector: TargetSelector; + readonly registryPath?: string; + readonly now?: () => Date; + readonly afterResolve?: () => Promise; +}): Promise { + const selector = validateSelector(opts.selector); + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + }); + const selected = await selectProject({ + cwd: opts.cwd, + selector, + projects: registry.projects, + }); + const context = selected.context; + + const before = await readGeneration(context); + const cfg = await readProjectConfig(context); + if (cfg.parseError) { + throw new TargetRefusal("config_parse_failed"); + } + + const root = await canonicalPath(context.projectRoot); + const registration = await findRegistrationForRoot({ + projects: registry.projects, + root, + }); + + const linked = await isLinkedGitWorktree({ repoRoot: context.projectRoot }); + const gitBranch = + linked === null + ? null + : await resolveGitCurrentBranch({ repoRoot: context.projectRoot }); + + const explicitBranch = selector.branch + ? sanitizeBranchSlug(selector.branch) || "branch" + : null; + const effective = await resolveEffectiveBranch({ + explicitBranch, + projectRoot: context.projectRoot, + autoBranchEnabled: resolveWorktreeAutoBranch(cfg), + }); + if (effective.source === "detached-worktree") { + throw new TargetRefusal("detached_checkout"); + } + const baseCompose = await resolveComposeProjectName({ + project: context, + cfg, + }); + const instanceBranch = effective.branch; + + const services = await discoverComposeServiceNames({ + composeFile: context.composeFile, + }); + if (selector.service !== null && !services.includes(selector.service)) { + throw new TargetRefusal("service_not_found"); + } + + const env = await resolveEnvSelection({ context, requested: selector.env }); + + await opts.afterResolve?.(); + const after = await readGeneration(context); + if (after.id !== before.id) { + throw new TargetRefusal("generation_changed_during_read"); + } + if ( + selector.expectGeneration !== null && + selector.expectGeneration !== after.id + ) { + throw new TargetRefusal("stale_generation", after.id); + } + + const now = opts.now ?? (() => new Date()); + return { + schema: TARGET_INSPECT_SCHEMA, + provisional: true, + observed_at: now().toISOString(), + authority: "none", + selector: { + kind: selected.kind, + branch_explicit: selector.branch !== null, + env_explicit: selector.env !== undefined, + expected_generation: selector.expectGeneration, + }, + project: { + name: projectSlug({ cfgName: cfg.name, root: context.projectRoot }), + registration, + project_dir: context.projectDir, + }, + checkout: { + root: context.projectRoot, + kind: checkoutKind(linked), + git_branch: gitBranch, + }, + instance: { + branch: instanceBranch, + source: instanceSource(effective.source), + compose_project: instanceBranch + ? branchComposeProjectName({ + base: baseCompose, + branch: instanceBranch, + }) + : baseCompose, + }, + service: selector.service === null ? null : { name: selector.service }, + services, + env, + generation: { + id: after.id, + scope: "hack_config", + inputs: after.inputs, + }, + runtime: { observed: false, reason: "not_probed" }, + }; +} + +/** Project slug: config `name`, else the slug of the checkout directory. */ +function projectSlug(opts: { + readonly cfgName: string | undefined; + readonly root: string; +}): string { + const configured = opts.cfgName?.trim() ?? ""; + return configured.length > 0 + ? sanitizeProjectSlug(configured) + : defaultProjectSlugFromPath(opts.root); +} + +function checkoutKind( + linked: boolean | null +): TargetInspectData["checkout"]["kind"] { + if (linked === null) { + return "not_git"; + } + return linked ? "linked" : "primary"; +} + +function instanceSource( + source: "explicit" | "worktree" | "none" +): TargetInspectData["instance"]["source"] { + return source === "none" ? "default" : source; +} + +async function selectProject(opts: { + readonly cwd: string; + readonly selector: ValidatedSelector; + readonly projects: readonly RegisteredProject[]; +}): Promise { + if (opts.selector.project !== null) { + let match: RegisteredProject | null; + try { + match = selectRegisteredProjectByName({ + projects: opts.projects, + name: opts.selector.project, + }); + } catch (error: unknown) { + if ( + error instanceof AmbiguousProjectNameError || + error instanceof AmbiguousProjectIdError + ) { + throw new TargetRefusal("ambiguous_project"); + } + throw error; + } + if (!match) { + throw new TargetRefusal("project_not_found"); + } + if (!(await isDirectory(match.repoRoot))) { + throw new TargetRefusal("registered_root_missing"); + } + const context = await guardInputs(() => + findProjectContextAtRoot({ + projectRoot: match.repoRoot, + projectDirName: match.projectDirName, + }) + ); + if (!context) { + throw new TargetRefusal("no_project_context"); + } + return { context, kind: "project" }; + } + + const startDir = + opts.selector.path === null + ? opts.cwd + : resolve(opts.cwd, opts.selector.path); + if (opts.selector.path !== null && !(await isDirectory(startDir))) { + throw new TargetRefusal("path_not_found"); + } + const context = await guardInputs(() => findProjectContext(startDir)); + if (!context) { + throw new TargetRefusal("no_project_context"); + } + return { context, kind: opts.selector.path === null ? "cwd" : "path" }; +} + +async function guardInputs(read: () => Promise): Promise { + try { + return await read(); + } catch (error: unknown) { + if (error instanceof ProjectInputSelectionError) { + throw new TargetRefusal("project_inputs_unsupported"); + } + throw error; + } +} + +/** + * Registration that owns this exact checkout root: its primary root, or one of + * its recorded linked worktrees. Name equality never matches, so independent + * checkouts that share a name stay separate. Two owners refuse as ambiguous. + */ +async function findRegistrationForRoot(opts: { + readonly projects: readonly RegisteredProject[]; + readonly root: string; +}): Promise { + const byRoot: RegisteredProject[] = []; + const byWorktree: RegisteredProject[] = []; + for (const project of opts.projects) { + if ((await canonicalPath(project.repoRoot)) === opts.root) { + byRoot.push(project); + continue; + } + for (const worktree of project.worktrees ?? []) { + if ((await canonicalPath(worktree.path)) === opts.root) { + byWorktree.push(project); + break; + } + } + } + const candidates = byRoot.length > 0 ? byRoot : byWorktree; + if (candidates.length > 1) { + throw new TargetRefusal("ambiguous_project"); + } + const match = candidates[0]; + if (!match) { + return null; + } + return { + id: match.id, + name: match.name, + match: byRoot.length > 0 ? "root" : "worktree", + }; +} + +async function resolveEnvSelection(opts: { + readonly context: ProjectContext; + readonly requested: string | null | undefined; +}): Promise { + if (opts.requested === null) { + return { overlay: null, source: "base" }; + } + if (opts.requested !== undefined) { + return { overlay: opts.requested, source: "explicit" }; + } + const configured = await readProjectDefaultEnvConfig({ + projectDir: opts.context.projectDir, + }); + return configured + ? { overlay: configured, source: "project_default" } + : { overlay: null, source: "base" }; +} + +type Generation = { + readonly id: string; + readonly inputs: TargetInspectData["generation"]["inputs"]; +}; + +/** + * Fingerprint of the Hack config inputs only. Ordinary source edits do not + * change it; editing hack.config.json, hack.toml or docker-compose.yml does. + * Absence is hashed distinctly from an empty file. + */ +async function readGeneration(context: ProjectContext): Promise { + const hash = createHash("sha256"); + const inputs: { file: string; present: boolean }[] = []; + for (const file of GENERATION_INPUTS) { + const bytes = await readBoundedInput(resolve(context.projectDir, file)); + hash.update(`${file}\n`); + if (bytes === null) { + hash.update("0\n"); + inputs.push({ file, present: false }); + continue; + } + hash.update(`1\n${bytes.byteLength}\n`); + hash.update(bytes); + inputs.push({ file, present: true }); + } + return { + id: `${GENERATION_PREFIX}${hash.digest("hex").slice(0, 32)}`, + inputs, + }; +} + +async function readBoundedInput(path: string): Promise { + let size: number; + try { + const info = await stat(path); + if (!info.isFile()) { + return null; + } + size = info.size; + } catch (error: unknown) { + if (isMissing(error)) { + return null; + } + throw error; + } + if (size > GENERATION_INPUT_MAX_BYTES) { + throw new TargetRefusal("generation_input_too_large"); + } + try { + const bytes = await readFile(path); + if (bytes.byteLength > GENERATION_INPUT_MAX_BYTES) { + throw new TargetRefusal("generation_input_too_large"); + } + return bytes; + } catch (error: unknown) { + if (isMissing(error)) { + return null; + } + throw error; + } +} + +function isMissing(error: unknown): boolean { + return ( + isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch (error: unknown) { + if (isMissing(error)) { + return false; + } + throw error; + } +} + +async function canonicalPath(path: string): Promise { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +// --------------------------------------------------------------------------- +// Envelope validation shared by adapters (the MCP tool re-validates the CLI's +// stdout through this, so a drifted or foreign payload is never passed on). +// --------------------------------------------------------------------------- + +const KNOWN_ERROR_CODES: ReadonlySet = new Set(HACK_ERROR_CODES); + +/** + * Parse a `hack project target --json` envelope. Returns a rebuilt envelope + * containing only contract fields, or null when the value does not match the + * provisional contract. Error envelopes with a known code but no `detail` + * (the CLI's shared option-grammar refusals) are accepted as-is. + */ +export function parseTargetInspectEnvelope( + value: unknown +): TargetInspectEnvelope | null { + if (!isRecord(value)) { + return null; + } + if (value.ok === true) { + const data = parseData(value.data); + return data ? { ok: true, data } : null; + } + if (value.ok !== false || !isRecord(value.error)) { + return null; + } + const { code, message, detail } = value.error; + if ( + typeof code !== "string" || + !KNOWN_ERROR_CODES.has(code) || + typeof message !== "string" + ) { + return null; + } + const errorCode = code as HackErrorCode; + if (detail === undefined) { + return { ok: false, error: { code: errorCode, message } }; + } + const parsedDetail = parseRefusalDetail(detail); + if (!parsedDetail) { + return null; + } + const spec = TARGET_REFUSALS[parsedDetail.reason]; + if (spec.code !== errorCode || spec.message !== message) { + return null; + } + return { + ok: false, + error: { code: errorCode, message, detail: parsedDetail }, + }; +} + +function parseRefusalDetail(value: unknown): TargetRefusalDetail | null { + if (!isRecord(value) || value.provisional !== true) { + return null; + } + const reason = value.reason; + if (typeof reason !== "string" || !Object.hasOwn(TARGET_REFUSALS, reason)) { + return null; + } + const typedReason = reason as TargetRefusalReason; + const spec = TARGET_REFUSALS[typedReason]; + if (value.stage !== spec.stage || value.retry !== spec.retry) { + return null; + } + const observed = value.observed_generation; + if ( + observed !== undefined && + (typedReason !== "stale_generation" || + typeof observed !== "string" || + !TARGET_GENERATION_PATTERN.test(observed)) + ) { + return null; + } + return { + reason: typedReason, + stage: spec.stage, + retry: spec.retry, + provisional: true, + ...(observed === undefined ? {} : { observed_generation: observed }), + }; +} + +function parseData(value: unknown): TargetInspectData | null { + if ( + !isRecord(value) || + value.schema !== TARGET_INSPECT_SCHEMA || + value.provisional !== true || + value.authority !== "none" || + typeof value.observed_at !== "string" + ) { + return null; + } + const selector = parseSelectorEcho(value.selector); + const project = parseProject(value.project); + const checkout = parseCheckout(value.checkout); + const instance = parseInstance(value.instance); + const service = parseService(value.service); + const services = parseStringList(value.services); + const env = parseEnv(value.env); + const generation = parseGeneration(value.generation); + const runtime = value.runtime; + if ( + !( + selector && + project && + checkout && + instance && + service !== undefined && + services && + env && + generation && + isRecord(runtime) && + runtime.observed === false && + runtime.reason === "not_probed" + ) + ) { + return null; + } + return { + schema: TARGET_INSPECT_SCHEMA, + provisional: true, + observed_at: value.observed_at, + authority: "none", + selector, + project, + checkout, + instance, + service, + services, + env, + generation, + runtime: { observed: false, reason: "not_probed" }, + }; +} + +function parseSelectorEcho( + value: unknown +): TargetInspectData["selector"] | null { + if ( + !isRecord(value) || + (value.kind !== "project" && + value.kind !== "path" && + value.kind !== "cwd") || + typeof value.branch_explicit !== "boolean" || + typeof value.env_explicit !== "boolean" + ) { + return null; + } + const expected = value.expected_generation; + if ( + expected !== null && + !(typeof expected === "string" && TARGET_GENERATION_PATTERN.test(expected)) + ) { + return null; + } + return { + kind: value.kind, + branch_explicit: value.branch_explicit, + env_explicit: value.env_explicit, + expected_generation: expected, + }; +} + +function parseProject(value: unknown): TargetInspectData["project"] | null { + if ( + !isRecord(value) || + typeof value.name !== "string" || + typeof value.project_dir !== "string" + ) { + return null; + } + const registration = value.registration; + if (registration === null) { + return { + name: value.name, + registration: null, + project_dir: value.project_dir, + }; + } + if ( + !isRecord(registration) || + typeof registration.id !== "string" || + typeof registration.name !== "string" || + (registration.match !== "root" && registration.match !== "worktree") + ) { + return null; + } + return { + name: value.name, + registration: { + id: registration.id, + name: registration.name, + match: registration.match, + }, + project_dir: value.project_dir, + }; +} + +function parseCheckout(value: unknown): TargetInspectData["checkout"] | null { + if ( + !isRecord(value) || + typeof value.root !== "string" || + (value.kind !== "primary" && + value.kind !== "linked" && + value.kind !== "not_git") || + !(value.git_branch === null || typeof value.git_branch === "string") + ) { + return null; + } + return { root: value.root, kind: value.kind, git_branch: value.git_branch }; +} + +function parseInstance(value: unknown): TargetInspectData["instance"] | null { + if ( + !isRecord(value) || + typeof value.compose_project !== "string" || + (value.source !== "explicit" && + value.source !== "worktree" && + value.source !== "default") || + !(value.branch === null || typeof value.branch === "string") + ) { + return null; + } + return { + branch: value.branch, + source: value.source, + compose_project: value.compose_project, + }; +} + +/** undefined means invalid; null is a valid "no service selected". */ +function parseService( + value: unknown +): TargetInspectData["service"] | undefined { + if (value === null) { + return null; + } + if (!isRecord(value) || typeof value.name !== "string") { + return undefined; + } + return { name: value.name }; +} + +function parseStringList(value: unknown): readonly string[] | null { + if (!Array.isArray(value)) { + return null; + } + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + return null; + } + out.push(entry); + } + return out; +} + +function parseEnv(value: unknown): TargetInspectData["env"] | null { + if ( + !isRecord(value) || + (value.source !== "explicit" && + value.source !== "project_default" && + value.source !== "base") || + !(value.overlay === null || typeof value.overlay === "string") + ) { + return null; + } + return { overlay: value.overlay, source: value.source }; +} + +function parseGeneration( + value: unknown +): TargetInspectData["generation"] | null { + if ( + !isRecord(value) || + typeof value.id !== "string" || + !TARGET_GENERATION_PATTERN.test(value.id) || + value.scope !== "hack_config" || + !Array.isArray(value.inputs) + ) { + return null; + } + const inputs: { file: string; present: boolean }[] = []; + for (const entry of value.inputs) { + if ( + !isRecord(entry) || + typeof entry.file !== "string" || + typeof entry.present !== "boolean" + ) { + return null; + } + inputs.push({ file: entry.file, present: entry.present }); + } + return { id: value.id, scope: "hack_config", inputs }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2e6727362..f35ee8c4e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -36,6 +36,10 @@ import { resolveRegisteredProjectByName, selectRegisteredProjectByName, } from "../lib/projects-registry.ts"; +import { + parseTargetInspectEnvelope, + TARGET_GENERATION_PATTERN, +} from "../lib/target-inspect.ts"; import { readLinesFromStream } from "../ui/lines.ts"; import type { McpCommandAdmission } from "./command-admission.ts"; import { superviseMcpCommand } from "./command-lifetime.ts"; @@ -202,6 +206,8 @@ const COMPOSE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; /** Same rule for branch names, which may also carry `/` (the CLI slugs it). */ const BRANCH_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_./-]*$/; const IDENTIFIER_MAX_LENGTH = 253; +/** Longest path accepted by the target inspector tool. */ +const TARGET_PATH_MAX_LENGTH = 4096; const READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({ readOnlyHint: true, @@ -1214,6 +1220,53 @@ function registerTools(opts: { }, }); + register({ + name: "hack.project.target", + title: "Inspect project target", + description: + "Resolve the exact project, checkout, runtime instance, optional service, env overlay and config generation that a command would target (hack project target --json). Read-only: no engine probe, registry write or repository change. Selection is resolved by the CLI, so refusals match the CLI exactly; data is the validated CLI envelope. Names and fields are provisional.", + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + projectName: z + .string() + .max(IDENTIFIER_MAX_LENGTH) + .describe("Registered project name (exclusive with path)") + .optional(), + path: z + .string() + .max(TARGET_PATH_MAX_LENGTH) + .describe( + "Checkout path, absolute or relative (exclusive with projectName)" + ) + .optional(), + ...branchInput, + service: composeIdentifierInput("Compose service to resolve").optional(), + env: composeIdentifierInput( + "Env overlay name; 'base' or 'default' selects base layers only" + ).optional(), + expectGeneration: z + .string() + .regex(TARGET_GENERATION_PATTERN) + .describe("Refuse unless the config generation still equals this id") + .optional(), + }, + handler: async (input, extra) => { + const result = await runHackCommand({ + context: opts.context, + signal: extra.signal, + tool: "hack.project.target", + args: buildTargetInspectArgs(input), + }); + + // `data` replaces stdout: it is the same envelope, re-validated + // against the provisional contract (null when it does not match). + return buildToolResult({ + result: { ...result, stdout: "" }, + data: parseTargetInspectEnvelope(parseJson(result.stdout)), + }); + }, + }); + installTypedToolDispatch({ server: opts.server, tools }); } @@ -1548,6 +1601,36 @@ export function buildDoctorArgs(opts: { } /** `hack env explain --json` argv; the validated key follows `--`. */ +/** + * `hack project target --json` argv. Project selection is passed through + * unresolved (both selectors included when both are given) so the CLI owns + * every resolution refusal, including `conflicting_selectors`. + */ +export function buildTargetInspectArgs(opts: { + readonly projectName?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}): string[] { + return [ + "project", + "target", + "--json", + ...(opts.projectName === undefined + ? [] + : [`--project=${opts.projectName}`]), + ...(opts.path === undefined ? [] : [`--path=${opts.path}`]), + ...(opts.branch === undefined ? [] : [`--branch=${opts.branch}`]), + ...(opts.service === undefined ? [] : [`--service=${opts.service}`]), + ...(opts.env === undefined ? [] : [`--env=${opts.env}`]), + ...(opts.expectGeneration === undefined + ? [] + : [`--expect-generation=${opts.expectGeneration}`]), + ]; +} + export function buildEnvExplainArgs(opts: { readonly projectArgs: readonly string[]; readonly env?: string; diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 29314f5b1..aeeb898b6 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -813,6 +813,11 @@ test("tools/list publishes names, annotations and required inputs for every tool annotations: { title: "List project instances", ...READ_ONLY }, required: [], }, + { + name: "hack.project.target", + annotations: { title: "Inspect project target", ...READ_ONLY }, + required: [], + }, ]); const run = listed.tools.find((tool) => tool.name === "hack.project.run"); expect(run?.inputSchema.properties?.timeoutMs).toMatchObject({ diff --git a/tests/project-target-inspect.test.ts b/tests/project-target-inspect.test.ts new file mode 100644 index 000000000..a7fe567ee --- /dev/null +++ b/tests/project-target-inspect.test.ts @@ -0,0 +1,654 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { + inspectTarget, + parseTargetInspectEnvelope, + TARGET_REFUSALS, + type TargetInspectEnvelope, + targetRefusalEnvelope, +} from "../src/lib/target-inspect.ts"; +import { buildTargetInspectArgs, createMcpServer } from "../src/mcp/server.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +// --------------------------------------------------------------------------- +// HACK-1144 / WU31: paired CLI and MCP fixtures for the read-only target +// inspector. Every case runs the source CLI directly AND through the MCP tool +// (whose child is the same source CLI), and both envelopes must be identical +// apart from the observation time. The fixture is a private temp root: its own +// HOME, HACK_HOME, registry and Git repositories. No Docker/OrbStack socket, +// keychain, real home or client config is touched; the CLI child gets an +// isolated Docker client config that selects no engine. +// --------------------------------------------------------------------------- + +const CLI_ENTRY = resolve(import.meta.dir, "..", "index.ts"); +const CASE_TIMEOUT_MS = 120_000; + +type Fixture = { + readonly root: string; + readonly state: string; + readonly alpha: string; + readonly beta: string; + readonly client: Client; + readonly cliEnv: Record; +}; + +let fixture: Fixture; + +function gitEnv(home: string): Record { + return { + PATH: process.env.PATH ?? "", + HOME: home, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }; +} + +function runGit(opts: { + readonly cwd: string; + readonly home: string; + readonly args: readonly string[]; +}): string { + const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: gitEnv(opts.home), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error( + `git ${opts.args.join(" ")} failed: ${result.stderr.toString()}` + ); + } + return result.stdout.toString(); +} + +async function writeProject(opts: { + readonly repo: string; + readonly name: string; + readonly services: readonly string[]; +}): Promise { + await mkdir(join(opts.repo, ".hack"), { recursive: true }); + const services = opts.services + .map((service) => ` ${service}:\n image: busybox\n`) + .join(""); + await writeFile( + join(opts.repo, ".hack", "docker-compose.yml"), + `services:\n${services}` + ); + await writeFile( + join(opts.repo, ".hack", "hack.config.json"), + `${JSON.stringify({ name: opts.name })}\n` + ); +} + +function registryRow(opts: { + readonly id: string; + readonly name: string; + readonly repo: string; + readonly worktrees?: readonly { path: string; branch: string | null }[]; +}) { + return { + id: opts.id, + name: opts.name, + repoRoot: opts.repo, + projectDirName: ".hack", + projectDir: join(opts.repo, ".hack"), + createdAt: "2026-01-01T00:00:00.000Z", + ...(opts.worktrees + ? { + worktrees: opts.worktrees.map((entry) => ({ + ...entry, + lastSeenAt: "2026-01-01T00:00:00.000Z", + })), + } + : {}), + }; +} + +beforeAll(async () => { + // realpath: the macOS tmpdir is a symlink; the registry stores real roots. + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-target-inspect-")) + ); + const home = join(root, "user-home"); + const state = join(root, "state"); + await mkdir(home, { recursive: true }); + await mkdir(state, { recursive: true }); + + // Project alpha: a Git checkout with two linked worktrees. + const alpha = join(root, "alpha"); + await writeProject({ repo: alpha, name: "alpha", services: ["api", "web"] }); + runGit({ cwd: alpha, home, args: ["init", "-q", "-b", "main"] }); + runGit({ cwd: alpha, home, args: ["add", "-A"] }); + runGit({ cwd: alpha, home, args: ["commit", "-q", "-m", "fixture"] }); + runGit({ + cwd: alpha, + home, + args: ["worktree", "add", "-q", "-b", "feature/login", "../alpha-login"], + }); + runGit({ + cwd: alpha, + home, + args: ["worktree", "add", "-q", "--detach", "../alpha-detached"], + }); + + // Project beta: an independent, non-Git project. + const beta = join(root, "beta"); + await writeProject({ repo: beta, name: "beta", services: ["db"] }); + + // Two independent checkouts that share a name (never merged or renamed). + await writeProject({ + repo: join(root, "gamma-one"), + name: "gamma", + services: ["api"], + }); + await writeProject({ + repo: join(root, "gamma-two"), + name: "gamma", + services: ["api"], + }); + + // A registration whose checkout was moved away. + const moved = join(root, "moved"); + await writeProject({ repo: moved, name: "moved", services: ["api"] }); + + await mkdir(join(root, "outside"), { recursive: true }); + + await writeFile( + join(state, "projects.json"), + `${JSON.stringify({ + version: 1, + projects: [ + registryRow({ + id: "alpha", + name: "alpha", + repo: alpha, + worktrees: [ + { path: join(root, "alpha-login"), branch: "feature/login" }, + ], + }), + registryRow({ id: "beta", name: "beta", repo: beta }), + registryRow({ + id: "gamma-1", + name: "gamma", + repo: join(root, "gamma-one"), + }), + registryRow({ + id: "gamma-2", + name: "gamma", + repo: join(root, "gamma-two"), + }), + registryRow({ id: "moved", name: "moved", repo: moved }), + ], + })}\n` + ); + await rm(moved, { recursive: true, force: true }); + + const wrapper = join(root, "hack-source-cli"); + await writeFile( + wrapper, + `#!/bin/sh\nexec '${process.execPath}' '${CLI_ENTRY}' "$@"\n` + ); + await chmod(wrapper, 0o755); + + const cliEnv = privateCliEnv({ root, env: { HACK_HOME: state } }); + const mcpEnv = privateCliEnv({ + root, + env: { HACK_HOME: state, HACK_MCP_COMMAND: wrapper }, + }); + const server = createMcpServer({ cwd: alpha, env: mcpEnv }); + const client = new Client({ name: "hack-target-inspect", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + fixture = { root, state, alpha, beta, client, cliEnv }; +}, CASE_TIMEOUT_MS); + +afterAll(async () => { + await fixture?.client.close(); + if (fixture?.root) { + await rm(fixture.root, { recursive: true, force: true }); + } +}, CASE_TIMEOUT_MS); + +type ToolInput = { + readonly projectName?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}; + +type McpPayload = { + readonly ok: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly command: string; + readonly spawned?: false; + readonly data?: unknown; + readonly error?: { readonly reason: string }; +}; + +async function runCli(input: ToolInput): Promise<{ + readonly exitCode: number; + readonly envelope: unknown; +}> { + const proc = Bun.spawn( + [process.execPath, CLI_ENTRY, ...buildTargetInspectArgs(input)], + { + cwd: fixture.alpha, + env: fixture.cliEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + return { exitCode, envelope: JSON.parse(stdout) as unknown }; +} + +async function runMcp(input: ToolInput): Promise { + const result = await fixture.client.callTool({ + name: "hack.project.target", + arguments: input, + }); + return result.structuredContent as McpPayload; +} + +/** Drop the only intentionally nondeterministic field. */ +function comparable(envelope: unknown): unknown { + const parsed = parseTargetInspectEnvelope(envelope); + if (!parsed) { + return { invalid: envelope }; + } + if (!parsed.ok) { + return parsed; + } + const { observed_at: _observedAt, ...data } = parsed.data; + return { ok: true, data }; +} + +/** Run one case on both surfaces and require the same answer. */ +async function inspectBoth(input: ToolInput): Promise { + const cli = await runCli(input); + const mcp = await runMcp(input); + const parsed = parseTargetInspectEnvelope(cli.envelope); + if (!parsed) { + throw new Error(`CLI envelope off contract: ${JSON.stringify(cli)}`); + } + expect(comparable(mcp.data)).toEqual(comparable(cli.envelope)); + expect(mcp.ok).toBe(parsed.ok); + expect(mcp.exitCode).toBe(cli.exitCode); + expect(cli.exitCode).toBe(parsed.ok ? 0 : 1); + // The MCP tool never pre-resolves selection: every case spawned the CLI. + expect(mcp.spawned).toBeUndefined(); + expect(mcp.stdout).toBe(""); + return parsed; +} + +function expectRefusal( + envelope: TargetInspectEnvelope, + reason: keyof typeof TARGET_REFUSALS +): void { + expect(envelope).toEqual(targetRefusalEnvelope({ reason })); +} + +function okData(envelope: TargetInspectEnvelope) { + if (!envelope.ok) { + throw new Error(`expected a target, got ${JSON.stringify(envelope)}`); + } + return envelope.data; +} + +describe("paired CLI/MCP target fixtures", () => { + test( + "the session checkout resolves to the primary default instance", + async () => { + const data = okData(await inspectBoth({})); + expect(data.selector).toEqual({ + kind: "cwd", + branch_explicit: false, + env_explicit: false, + expected_generation: null, + }); + expect(data.project).toEqual({ + name: "alpha", + registration: { id: "alpha", name: "alpha", match: "root" }, + project_dir: join(fixture.alpha, ".hack"), + }); + expect(data.checkout).toEqual({ + root: fixture.alpha, + kind: "primary", + git_branch: "main", + }); + expect(data.instance).toEqual({ + branch: null, + source: "default", + compose_project: "alpha", + }); + expect(data.services).toEqual(["api", "web"]); + expect(data.service).toBeNull(); + expect(data.authority).toBe("none"); + expect(data.runtime).toEqual({ observed: false, reason: "not_probed" }); + }, + CASE_TIMEOUT_MS + ); + + test( + "a second project is selected by name without leaking the first", + async () => { + const data = okData(await inspectBoth({ projectName: "beta" })); + expect(data.selector.kind).toBe("project"); + expect(data.project.registration).toEqual({ + id: "beta", + name: "beta", + match: "root", + }); + expect(data.checkout).toEqual({ + root: fixture.beta, + kind: "not_git", + git_branch: null, + }); + expect(data.instance.compose_project).toBe("beta"); + expect(data.services).toEqual(["db"]); + }, + CASE_TIMEOUT_MS + ); + + test( + "a linked worktree selects its own branch instance, not the primary", + async () => { + const data = okData( + await inspectBoth({ path: join(fixture.root, "alpha-login") }) + ); + expect(data.project.registration).toEqual({ + id: "alpha", + name: "alpha", + match: "worktree", + }); + expect(data.checkout).toEqual({ + root: join(fixture.root, "alpha-login"), + kind: "linked", + git_branch: "feature/login", + }); + expect(data.instance).toEqual({ + branch: "feature-login", + source: "worktree", + compose_project: "alpha--feature-login", + }); + }, + CASE_TIMEOUT_MS + ); + + test( + "an explicit instance wins and the Git branch is reported unchanged", + async () => { + const data = okData( + await inspectBoth({ + path: join(fixture.root, "alpha-login"), + branch: "review", + service: "api", + }) + ); + expect(data.selector.branch_explicit).toBe(true); + expect(data.checkout.git_branch).toBe("feature/login"); + expect(data.instance).toEqual({ + branch: "review", + source: "explicit", + compose_project: "alpha--review", + }); + expect(data.service).toEqual({ name: "api" }); + const branches = runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["branch", "--list", "--format=%(refname:short)"], + }); + expect(branches.trim().split("\n").sort()).toEqual([ + "feature/login", + "main", + ]); + }, + CASE_TIMEOUT_MS + ); + + test( + "conflicting selectors are refused identically", + async () => { + expectRefusal( + await inspectBoth({ projectName: "alpha", path: fixture.beta }), + "conflicting_selectors" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "closed project, instance and service refusals agree on both surfaces", + async () => { + expectRefusal( + await inspectBoth({ projectName: "gamma" }), + "ambiguous_project" + ); + expectRefusal( + await inspectBoth({ projectName: "moved" }), + "registered_root_missing" + ); + expectRefusal( + await inspectBoth({ projectName: "zeta" }), + "project_not_found" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "alpha-detached") }), + "detached_checkout" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "outside") }), + "no_project_context" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "absent") }), + "path_not_found" + ); + expectRefusal( + await inspectBoth({ projectName: "alpha", service: "nope" }), + "service_not_found" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "a stale generation is refused; source edits keep the generation", + async () => { + const first = okData(await inspectBoth({ projectName: "beta" })); + const generation = first.generation.id; + + // An ordinary source edit does not invalidate the target. + await writeFile(join(fixture.beta, "app.ts"), "export const x = 1;\n"); + const unchanged = okData( + await inspectBoth({ projectName: "beta", expectGeneration: generation }) + ); + expect(unchanged.generation.id).toBe(generation); + expect(unchanged.selector.expected_generation).toBe(generation); + + // A Hack config edit does. + await writeFile( + join(fixture.beta, ".hack", "hack.config.json"), + `${JSON.stringify({ name: "beta", dev_host: "beta.hack" })}\n` + ); + const stale = await inspectBoth({ + projectName: "beta", + expectGeneration: generation, + }); + const current = okData(await inspectBoth({ projectName: "beta" })); + expect(current.generation.id).not.toBe(generation); + expect(stale).toEqual( + targetRefusalEnvelope({ + reason: "stale_generation", + observedGeneration: current.generation.id, + }) + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "both surfaces refuse a malformed instance selector before resolution", + async () => { + const cli = await runCli({ branch: "-x" }); + expect(cli.exitCode).toBe(1); + expect(cli.envelope).toEqual( + targetRefusalEnvelope({ reason: "invalid_selector" }) + ); + // MCP validates the same grammar at its input boundary: no child runs. + const mcp = await runMcp({ branch: "-x" }); + expect(mcp.ok).toBe(false); + expect(mcp.spawned).toBe(false); + expect(mcp.error?.reason).toBe("invalid_arguments"); + }, + CASE_TIMEOUT_MS + ); + + test( + "inspection writes nothing: no registry, state or Git change", + async () => { + const registryPath = join(fixture.state, "projects.json"); + const before = await readFile(registryPath, "utf8"); + const beforeStat = await stat(registryPath); + const worktreesBefore = runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["worktree", "list", "--porcelain"], + }); + await runCli({ projectName: "alpha", branch: "fresh-instance" }); + await runCli({ path: join(fixture.root, "alpha-login") }); + expect(await readFile(registryPath, "utf8")).toBe(before); + expect((await stat(registryPath)).mtimeMs).toBe(beforeStat.mtimeMs); + // Only the registry and the MCP audit log exist in the state dir. + expect((await readdir(fixture.state)).sort()).toEqual([ + "mcp-audit.log", + "projects.json", + ]); + expect(await readdir(join(fixture.alpha, ".hack"))).toEqual( + expect.not.arrayContaining([".branch", ".internal"]) + ); + expect( + runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["worktree", "list", "--porcelain"], + }) + ).toBe(worktreesBefore); + }, + CASE_TIMEOUT_MS + ); +}); + +describe("target inspector domain", () => { + test("a config change during the read is refused, not snapshotted", async () => { + const result = await inspectTarget({ + cwd: fixture.root, + registryPath: join(fixture.state, "projects.json"), + selector: { path: fixture.beta }, + afterResolve: async () => { + await writeFile( + join(fixture.beta, ".hack", "docker-compose.yml"), + "services:\n db:\n image: postgres\n" + ); + }, + }); + expect(result).toEqual( + targetRefusalEnvelope({ reason: "generation_changed_during_read" }) + ); + }); + + test("selectors are validated before any project lookup", async () => { + const registryPath = join(fixture.state, "projects.json"); + for (const selector of [ + { project: "a=b" }, + { branch: "--all" }, + { service: "a b" }, + { env: "X=secret" }, + { expectGeneration: "cfg-v0:zz" }, + { path: "" }, + ]) { + expect( + await inspectTarget({ cwd: fixture.root, registryPath, selector }) + ).toEqual(targetRefusalEnvelope({ reason: "invalid_selector" })); + } + }); + + test("refusal text never repeats caller input", () => { + for (const spec of Object.values(TARGET_REFUSALS)) { + expect(spec.message).not.toMatch( + /gamma|alpha|beta|zeta|secret|tmp|feature/ + ); + } + }); + + test("the adapter validator rejects drifted or foreign envelopes", () => { + const refusal = targetRefusalEnvelope({ reason: "service_not_found" }); + expect(parseTargetInspectEnvelope(refusal)).toEqual(refusal); + const drift = (patch: Record) => ({ + ok: false, + error: { + ...TARGET_REFUSALS.service_not_found, + detail: { + reason: "service_not_found", + stage: "service", + retry: "fix_selector", + provisional: true, + ...patch, + }, + }, + }); + expect(parseTargetInspectEnvelope(drift({ reason: "other" }))).toBeNull(); + expect( + parseTargetInspectEnvelope(drift({ retry: "reinspect" })) + ).toBeNull(); + expect( + parseTargetInspectEnvelope( + drift({ + observed_generation: "cfg-v0:0123456789abcdef0123456789abcdef", + }) + ) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ok: false, + error: { code: "E_NOT_A_CODE", message: "x" }, + }) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ok: true, + data: { schema: "hack.target.v1" }, + }) + ).toBeNull(); + }); +}); From bc49c11a32535f7715ecf7cab6569ca146e867d1 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 12:18:00 -0400 Subject: [PATCH 04/11] fix(agents): refuse malformed Compose files and cover target selection gaps The read-only target inspector now refuses a Compose file that is not YAML, whose top level is not a mapping, or whose services key is not a mapping, as compose_parse_failed, instead of reporting a target with no services. Paired CLI/MCP fixtures now cover env selection (project default, explicit overlay, base), explicit instance slugging (Feature/X -> --feature-x), two registrations owning one checkout, config_parse_failed, compose_parse_failed and generation_input_too_large. A stubbed CLI proves the MCP tool re-validates the envelope, and the validator's code/message consistency check has direct tests. Restores the buildEnvExplainArgs JSDoc. Refs HACK-1144 Co-Authored-By: Claude Opus 5.5 --- docs/reference/ax-target-result-contract.md | 13 +- src/lib/target-inspect.ts | 51 ++++- src/mcp/server.ts | 2 +- tests/project-target-inspect.test.ts | 230 +++++++++++++++++++- 4 files changed, 288 insertions(+), 8 deletions(-) diff --git a/docs/reference/ax-target-result-contract.md b/docs/reference/ax-target-result-contract.md index b0b7c11ba..2fc94c72f 100644 --- a/docs/reference/ax-target-result-contract.md +++ b/docs/reference/ax-target-result-contract.md @@ -122,8 +122,9 @@ on a refusal. | `project.registration` | The registration that owns this exact checkout root (`match: root`) or lists it as a linked worktree (`match: worktree`); `null` when unregistered | | `checkout.kind` | `primary`, `linked` (a linked Git worktree) or `not_git` | | `instance.source` | `explicit` (`--branch`), `worktree` (derived from a linked worktree's branch) or `default` | +| `instance.branch` | The instance slug. An explicit `--branch` is slugged like `hack up --branch` (`Feature/X` becomes `feature-x`) | | `instance.compose_project` | The Compose project name commands use for this instance (`--` for a branch instance) | -| `services` | Service names declared in the Compose file, sorted | +| `services` | Service names declared in the Compose file, sorted; `[]` when the file is absent or has no `services` key. A Compose file that is not YAML, whose top level is not a mapping, or whose `services` is not a mapping is refused as `compose_parse_failed` | | `env.source` | `explicit`, `project_default` (configured default overlay) or `base`; selection only, overlay existence is not checked | | `generation.id` | `cfg-v0:` plus 32 hex characters; pass it back as `--expect-generation` | @@ -147,6 +148,7 @@ fixed text that never repeats caller input, paths or registry values. | `no_project_context` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | | `project_inputs_unsupported` | `E_CONFIG_INVALID` | `project` | `fix_project` | | `config_parse_failed` | `E_CONFIG_PARSE` | `project` | `fix_project` | +| `compose_parse_failed` | `E_CONFIG_PARSE` | `project` | `fix_project` | | `detached_checkout` | `E_USAGE` | `instance` | `fix_selector` | | `service_not_found` | `E_SERVICE_NOT_FOUND` | `service` | `fix_selector` | | `stale_generation` | `E_USAGE` | `generation` | `reinspect` | @@ -183,15 +185,20 @@ WU21). The inspector itself only produces snapshots. | --- | --- | --- | --- | | Two projects, selection by name | yes | yes | paired fixture | | Two branches (linked worktree default, explicit instance) | yes | yes | paired fixture | +| Explicit instance slug (`Feature/X` gives `--feature-x`) | yes | yes | paired fixture | +| Env selection: project default, explicit overlay, `base` | yes | yes | paired fixture | | Conflicting selectors | `conflicting_selectors` | same | paired fixture | | Stale generation | `stale_generation` | same | paired fixture | | Source edit keeps generation | yes | yes | paired fixture | | Ambiguous equal names, moved checkout, unknown project | closed refusals | same | paired fixture | +| Two registrations owning one checkout | `ambiguous_project` | same | paired fixture | +| Malformed Hack config or Compose file, oversized input | `config_parse_failed`, `compose_parse_failed`, `generation_input_too_large` | same | paired fixture | | Detached linked worktree, no project, missing path | closed refusals | same | paired fixture | | Unknown service | `service_not_found` | same | paired fixture | | Config changed during read | `generation_changed_during_read` | same code path | domain test | | No registry, state or Git writes | yes | yes (audit log only) | paired fixture | | Malformed selector | `invalid_selector` / parser `E_USAGE` | `invalid_arguments`, not spawned | paired fixture | +| Drifted CLI output | n/a | `data: null`; foreign fields dropped | stubbed CLI fixture | The fixtures live in `tests/project-target-inspect.test.ts`. @@ -205,6 +212,10 @@ The fixtures live in `tests/project-target-inspect.test.ts`. inspector has no runtime probe yet. - **Env overlays.** Selection only; overlay existence and resolved keys are not checked. Use `hack env list` or `hack.project.env.keys` for key metadata. +- **Compose validity.** The inspector refuses a Compose file it cannot parse + as a YAML mapping, but it does not run `docker compose config`, so a file + that parses but that Compose would reject (for example an invalid service + definition) is still reported as a target. - **Generation scope.** Worktree-local overrides, env files and native `hack.project.json` inputs are not part of the generation. Native projects are refused as `project_inputs_unsupported`. diff --git a/src/lib/target-inspect.ts b/src/lib/target-inspect.ts index 941feb77b..75931fef1 100644 --- a/src/lib/target-inspect.ts +++ b/src/lib/target-inspect.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { readFile, realpath, stat } from "node:fs/promises"; import { resolve } from "node:path"; +import { YAML } from "bun"; import { PROJECT_COMPOSE_FILENAME, PROJECT_CONFIG_FILENAME, @@ -29,7 +30,6 @@ import { sanitizeBranchSlug, sanitizeProjectSlug, } from "./project.ts"; -import { discoverComposeServiceNames } from "./project-env-config.ts"; import { ProjectInputSelectionError } from "./project-input-selection.ts"; import { AmbiguousProjectNameError, @@ -176,6 +176,12 @@ export const TARGET_REFUSALS = Object.freeze({ retry: "fix_project", message: "The project config could not be parsed.", }, + compose_parse_failed: { + code: "E_CONFIG_PARSE", + stage: "project", + retry: "fix_project", + message: "The project's Compose file could not be parsed.", + }, detached_checkout: { code: "E_USAGE", stage: "instance", @@ -455,6 +461,7 @@ async function resolveTarget(opts: { if (cfg.parseError) { throw new TargetRefusal("config_parse_failed"); } + const services = await readComposeServiceNames(context.composeFile); const root = await canonicalPath(context.projectRoot); const registration = await findRegistrationForRoot({ @@ -485,9 +492,6 @@ async function resolveTarget(opts: { }); const instanceBranch = effective.branch; - const services = await discoverComposeServiceNames({ - composeFile: context.composeFile, - }); if (selector.service !== null && !services.includes(selector.service)) { throw new TargetRefusal("service_not_found"); } @@ -696,6 +700,45 @@ async function resolveEnvSelection(opts: { : { overlay: null, source: "base" }; } +/** + * Service names declared in the Compose file, sorted. An absent file declares + * none, as does an empty `services:` key. A file that is present but is not YAML, whose top level is not a + * mapping, or whose `services` is not a mapping is refused as + * `compose_parse_failed`: lifecycle commands would fail on it, so it must not + * be reported as a target with no services. The generation read has already + * bounded the file's size. + */ +async function readComposeServiceNames( + composeFile: string +): Promise { + let text: string; + try { + text = await readFile(composeFile, "utf8"); + } catch (error: unknown) { + if (isMissing(error)) { + return []; + } + throw error; + } + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch { + throw new TargetRefusal("compose_parse_failed"); + } + if (!isRecord(parsed)) { + throw new TargetRefusal("compose_parse_failed"); + } + const services = parsed.services; + if (services === undefined || services === null) { + return []; + } + if (!isRecord(services)) { + throw new TargetRefusal("compose_parse_failed"); + } + return Object.keys(services).sort((left, right) => left.localeCompare(right)); +} + type Generation = { readonly id: string; readonly inputs: TargetInspectData["generation"]["inputs"]; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f35ee8c4e..4526aa25b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1600,7 +1600,6 @@ export function buildDoctorArgs(opts: { ]; } -/** `hack env explain --json` argv; the validated key follows `--`. */ /** * `hack project target --json` argv. Project selection is passed through * unresolved (both selectors included when both are given) so the CLI owns @@ -1631,6 +1630,7 @@ export function buildTargetInspectArgs(opts: { ]; } +/** `hack env explain --json` argv; the validated key follows `--`. */ export function buildEnvExplainArgs(opts: { readonly projectArgs: readonly string[]; readonly env?: string; diff --git a/tests/project-target-inspect.test.ts b/tests/project-target-inspect.test.ts index a7fe567ee..3446e8d16 100644 --- a/tests/project-target-inspect.test.ts +++ b/tests/project-target-inspect.test.ts @@ -45,6 +45,8 @@ type Fixture = { readonly alpha: string; readonly beta: string; readonly client: Client; + /** MCP client whose CLI command is a stub that prints `fake-envelope.json`. */ + readonly stubClient: Client; readonly cliEnv: Record; }; @@ -85,6 +87,7 @@ async function writeProject(opts: { readonly repo: string; readonly name: string; readonly services: readonly string[]; + readonly config?: Readonly>; }): Promise { await mkdir(join(opts.repo, ".hack"), { recursive: true }); const services = opts.services @@ -96,10 +99,13 @@ async function writeProject(opts: { ); await writeFile( join(opts.repo, ".hack", "hack.config.json"), - `${JSON.stringify({ name: opts.name })}\n` + `${JSON.stringify({ name: opts.name, ...opts.config })}\n` ); } +/** Larger than the inspector's 4 MiB bound on one generation input. */ +const OVERSIZED_INPUT_BYTES = 4 * 1024 * 1024 + 1024; + function registryRow(opts: { readonly id: string; readonly name: string; @@ -171,6 +177,46 @@ beforeAll(async () => { const moved = join(root, "moved"); await writeProject({ repo: moved, name: "moved", services: ["api"] }); + // A project with a configured default env overlay. + const delta = join(root, "delta"); + await writeProject({ + repo: delta, + name: "delta", + services: ["api"], + config: { env: { default_overlay: "staging" } }, + }); + + // One checkout owned by two registrations (a corrupt registry). + const shared = join(root, "shared"); + await writeProject({ repo: shared, name: "shared", services: ["api"] }); + + // Unregistered projects with malformed or oversized Hack inputs. + const brokenConfig = join(root, "broken-config"); + await writeProject({ repo: brokenConfig, name: "broken", services: ["api"] }); + await writeFile(join(brokenConfig, ".hack", "hack.config.json"), '{"name":'); + const brokenCompose = join(root, "broken-compose"); + await writeProject({ + repo: brokenCompose, + name: "broken", + services: ["api"], + }); + await writeFile( + join(brokenCompose, ".hack", "docker-compose.yml"), + "services: [unclosed\n" + ); + const listCompose = join(root, "list-compose"); + await writeProject({ repo: listCompose, name: "listed", services: ["api"] }); + await writeFile( + join(listCompose, ".hack", "docker-compose.yml"), + "services:\n - api\n" + ); + const oversized = join(root, "oversized"); + await writeProject({ repo: oversized, name: "oversized", services: ["api"] }); + await writeFile( + join(oversized, ".hack", "docker-compose.yml"), + `services:\n api:\n image: busybox\n#${"x".repeat(OVERSIZED_INPUT_BYTES)}\n` + ); + await mkdir(join(root, "outside"), { recursive: true }); await writeFile( @@ -198,6 +244,9 @@ beforeAll(async () => { repo: join(root, "gamma-two"), }), registryRow({ id: "moved", name: "moved", repo: moved }), + registryRow({ id: "delta", name: "delta", repo: delta }), + registryRow({ id: "shared-a", name: "shared-a", repo: shared }), + registryRow({ id: "shared-b", name: "shared-b", repo: shared }), ], })}\n` ); @@ -222,11 +271,35 @@ beforeAll(async () => { await server.connect(serverTransport); await client.connect(clientTransport); - fixture = { root, state, alpha, beta, client, cliEnv }; + // A stub CLI that prints whatever envelope a test stores, so the MCP + // adapter's re-validation can be observed against drifted output. + const stub = join(root, "hack-stub-cli"); + await writeFile( + stub, + `#!/bin/sh\ncat '${join(root, "fake-envelope.json")}'\n` + ); + await chmod(stub, 0o755); + const stubState = join(root, "stub-state"); + await mkdir(stubState, { recursive: true }); + const stubServer = createMcpServer({ + cwd: alpha, + env: privateCliEnv({ + root, + env: { HACK_HOME: stubState, HACK_MCP_COMMAND: stub }, + }), + }); + const stubClient = new Client({ name: "hack-target-stub", version: "1" }); + const [stubClientTransport, stubServerTransport] = + InMemoryTransport.createLinkedPair(); + await stubServer.connect(stubServerTransport); + await stubClient.connect(stubClientTransport); + + fixture = { root, state, alpha, beta, client, stubClient, cliEnv }; }, CASE_TIMEOUT_MS); afterAll(async () => { await fixture?.client.close(); + await fixture?.stubClient.close(); if (fixture?.root) { await rm(fixture.root, { recursive: true, force: true }); } @@ -353,6 +426,7 @@ describe("paired CLI/MCP target fixtures", () => { }); expect(data.services).toEqual(["api", "web"]); expect(data.service).toBeNull(); + expect(data.env).toEqual({ overlay: null, source: "base" }); expect(data.authority).toBe("none"); expect(data.runtime).toEqual({ observed: false, reason: "not_probed" }); }, @@ -436,6 +510,93 @@ describe("paired CLI/MCP target fixtures", () => { CASE_TIMEOUT_MS ); + test( + "an explicit instance is slugged like 'hack up --branch'", + async () => { + // `hack up --branch Feature/X` targets --feature-x. + const data = okData( + await inspectBoth({ projectName: "alpha", branch: "Feature/X" }) + ); + expect(data.instance).toEqual({ + branch: "feature-x", + source: "explicit", + compose_project: "alpha--feature-x", + }); + expect(data.checkout.git_branch).toBe("main"); + }, + CASE_TIMEOUT_MS + ); + + test( + "env selection: project default, explicit overlay and base agree", + async () => { + const byDefault = okData(await inspectBoth({ projectName: "delta" })); + expect(byDefault.selector.env_explicit).toBe(false); + expect(byDefault.env).toEqual({ + overlay: "staging", + source: "project_default", + }); + + const explicit = okData( + await inspectBoth({ projectName: "delta", env: "preview" }) + ); + expect(explicit.selector.env_explicit).toBe(true); + expect(explicit.env).toEqual({ overlay: "preview", source: "explicit" }); + + const base = okData( + await inspectBoth({ projectName: "delta", env: "base" }) + ); + expect(base.selector.env_explicit).toBe(true); + expect(base.env).toEqual({ overlay: null, source: "base" }); + }, + CASE_TIMEOUT_MS + ); + + test( + "malformed or oversized Hack inputs are refused, not reported empty", + async () => { + expectRefusal( + await inspectBoth({ path: join(fixture.root, "broken-config") }), + "config_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "broken-compose") }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ + path: join(fixture.root, "broken-compose"), + service: "api", + }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "list-compose") }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "oversized") }), + "generation_input_too_large" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "two registrations that own one checkout are refused as ambiguous", + async () => { + expectRefusal( + await inspectBoth({ path: join(fixture.root, "shared") }), + "ambiguous_project" + ); + expectRefusal( + await inspectBoth({ projectName: "shared-a" }), + "ambiguous_project" + ); + }, + CASE_TIMEOUT_MS + ); + test( "conflicting selectors are refused identically", async () => { @@ -569,6 +730,55 @@ describe("paired CLI/MCP target fixtures", () => { ); }); +describe("MCP adapter re-validation", () => { + async function stubbedData(envelope: unknown): Promise { + await writeFile( + join(fixture.root, "fake-envelope.json"), + JSON.stringify(envelope) + ); + const result = await fixture.stubClient.callTool({ + name: "hack.project.target", + arguments: {}, + }); + const payload = result.structuredContent as McpPayload; + expect(payload.stdout).toBe(""); + return payload.data; + } + + test( + "drifted CLI output is never passed to MCP callers", + async () => { + // Off-contract success data becomes null. + expect( + await stubbedData({ + ok: true, + data: { schema: "hack.target.v0", provisional: true, leak: "x" }, + }) + ).toBeNull(); + // Foreign fields around a valid refusal are dropped. + const refusal = targetRefusalEnvelope({ reason: "service_not_found" }); + if (refusal.ok) { + throw new Error("expected a refusal envelope"); + } + expect( + await stubbedData({ + ...refusal, + leak: "x", + error: { ...refusal.error, leak: "x" }, + }) + ).toEqual(refusal); + // A refusal whose message no longer matches its reason becomes null. + expect( + await stubbedData({ + ...refusal, + error: { ...refusal.error, message: "drifted text" }, + }) + ).toBeNull(); + }, + CASE_TIMEOUT_MS + ); +}); + describe("target inspector domain", () => { test("a config change during the read is refused, not snapshotted", async () => { const result = await inspectTarget({ @@ -644,6 +854,22 @@ describe("target inspector domain", () => { error: { code: "E_NOT_A_CODE", message: "x" }, }) ).toBeNull(); + // The message and code must be the reason's own fixed values. + if (refusal.ok) { + throw new Error("expected a refusal envelope"); + } + expect( + parseTargetInspectEnvelope({ + ...refusal, + error: { ...refusal.error, message: "The service is missing." }, + }) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ...refusal, + error: { ...refusal.error, code: "E_USAGE" }, + }) + ).toBeNull(); expect( parseTargetInspectEnvelope({ ok: true, From 711e29ddd24d7a3770517addec001532ce776b5b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 12:31:28 -0400 Subject: [PATCH 05/11] test(e2e): keep the event-cutoff replay window inside ticker lines The first hosted run replayed 50 lines, which included the older boot marker, so only 4 of the 5 counted log events were ticks. Replay 3 ticks and let 2 live ones reach the bound. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/scenarios/mcp-logs-compose.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/scenarios/mcp-logs-compose.ts b/tests/e2e/scenarios/mcp-logs-compose.ts index 1c29153bc..4ca226f7c 100644 --- a/tests/e2e/scenarios/mcp-logs-compose.ts +++ b/tests/e2e/scenarios/mcp-logs-compose.ts @@ -432,9 +432,10 @@ async function checkBoundedOutcomes(session: Session): Promise { }); // Event cutoff: exactly maxEvents log events; other events do not count. + // Three replayed ticks (the boot line is older) plus two live ones. const eventTail = await session.call("hack.project.logs.tail", { service: "ticker", - tail: 50, + tail: 3, maxEvents: 5, maxMs: 30_000, }); @@ -451,7 +452,7 @@ async function checkBoundedOutcomes(session: Session): Promise { eventTicks.length === 5 && strictlyOrdered(eventTicks) && Number(eventTail.data?.duration_ms) < 30_000, - message: `ticker tail must stop at exactly 5 log events: ${summarize(eventTail)}`, + message: `ticker tail must stop at exactly 5 log events, all ticks (${eventTicks.length} ticks): ${summarize(eventTail)}`, }); } From 889cd62ac2d7375a19257f5c7113d1614af97671 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 12:48:50 -0400 Subject: [PATCH 06/11] test(e2e): list full container IDs in the MCP logs scenario docker ps -q prints 12-character IDs, so the exact-ID check refused the ticker container before the restart step. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/scenarios/mcp-logs-compose.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/scenarios/mcp-logs-compose.ts b/tests/e2e/scenarios/mcp-logs-compose.ts index 4ca226f7c..f7364add9 100644 --- a/tests/e2e/scenarios/mcp-logs-compose.ts +++ b/tests/e2e/scenarios/mcp-logs-compose.ts @@ -800,7 +800,8 @@ async function prepareStack(opts: { await chmod(mcpCommand, 0o755); const list = async (kind: ResourceKind, filters: readonly string[]) => { - const args = kind === "container" ? ["ps", "-aq"] : [kind, "ls", "-q"]; + const args = + kind === "container" ? ["ps", "-aq", "--no-trunc"] : [kind, "ls", "-q"]; return ( await docker([ ...args, From 85fb6ffd936d2d37c4759707475673dc61bc1a17 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 12:53:55 -0400 Subject: [PATCH 07/11] fix(release): let candidate launchers select the Compose backend hack-next (the channel manager) and the bundled hack-v5 entrypoint exported HACK_RUNTIME_BACKEND=native unconditionally, and hack-next also forced the version's private HACK_HOME. Testers could not run the supported Compose backend through the channel; the M3 HACK-1124 rerun had to call the bundled hack-cli directly with a private HACK_HOME, which also hid the global Caddy/CoreDNS services and local CA. Unset or empty HACK_RUNTIME_BACKEND still selects native with the private homes. An explicit HACK_RUNTIME_BACKEND=compose now runs the verified bundle's hack-cli on Compose with the caller's own HACK_HOME and HACK_GLOBAL_CONFIG_PATH, and removes native selectors. Any other value refuses before verification or spawn. The manager runs hack-cli directly for Compose because retained bundles' hack-v5 always selects native. Both launchers set HACK_CANDIDATE_ENTRYPOINT, and `update` refuses when it is set, so a Compose launch keeps the candidate self-update refusal that the native backend marker used to provide. The released next.3-next.5 manager (e2e2bcba) is added as a reviewed predecessor with its fixture, so existing channels get backend selection through upgrade-manager. Refs HACK-1275 Co-Authored-By: Claude Opus 5.5 --- docs/guides/candidate-install.md | 65 +- docs/guides/native-candidate.md | 10 +- docs/guides/v5-dogfooding.md | 10 +- scripts/hack-v5.sh | 33 +- scripts/install-prerelease.py | 33 +- src/commands/update.ts | 9 +- .../fixtures/prerelease-manager-storage-v5.py | 971 ++++++++++++++++++ tests/native-candidate-launcher.test.ts | 187 +++- tests/python/test_prerelease_install.py | 97 +- tests/update-command.test.ts | 35 + 10 files changed, 1381 insertions(+), 69 deletions(-) create mode 100644 tests/fixtures/prerelease-manager-storage-v5.py diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 23b77109d..522f6f6b6 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -30,8 +30,10 @@ latest-version lookup or automatic updater. By default installation creates a private, current-user-owned mode-`0700` `~/.hack-next` directory. Each immutable version directory contains a complete bundle, its own `native-home`, and its own `cli-home`. An installation receipt binds -the bundle hashes and both homes' filesystem identities. Stable Homebrew `hack`, -its links, and `~/.hack` are untouched. No shell profile or PATH is edited. +the bundle hashes and both homes' filesystem identities. Installation leaves stable +Homebrew `hack`, its links, and `~/.hack` untouched; only an explicit +[Compose test](#test-both-backends) uses your own Hack home. No shell profile or +PATH is edited. Run the candidate explicitly: @@ -41,9 +43,13 @@ Run the candidate explicitly: python3 "$HOME/.hack-next/manager.py" --root "$HOME/.hack-next" status ``` -`hack-next` selects the adjacent verified native executor and the selected version's -private native and CLI homes. These settings override inherited installation -selectors, including `HACK_GLOBAL_CONFIG_PATH`. Explicit native project adaptation, +By default `hack-next` uses the native backend. It selects the adjacent verified +native executor and the selected version's private native and CLI homes. These +settings override inherited installation selectors, including `HACK_NATIVE_BINARY`, +`HACK_NATIVE_HOME`, `HACK_HOME` and `HACK_GLOBAL_CONFIG_PATH`. To run the same +verified candidate on the Compose backend, set `HACK_RUNTIME_BACKEND=compose`; see +[Test both backends](#test-both-backends). Any other non-empty value refuses before +the candidate starts. Explicit native project adaptation, dependency, AWS profile, routing, and source-sharing settings still reach the CLI. Arguments, standard input and output, exit codes, and signals reach the selected candidate. Concurrent observation and stop commands can accompany a foreground @@ -63,7 +69,54 @@ Provider setup and actual application acceptance remain separate steps. Installa does not install the provider, boot a VM, enroll a project, move volumes, or activate system DNS or trust. Follow [native candidate preparation](native-candidate.md) using the selected bundle and its `native-home`. Candidate `hack-next update` -retains the existing refusal; use the channel manager's explicit upgrade command. +retains the existing refusal on both backends; use the channel manager's explicit +upgrade command. + +## Test both backends + +The same selected candidate can run on either backend. You do not need to call the +bundled `hack-cli` directly or create a private `HACK_HOME` to test Compose. + +| Backend | Command | Hack home | Routing, DNS and TLS | +| --- | --- | --- | --- | +| Native (default) | `"$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | The version's private `cli-home` and `native-home` | The candidate's native routing, set up through [native candidate preparation](native-candidate.md) | +| Compose | `HACK_RUNTIME_BACKEND=compose "$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | Your own `HACK_HOME` (default `~/.hack`) and `HACK_GLOBAL_CONFIG_PATH` | Your existing global Caddy, CoreDNS and local CA | + +With `HACK_RUNTIME_BACKEND=compose`, `hack-next` verifies the selected bundle as +usual and then runs its `hack-cli` on the Compose backend. It keeps your Hack home, +so `https://.hack` routes, container DNS and CA trust use the global +services that `hack global install` already set up. It removes `HACK_NATIVE_BINARY` +and `HACK_NATIVE_HOME` so native selections cannot reach Compose children. The +candidate's private homes are not used. Compose `up` still uses the configurable +Compose startup budget; a cold image build can need a larger +`HACK_COMPOSE_STARTUP_TIMEOUT_MS`. + +Compose testing shares state with stable Hack: + +- The candidate reads and writes the same project registry, global configuration + and project `.hack/` files as stable `hack`. Stable v4 uses an older registry + lock protocol, so do not run stable `hack` and the candidate against the same + home at the same time. Stop one before you start the other. +- `hack-next` does not set up the shared global services for you. Install or + change them with stable `hack global` commands. +- To test Compose in isolation instead, set `HACK_HOME` to a separate private + directory. That home has no global services, so routing, container DNS and CA + trust are not tested. + +Leave `HACK_RUNTIME_BACKEND` unset, or set it to `native`, to return to the native +default. A shell that exported `HACK_RUNTIME_BACKEND=compose` for other work also +selects Compose for `hack-next`; unset it before native testing. + +Backend selection needs the current channel manager. The manager released with +`5.0.0-next.3` through `5.0.0-next.5` always selects native, and ignores +`HACK_RUNTIME_BACKEND=compose`. Run the reviewed `upgrade-manager` operation below +once to receive backend selection. This works for every retained bundle, including +`5.0.0-next.5`. Candidate CLIs from `5.0.0-next.5` and earlier refuse `update` only +on the native backend; do not run `hack-next update` on Compose with those bundles. + +The bundled `hack-v5` entrypoint follows the same rule for manual bundles: +unset selects native and requires `HACK_NATIVE_HOME`; `compose` needs no native +home and uses your own Hack home. ## Upgrade and return to a retained candidate diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index b5f309b4c..4f3f0f71c 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -110,8 +110,14 @@ export HACK_NATIVE_HOME=/absolute/private/candidate-home ``` Keep the bundle together and invoke this entrypoint by its full path (do not copy -or symlink only the script). It selects the adjacent executor even if inherited -backend variables select another binary. It preserves arguments, exit codes and +or symlink only the script). With `HACK_RUNTIME_BACKEND` unset (or `native`) it +selects the native backend and the adjacent executor, even if an inherited +`HACK_NATIVE_BINARY` selects another binary. An explicit +`HACK_RUNTIME_BACKEND=compose` runs the same bundled CLI on the Compose backend +with your own Hack home and existing global routing services. It needs no +`HACK_NATIVE_HOME` and removes the native selectors. Other values refuse with exit +code 64. The [candidate installer guide](https://github.com/hack-dance/hack/blob/next/docs/guides/candidate-install.md#test-both-backends) +describes Compose testing and its shared-state limits. It preserves arguments, exit codes and signals through `exec`, and never replaces the installed `hack`. Project-specific native adaptation, dependency and routing selections remain explicit; this entrypoint does not prepare a provider or migrate existing projects. An unversioned local diff --git a/docs/guides/v5-dogfooding.md b/docs/guides/v5-dogfooding.md index bae09b2e1..461b72311 100644 --- a/docs/guides/v5-dogfooding.md +++ b/docs/guides/v5-dogfooding.md @@ -9,8 +9,9 @@ compatibility, lower resource use or whole-product readiness. The [version-pinned candidate installer](candidate-install.md) provides `hack-next` alongside stable `hack`. It does not replace Homebrew Hack, edit PATH, convert -projects, move volumes, enable DNS or trust, or start a VM. Candidate homes and -stable state are separate. Do not alias `hack` to `hack-next` as a rollout step. +projects, move volumes, enable DNS or trust, or start a VM. On the default native +backend, candidate homes and stable state are separate; an explicit Compose test +uses your own Hack home. Do not alias `hack` to `hack-next` as a rollout step. Each candidate upgrade starts fresh native and CLI homes. Retained bundles keep their original homes; selecting another version does not migrate application data. @@ -35,6 +36,11 @@ These commands do not establish that an arbitrary v4 project can run natively. the [candidate guide](native-candidate.md), with admitted storage and an explicitly approved source share and network policy. A refused guard is a result to inspect, not permission to change host security, erase evidence or raise resource limits. + Test the backend the project will use. `hack-next` runs native by default; + `HACK_RUNTIME_BACKEND=compose hack-next` runs the same candidate on Compose with + your own Hack home, so global routing, DNS and CA trust are checked too. See + [Test both backends](candidate-install.md#test-both-backends), including why + stable `hack` must not run against the same home at the same time. 3. Exercise the project's supported command form: foreground start, readiness, status, finite logs, noninteractive exec, retaining stop and restart. Exercise one-off run only where admitted. For an authored project, check generated public diff --git a/scripts/hack-v5.sh b/scripts/hack-v5.sh index beda8d690..56089d824 100644 --- a/scripts/hack-v5.sh +++ b/scripts/hack-v5.sh @@ -1,15 +1,36 @@ #!/bin/sh -# Relocatable frontend for an explicitly selected native candidate home. +# Relocatable frontend for an explicitly selected candidate bundle. +# +# Backend selection: an unset or empty HACK_RUNTIME_BACKEND selects the native +# runtime with this bundle's adjacent executor and the explicit private +# HACK_NATIVE_HOME. An explicit HACK_RUNTIME_BACKEND=compose runs this bundle's +# CLI on the Compose backend with the caller's own Hack home (HACK_HOME, or +# ~/.hack when unset); native selectors are removed so they cannot leak into +# Compose children. Any other value refuses rather than guessing. set -eu bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) -case "${HACK_NATIVE_HOME:-}" in - /*) ;; - *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; +backend=${HACK_RUNTIME_BACKEND:-native} +case "$backend" in + native|compose) ;; + *) echo "HACK_RUNTIME_BACKEND must be native or compose (unset selects native)." >&2; exit 64 ;; esac +if [ "$backend" = native ]; then + case "${HACK_NATIVE_HOME:-}" in + /*) ;; + *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; + esac +fi if [ ! -x "$bundle/hack-cli" ] || [ ! -x "$bundle/hack-native" ]; then echo "Incomplete candidate bundle: hack-cli and hack-native are required." >&2 exit 69 fi -export HACK_RUNTIME_BACKEND=native -export HACK_NATIVE_BINARY="$bundle/hack-native" +# Candidate CLIs refuse self-update on either backend. +export HACK_CANDIDATE_ENTRYPOINT=hack-v5 +if [ "$backend" = native ]; then + export HACK_RUNTIME_BACKEND=native + export HACK_NATIVE_BINARY="$bundle/hack-native" +else + unset HACK_NATIVE_BINARY HACK_NATIVE_HOME + export HACK_RUNTIME_BACKEND=compose +fi exec "$bundle/hack-cli" "$@" diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py index dafdab645..f7cb0cf66 100644 --- a/scripts/install-prerelease.py +++ b/scripts/install-prerelease.py @@ -47,7 +47,8 @@ REPOSITORY = "hack-dance/hack" DOWNLOAD_HOSTS = {"api.github.com", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"} -# Reviewed flat-layout, shared-MCP, compiler, batched-signature, and verified-launcher managers. +# Reviewed flat-layout, shared-MCP, compiler, batched-signature, verified-launcher and +# storage-witness managers (the last, e2e2bcba..., shipped with 5.0.0-next.3 through next.5). # Keep this an explicit allowlist; a matching user-written receipt is not provenance. MANAGER_PREDECESSORS = frozenset({ "b459ffc4f227482119b48e357c91e4607aa4f7d1f88c7ccf5b7d684f66f2c0cd", @@ -55,7 +56,10 @@ "c7562953361b6b37141e126df31edefabc05e4441e3c6e0a38b1c3cda6b645da", "ca432b7fc6562bb091d17d3217f5d1daf9f91621a6919c8964ca51bee2111d0c", "d39e77623be1876567e9db66c468e8da96067aeac0d88089df28efc11f850ef8", + "e2e2bcbacf58b21d1086047c94727e5e814776384439700adcf5aabe5c49c2fa", }) +# Launch backends. Unset or empty selects native; anything else refuses. +BACKENDS = frozenset(("native", "compose")) MANAGER_UPGRADE = ".manager-upgrade.json" @@ -772,9 +776,24 @@ def verify_version(self, version, checksum): "Candidate home identity changed.") return directory - def environment(self, version): + def environment(self, version, backend="native"): + """Child environment for one launch. + + Native pins this version's executor and both private homes, overriding + inherited installation selectors. Compose is an explicit caller choice: + it keeps the caller's HACK_HOME and HACK_GLOBAL_CONFIG_PATH so the + existing global Caddy/CoreDNS and local CA apply, and removes native + selectors. Either way the CLI knows it is a candidate and refuses + self-update. + """ directory = self.root / "versions" / version environment = dict(os.environ) + environment["HACK_CANDIDATE_ENTRYPOINT"] = "hack-next" + if backend == "compose": + environment.pop("HACK_NATIVE_BINARY", None) + environment.pop("HACK_NATIVE_HOME", None) + environment["HACK_RUNTIME_BACKEND"] = "compose" + return environment environment.pop("HACK_GLOBAL_CONFIG_PATH", None) environment.update({"HACK_RUNTIME_BACKEND": "native", "HACK_NATIVE_BINARY": str(directory / "bundle/hack-native"), @@ -891,6 +910,9 @@ def install(self, version, archive=None, checksum=None, upgrade=False): remove_created_stage(stage, created) def run(self, arguments): + backend = os.environ.get("HACK_RUNTIME_BACKEND") or "native" + require(backend in BACKENDS, + "HACK_RUNTIME_BACKEND must be native or compose (unset selects native).") version = self.state["selected"] require(version is not None, "No candidate selected. Use your ordinary stable hack command.") directory = self.verify_version(version, self.state["installed"][version]) @@ -898,8 +920,11 @@ def run(self, arguments): # Shared launcher locks permit ps/down alongside foreground up, while an # exclusive selection switch refuses any in-flight launcher. Background # runtime state is gated by the owning executor on later switches. - child = subprocess.Popen([str(directory / "bundle/hack-v5"), *arguments], - env=self.environment(version)) + # Compose runs the verified CLI directly: retained bundles' hack-v5 + # entrypoints always select native and would ignore the choice. + entrypoint = "bundle/hack-cli" if backend == "compose" else "bundle/hack-v5" + child = subprocess.Popen([str(directory / entrypoint), *arguments], + env=self.environment(version, backend)) handlers = {} def forward(signum, _frame): child.send_signal(signum) diff --git a/src/commands/update.ts b/src/commands/update.ts index 1c8fa1dd2..6b5ce8669 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -68,8 +68,13 @@ const handleUpdate: CommandHandlerFor = async ({ ctx, args, }): Promise => { - // The native launcher runs hack-cli; PATH fallback can select the stable install. - if (process.env.HACK_RUNTIME_BACKEND === "native") { + // Candidate launchers run hack-cli; PATH fallback can select the stable install. + // `hack-next` and `hack-v5` mark their children on both backends, because an + // explicit Compose selection no longer carries the native backend marker. + if ( + process.env.HACK_RUNTIME_BACKEND === "native" || + (process.env.HACK_CANDIDATE_ENTRYPOINT ?? "") !== "" + ) { return writeResult({ json: args.options.json === true, result: { diff --git a/tests/fixtures/prerelease-manager-storage-v5.py b/tests/fixtures/prerelease-manager-storage-v5.py new file mode 100644 index 000000000..dafdab645 --- /dev/null +++ b/tests/fixtures/prerelease-manager-storage-v5.py @@ -0,0 +1,971 @@ +#!/usr/bin/env python3 +"""Explicit, side-by-side native prerelease selection (Python 3.9+, standard library). + +Selection changes commit through one atomic receipt. Explicit manager upgrades +use a recoverable journal. Bundles and homes are retained by version; this manager +never migrates runtime state or edits shell PATH. +""" + +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import signal +import stat +import subprocess +import sys +import uuid + + +VERSION = re.compile(r"5\.0\.0-next\.([1-9][0-9]*)\Z") +SHA256 = re.compile(r"[0-9a-f]{64}\Z") +REVISION = re.compile(r"[0-9a-f]{40}\Z") +PAYLOAD = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", + "provider-pins.json", "README.md", "prerelease.json")) +LEGACY_COMPILER_PAYLOAD = frozenset(("hack-config-compiler", "hack.project.schema.json")) +COMPILER_PAYLOAD = LEGACY_COMPILER_PAYLOAD | {"hack.local.schema.json"} +# Guest-only Linux ARM64 witness for authored persistent mounts and its byte +# manifest. The CLI resolves both as siblings of hack-native; older bundles omit both. +STORAGE_TOOL = "hack-storage-root-witness" +STORAGE_TOOL_MANIFEST = "native-storage-tool.json" +STORAGE_TOOL_PAYLOAD = frozenset((STORAGE_TOOL, STORAGE_TOOL_MANIFEST)) +STORAGE_TOOL_KEYS = {"version", "kind", "platform", "architecture", "file", "sha256", "bytes", + "sourceRevision"} +EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", + "hack-config-compiler")) +BUNDLE_FILES = PAYLOAD | {"SHA256SUMS"} +MCP_FILES = {"adapter": "hack-mcp-adapter", "owner": "hack-mcp-owner", + "backend": "hack-mcp-backend"} +MCP_MEMBER = re.compile(r"mcp/([0-9a-f]{64})/(manifest\.json|hack-mcp-adapter|hack-mcp-owner|hack-mcp-backend)\Z") +MAX_ARCHIVE = 512 * 1024 * 1024 +METADATA_KEYS = {"schema", "version", "tag", "source_revision", "platform"} +REPOSITORY = "hack-dance/hack" +DOWNLOAD_HOSTS = {"api.github.com", "github.com", "release-assets.githubusercontent.com", + "objects.githubusercontent.com"} +# Reviewed flat-layout, shared-MCP, compiler, batched-signature, and verified-launcher managers. +# Keep this an explicit allowlist; a matching user-written receipt is not provenance. +MANAGER_PREDECESSORS = frozenset({ + "b459ffc4f227482119b48e357c91e4607aa4f7d1f88c7ccf5b7d684f66f2c0cd", + "b7c49e3fec6b06790e833db1d2dcb441d2223c283b792713be46826aa2eef877", + "c7562953361b6b37141e126df31edefabc05e4441e3c6e0a38b1c3cda6b645da", + "ca432b7fc6562bb091d17d3217f5d1daf9f91621a6919c8964ca51bee2111d0c", + "d39e77623be1876567e9db66c468e8da96067aeac0d88089df28efc11f850ef8", +}) +MANAGER_UPGRADE = ".manager-upgrade.json" + + +class Refusal(Exception): + """Untrusted, changed or ambiguous input: preserve the current selection.""" + + +def require(condition, message): + if not condition: + raise Refusal(message) + + +def version_number(value): + match = VERSION.fullmatch(value) if isinstance(value, str) else None + require(match is not None, "Version must be 5.0.0-next.N (positive N, no leading zero).") + return int(match.group(1)) + + +def digest(path): + value = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def json_bytes(value): + return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode("utf-8") + + +def parse_json(raw): + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON field.") + result[key] = value + return result + try: + value = json.loads(raw, object_pairs_hook=unique) + except (ValueError, UnicodeError) as error: + raise Refusal("Malformed JSON.") from error + require(isinstance(value, dict), "Expected a JSON object.") + return value + + +def metadata(raw, version): + value = parse_json(raw) + require(set(value) == METADATA_KEYS, "Unexpected prerelease metadata fields.") + require(value["schema"] == "hack.prerelease/v1" and value["version"] == version + and value["tag"] == "v" + version and value["platform"] == "darwin-arm64" + and isinstance(value["source_revision"], str) + and REVISION.fullmatch(value["source_revision"]), "Prerelease identity mismatch.") + return value + + +def canonical(path): + path = Path(path) + require(path.is_absolute() and path == path.resolve(), + "Use a canonical absolute path without symlinks or traversal.") + return path + + +def owned(path, directory=False, mode=None): + info = path.lstat() + expected = stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode) + require(expected and info.st_uid == os.getuid(), "Foreign or aliased path: " + str(path)) + if not directory: + require(info.st_nlink == 1, "Hard-linked file: " + str(path)) + if mode is not None: + require(stat.S_IMODE(info.st_mode) == mode, "Unsafe mode: " + str(path)) + return info + + +def private_json(path): + owned(path, mode=0o600) + require(path.stat().st_size <= 1024 * 1024, "Oversized installation receipt.") + return parse_json(path.read_bytes()) + + +def sync_directory(path): + descriptor = os.open(str(path), os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_file(path, contents, mode=0o600): + descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + with os.fdopen(descriptor, "wb") as target: + os.fchmod(target.fileno(), mode) + target.write(contents) + target.flush() + os.fsync(target.fileno()) + + +def atomic_file(path, contents): + temporary = path.parent / (".selection-" + uuid.uuid4().hex) + try: + write_file(temporary, contents) + os.replace(str(temporary), str(path)) + sync_directory(path.parent) + finally: + if temporary.exists(): + temporary.unlink() + + +def atomic_json(path, value): + atomic_file(path, json_bytes(value)) + + +def launcher_bytes(): + return ("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd -P)\n" + "exec /usr/bin/python3 -I -S \"$root/manager.py\" --root \"$root\" run -- \"$@\"\n").encode() + + +def checksums(raw, expected): + try: + lines = raw.decode("ascii").splitlines() + except UnicodeError as error: + raise Refusal("Malformed SHA256SUMS.") from error + result = {} + for line in lines: + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9./-]+)", line) + require(match is not None, "Malformed SHA256SUMS entry.") + checksum, name = match.groups() + require(name in expected and name not in result, "Duplicate or foreign checksum entry.") + result[name] = checksum + require(set(result) == set(expected), "Incomplete checksum manifest.") + return result + + +def payload_inventory(names): + """Accept complete optional compiler, storage tool and MCP groups beside the original payload.""" + names = set(names) + require(BUNDLE_FILES <= names, "Incomplete candidate bundle.") + compiler = names & COMPILER_PAYLOAD + require(not compiler or compiler in (LEGACY_COMPILER_PAYLOAD, COMPILER_PAYLOAD), + "Incomplete config compiler payload.") + storage = names & STORAGE_TOOL_PAYLOAD + require(not storage or storage == STORAGE_TOOL_PAYLOAD, "Incomplete storage tool payload.") + extra = names - BUNDLE_FILES - COMPILER_PAYLOAD - STORAGE_TOOL_PAYLOAD + if extra: + matches = [MCP_MEMBER.fullmatch(name) for name in extra] + require(all(matches), "Foreign candidate MCP payload.") + identities = {match.group(1) for match in matches} + require(len(identities) == 1, "Candidate requires one MCP bundle identity.") + prefix = "mcp/" + next(iter(identities)) + "/" + require(extra == {prefix + name for name in (*MCP_FILES.values(), "manifest.json")}, + "Incomplete candidate MCP payload.") + return names - {"SHA256SUMS"} + + +def bundle_inventory(bundle): + names = {entry.name for entry in bundle.iterdir()} + if "mcp" in names: + owned(bundle / "mcp", directory=True, mode=0o700) + identities = list((bundle / "mcp").iterdir()) + require(len(identities) == 1 and SHA256.fullmatch(identities[0].name), + "Candidate requires one MCP bundle identity.") + directory = identities[0] + owned(directory, directory=True, mode=0o700) + names.remove("mcp") + names.update("mcp/" + directory.name + "/" + entry.name for entry in directory.iterdir()) + return payload_inventory(names) + + +def payload_mode(name): + if MCP_MEMBER.fullmatch(name): + return 0o400 if name.endswith("/manifest.json") else 0o500 + if name == STORAGE_TOOL: + return 0o500 + return 0o755 if name in EXECUTABLES else 0o600 + + +def verify_storage_tool(bundle, payload, verified_digests, identity): + """Bind the witness manifest to bytes hashed in this verification and to provenance. + + Runtime admission parses the same fields and refuses any mismatch; checking here + keeps an unusable pair from being selected. The ELF itself never runs on macOS. + """ + if STORAGE_TOOL not in payload: + return + path = bundle / STORAGE_TOOL_MANIFEST + require(path.stat().st_size <= 4096, "Oversized storage tool manifest.") + manifest = parse_json(path.read_bytes()) + require(set(manifest) == STORAGE_TOOL_KEYS, "Invalid storage tool manifest fields.") + require(type(manifest["version"]) is int and manifest["version"] == 1 + and manifest["kind"] == "native-storage-root-witness" + and manifest["platform"] == "linux" and manifest["architecture"] == "arm64" + and manifest["file"] == STORAGE_TOOL, "Incompatible storage tool manifest.") + size = (bundle / STORAGE_TOOL).stat().st_size + require(type(manifest["bytes"]) is int and 64 <= manifest["bytes"] <= 2 * 1024 * 1024 + and manifest["bytes"] == size + and isinstance(manifest["sha256"], str) and SHA256.fullmatch(manifest["sha256"]) + and manifest["sha256"] == verified_digests[STORAGE_TOOL], + "Storage tool fingerprint mismatch.") + require(manifest["sourceRevision"] == identity["source_revision"], + "Storage tool source revision mismatch.") + + +def verify_mcp_bundle(bundle, payload, verified_digests): + nested = sorted(name for name in payload if MCP_MEMBER.fullmatch(name)) + if not nested: + return + prefix = str(Path(nested[0]).parent) + path = bundle / prefix / "manifest.json" + require(path.stat().st_size <= 8192, "Oversized MCP manifest.") + manifest = parse_json(path.read_bytes()) + require(set(manifest) == {"schemaVersion", "startupProtocol", "wireProtocol", "platform", + "architecture", "files", "bundleId"}, "Invalid MCP manifest fields.") + require(all(type(manifest[field]) in (int, float) + for field in ("schemaVersion", "startupProtocol", "wireProtocol")) + and manifest["schemaVersion"] == 1 and manifest["startupProtocol"] == 2 + and manifest["wireProtocol"] == 1 and manifest["platform"] == "darwin" + and manifest["architecture"] == "arm64", "Incompatible MCP protocol or host.") + files = manifest["files"] + require(isinstance(files, dict) and set(files) == set(MCP_FILES), "Invalid MCP asset inventory.") + canonical_files = {} + for role, name in MCP_FILES.items(): + entry = files[role] + require(isinstance(entry, dict) and set(entry) == {"sha256", "bytes"} + and isinstance(entry["sha256"], str) and SHA256.fullmatch(entry["sha256"]) + and type(entry["bytes"]) is int and 0 < entry["bytes"] <= 256 * 1024 * 1024, + "Invalid MCP asset fingerprint.") + asset = bundle / prefix / name + # Compare the nested identity against bytes hashed in this verification, + # not just the outer manifest's claims. Never retain this across calls. + require(asset.stat().st_size == entry["bytes"] + and verified_digests[prefix + "/" + name] == entry["sha256"], + "MCP asset fingerprint mismatch.") + canonical_files[role] = {"sha256": entry["sha256"], "bytes": entry["bytes"]} + body = {"schemaVersion": 1, "startupProtocol": 2, "wireProtocol": 1, + "platform": "darwin", "architecture": "arm64", "files": canonical_files} + identity = hashlib.sha256(json.dumps(body, separators=(",", ":")).encode()).hexdigest() + require(manifest["bundleId"] == identity and Path(prefix).name == identity, + "MCP bundle identity mismatch.") + + +def verify_bundle(bundle, version): + owned(bundle, directory=True, mode=0o700) + payload = bundle_inventory(bundle) + for name in payload | {"SHA256SUMS"}: + owned(bundle / name, mode=payload_mode(name)) + require((bundle / "SHA256SUMS").stat().st_size <= 64 * 1024 + and (bundle / "prerelease.json").stat().st_size <= 64 * 1024, + "Oversized bundle metadata or checksum manifest.") + manifest = checksums((bundle / "SHA256SUMS").read_bytes(), payload) + verified_digests = {} + for name, checksum in manifest.items(): + actual = digest(bundle / name) + require(actual == checksum, "Candidate checksum mismatch: " + name) + verified_digests[name] = actual + identity = metadata((bundle / "prerelease.json").read_bytes(), version) + verify_mcp_bundle(bundle, payload, verified_digests) + verify_storage_tool(bundle, payload, verified_digests, identity) + return identity, manifest + + +def verify_signatures(bundle): + # Linux guest ELFs (hack-relay-guest, the storage witness) carry no macOS code + # signature; checksums and, for the witness, its bound manifest pin them. + names = ["hack-native", "hack-cli"] + payload = bundle_inventory(bundle) + if "hack-config-compiler" in payload: + names.append("hack-config-compiler") + names.extend(sorted(name for name in payload + if MCP_MEMBER.fullmatch(name) and not name.endswith("/manifest.json"))) + # codesign verifies every supplied path and fails if any is invalid. Keep all + # executables and strict validation in one bounded, output-redacted process. + try: + result = subprocess.run(["/usr/bin/codesign", "--verify", "--strict", + *(str(bundle / name) for name in names)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refusal("Cannot verify candidate code signature.") from error + require(result.returncode == 0, "Candidate code signature failed.") + + +def extract_archive(archive, checksum, destination, version, release_metadata=None): + import tarfile + + owned(archive) + require(SHA256.fullmatch(checksum) is not None, "Expected archive SHA-256 is required.") + require(archive.stat().st_size <= MAX_ARCHIVE and digest(archive) == checksum, + "Archive checksum or size mismatch.") + try: + with tarfile.open(str(archive), "r:gz") as source: + entries = [] + seen = set() + total = 0 + for entry in source: + require(len(entries) < len(BUNDLE_FILES) + len(COMPILER_PAYLOAD) + + len(STORAGE_TOOL_PAYLOAD) + 4, "Incomplete or duplicate archive entries.") + require((entry.name in BUNDLE_FILES | COMPILER_PAYLOAD | STORAGE_TOOL_PAYLOAD + or MCP_MEMBER.fullmatch(entry.name)) and entry.name not in seen, + "Foreign, duplicate or traversing archive entry.") + require(entry.isfile() and not entry.issparse() and entry.size > 0 + and entry.size <= MAX_ARCHIVE, "Only bounded regular archive files are accepted.") + seen.add(entry.name) + total += entry.size + require(total <= MAX_ARCHIVE, "Oversized archive.") + entries.append(entry) + payload = payload_inventory(seen) + by_name = {entry.name: entry for entry in entries} + require(by_name["prerelease.json"].size <= 64 * 1024 + and by_name["SHA256SUMS"].size <= 64 * 1024, + "Oversized archive metadata or checksum manifest.") + with source.extractfile(by_name["prerelease.json"]) as incoming: + identity = metadata(incoming.read(), version) + require(release_metadata is None or identity == release_metadata, + "Archive metadata differs from the pinned release.") + with source.extractfile(by_name["SHA256SUMS"]) as incoming: + manifest = checksums(incoming.read(), payload) + # Preflight hashes before writing any payload. Revalidation after + # copying also catches a source changed between these two passes. + for entry in entries: + name = entry.name + if name not in payload: + continue + value = hashlib.sha256() + with source.extractfile(entry) as incoming: + for chunk in iter(lambda: incoming.read(1024 * 1024), b""): + value.update(chunk) + require(value.hexdigest() == manifest[name], "Candidate checksum mismatch: " + name) + destination.mkdir(mode=0o700) + nested = {name for name in payload if MCP_MEMBER.fullmatch(name)} + if nested: + prefix = Path(next(iter(nested))).parent + (destination / "mcp").mkdir(mode=0o700) + (destination / prefix).mkdir(mode=0o700) + for entry in entries: + incoming = source.extractfile(entry) + require(incoming is not None, "Unreadable archive payload.") + with incoming: + descriptor = os.open(str(destination / entry.name), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + payload_mode(entry.name)) + with os.fdopen(descriptor, "wb") as target: + os.fchmod(target.fileno(), payload_mode(entry.name)) + remaining = entry.size + while remaining: + chunk = incoming.read(min(1024 * 1024, remaining)) + require(bool(chunk), "Truncated archive payload.") + target.write(chunk) + remaining -= len(chunk) + target.flush() + os.fsync(target.fileno()) + except (tarfile.TarError, EOFError) as error: + raise Refusal("Invalid candidate archive.") from error + identity, manifest = verify_bundle(destination, version) + require(release_metadata is None or identity == release_metadata, + "Archive metadata differs from the pinned release.") + verify_signatures(destination) + sync_directory(destination) + return identity, manifest + + +def secure_url(url): + import urllib.parse + + parsed = urllib.parse.urlsplit(url) + require(parsed.scheme == "https" and parsed.hostname in DOWNLOAD_HOSTS + and parsed.port in (None, 443) and not parsed.username and not parsed.password + and not parsed.fragment, "Refusing nonofficial or non-HTTPS download.") + + +def official_redirect_handler(): + # Local launches do not need the TLS/network/archive import graph. Load the + # same restricted redirect handler only when an official download is needed. + import urllib.request + + class OfficialRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + secure_url(url) + return super().redirect_request(request, response, code, message, headers, url) + + return OfficialRedirect() + + +def download(url, target, limit): + """Write one complete official response, or refuse. + + A short body, a stream interrupted by the transport, or bytes past the budget + refuse. The caller owns `target` and its staging directory; this function never + publishes or selects what it wrote. + """ + import http.client + import urllib.request + + secure_url(url) + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json", + "User-Agent": "hack-prerelease-installer"}) + opener = urllib.request.build_opener(official_redirect_handler()) + with opener.open(request, timeout=30) as response: + secure_url(response.geturl()) + # http.client returns a short body as an ordinary end of stream, so the + # declared length is checked here rather than trusted to the transport. + declared = response.headers.get("Content-Length") + require(declared is None or isinstance(declared, str) and re.fullmatch(r"[0-9]{1,12}", declared), + "Malformed release download length.") + expected = None if declared is None else int(declared) + require(expected is None or expected <= limit, "Release download exceeds its size budget.") + descriptor = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as output: + size = 0 + while True: + try: + chunk = response.read(1024 * 1024) + except (http.client.HTTPException, OSError) as error: + raise Refusal("Release download was interrupted.") from error + if not chunk: + break + size += len(chunk) + require(size <= limit, "Release download exceeds its size budget.") + require(expected is None or size <= expected, + "Release download exceeds its declared length.") + output.write(chunk) + require(expected is None or size == expected, "Release download is incomplete.") + output.flush() + os.fsync(output.fileno()) + + +def official_archive(stage, version): + tag = "v" + version + api_path = stage / "release-api.json" + download("https://api.github.com/repos/" + REPOSITORY + "/releases/tags/" + tag, + api_path, 2 * 1024 * 1024) + release = parse_json(api_path.read_bytes()) + require(release.get("tag_name") == tag and release.get("prerelease") is True + and release.get("draft") is False, "Expected a published, pinned official prerelease.") + archive_name = "hack-" + version + "-darwin-arm64-native.tar.gz" + wanted = {archive_name, "SHA256SUMS", "prerelease.json"} + assets = release.get("assets") + require(isinstance(assets, list), "Missing release assets.") + selected = {} + for asset in assets: + require(isinstance(asset, dict), "Malformed release asset.") + name = asset.get("name") + require(isinstance(name, str), "Malformed release asset name.") + if name in wanted: + require(name not in selected, "Duplicate release asset.") + expected = "https://github.com/" + REPOSITORY + "/releases/download/" + tag + "/" + name + require(asset.get("browser_download_url") == expected, "Foreign release asset URL.") + selected[name] = expected + require(set(selected) == wanted, "Incomplete official prerelease assets.") + download(selected["prerelease.json"], stage / "release-metadata.json", 64 * 1024) + identity = metadata((stage / "release-metadata.json").read_bytes(), version) + download("https://api.github.com/repos/" + REPOSITORY + "/git/ref/tags/" + tag, + stage / "release-tag.json", 64 * 1024) + reference = parse_json((stage / "release-tag.json").read_bytes()) + commit = reference.get("object") + require(reference.get("ref") == "refs/tags/" + tag and isinstance(commit, dict) + and commit.get("type") == "commit" and commit.get("sha") == identity["source_revision"], + "Official prerelease tag does not match its source revision.") + download(selected["SHA256SUMS"], stage / "release-checksums", 64 * 1024) + checksum = checksums((stage / "release-checksums").read_bytes(), {archive_name})[archive_name] + archive = stage / archive_name + download(selected[archive_name], archive, MAX_ARCHIVE) + return archive, checksum, identity + + +def remove_created_stage(stage, created): + """Remove the one install stage whose identity this call recorded at creation. + + A missing, replaced or foreign path is left for inspection. Other stages, + including a manager upgrade's journaled stage, are never touched. + """ + import shutil + + try: + info = stage.lstat() + except FileNotFoundError: + return + if (stat.S_ISDIR(info.st_mode) and info.st_uid == os.getuid() + and (info.st_dev, info.st_ino) == (created.st_dev, created.st_ino)): + shutil.rmtree(str(stage)) + sync_directory(stage.parent) + + +class Channel: + """Owned private layout; the selection file records immutable receipt hashes.""" + + def __init__(self, root): + self.root = canonical(root) + self.state = None + + def initialize(self): + if not self.root.exists(): + owned(self.root.parent, directory=True) + self.root.mkdir(mode=0o700) + owned(self.root, directory=True, mode=0o700) + if (self.root / ".channel.json").exists(): + return + require(not list(self.root.iterdir()), "Refusing to adopt a nonempty installation root.") + (self.root / "bin").mkdir(mode=0o700) + (self.root / "versions").mkdir(mode=0o700) + write_file(self.root / "manager.py", Path(__file__).read_bytes()) + write_file(self.root / "bin/hack-next", launcher_bytes(), 0o755) + root_info = self.root.stat() + write_file(self.root / ".channel.json", json_bytes({ + "schema": "hack.prerelease-install/v1", "root": str(self.root), "uid": os.getuid(), + "device": root_info.st_dev, "inode": root_info.st_ino, + "manager_sha256": digest(self.root / "manager.py"), + "launcher_sha256": digest(self.root / "bin/hack-next")})) + write_file(self.root / ".selection.json", json_bytes({ + "schema": "hack.prerelease-selection/v1", "installed": {}, + "selected": None, "previous": None})) + write_file(self.root / ".manager.lock", b"") + sync_directory(self.root) + + @contextlib.contextmanager + def lock(self, shared=False, manager_upgrade=False): + owned(self.root, directory=True, mode=0o700) + owned(self.root / ".manager.lock", mode=0o600) + descriptor = os.open(str(self.root / ".manager.lock"), os.O_RDWR | os.O_NOFOLLOW) + with os.fdopen(descriptor, "rb") as handle: + try: + fcntl.flock(handle, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) + except BlockingIOError as error: + raise Refusal("Another candidate manager or launcher is active.") from error + self.validate(manager_upgrade=manager_upgrade) + yield + + def validate(self, manager_upgrade=False): + root_info = owned(self.root, directory=True, mode=0o700) + pending = os.path.lexists(self.root / MANAGER_UPGRADE) + require(not pending or manager_upgrade, + "Manager upgrade is pending; rerun upgrade-manager with the same reviewed installer.") + transition = self.read_manager_upgrade() if pending else None + marker = private_json(self.root / ".channel.json") + require(set(marker) == {"schema", "root", "uid", "device", "inode", + "manager_sha256", "launcher_sha256"} + and marker["schema"] == "hack.prerelease-install/v1" + and marker["root"] == str(self.root) and marker["uid"] == os.getuid() + and marker["device"] == root_info.st_dev and marker["inode"] == root_info.st_ino, + "Foreign or malformed installation receipt.") + owned(self.root / "manager.py", mode=0o600) + owned(self.root / "bin", directory=True, mode=0o700) + owned(self.root / "bin/hack-next", mode=0o755) + manager_hash = digest(self.root / "manager.py") + require((manager_hash == marker["manager_sha256"] or transition is not None + and manager_hash == transition["to_sha256"] + and marker["manager_sha256"] == transition["from_sha256"]) + and digest(self.root / "bin/hack-next") == marker["launcher_sha256"], + "Installation manager or launcher changed.") + require({p.name for p in (self.root / "bin").iterdir()} == {"hack-next"}, + "Foreign launcher entry.") + state = private_json(self.root / ".selection.json") + require(set(state) == {"schema", "installed", "selected", "previous"} + and state["schema"] == "hack.prerelease-selection/v1" + and isinstance(state["installed"], dict), "Malformed selection receipt.") + installed = state["installed"] + require(len(installed) <= 256, "Too many candidate versions; inspection required.") + for version, checksum in installed.items(): + version_number(version) + require(isinstance(checksum, str) and SHA256.fullmatch(checksum), "Malformed receipt hash.") + self.verify_version(version, checksum) + for key in ("selected", "previous"): + require(state[key] is None or isinstance(state[key], str) and state[key] in installed, + "Unknown selected or previous candidate.") + require(state["selected"] is None or state["selected"] != state["previous"], + "Malformed candidate history.") + owned(self.root / "versions", directory=True, mode=0o700) + for entry in (self.root / "versions").iterdir(): + if entry.name not in installed: + version_number(entry.name) + # Rename can precede selection by a power-loss boundary. Validate + # the retained directory, but never select/adopt it automatically. + private_json(entry / ".receipt.json") + self.verify_version(entry.name, digest(entry / ".receipt.json")) + allowed = {".channel.json", ".selection.json", ".manager.lock", "manager.py", "bin", "versions"} + if transition is not None: + allowed.add(MANAGER_UPGRADE) + for entry in self.root.iterdir(): + if entry.name in allowed: + continue + if re.fullmatch(r"\.stage-[0-9a-f]{32}", entry.name): + owned(entry, directory=True, mode=0o700) + continue + if re.fullmatch(r"\.selection-[0-9a-f]{32}", entry.name): + owned(entry, mode=0o600) + continue + raise Refusal("Foreign installation entry: " + entry.name) + self.state = state + + def read_manager_upgrade(self): + """Verify both sides of the only supported two-file transition before recovery. + + The journal closes ordinary launch admission before either file changes. + Recovery may finish this exact transition, never adopt changed receipts or + select software. The staged originals remain available for inspection. + """ + plan = private_json(self.root / MANAGER_UPGRADE) + require(set(plan) == {"schema", "stage", "from_sha256", "to_sha256", + "channel_sha256", "selection_sha256"} + and plan["schema"] == "hack.prerelease-manager-upgrade/v1" + and isinstance(plan["stage"], str) + and re.fullmatch(r"\.stage-[0-9a-f]{32}", plan["stage"]), + "Malformed manager upgrade journal.") + require(all(isinstance(plan[key], str) and SHA256.fullmatch(plan[key]) + for key in ("from_sha256", "to_sha256", "channel_sha256", "selection_sha256")), + "Malformed manager upgrade digest.") + require(plan["from_sha256"] in MANAGER_PREDECESSORS + and plan["to_sha256"] == digest(Path(__file__)), + "Manager recovery requires the same reviewed installer and a known predecessor.") + stage = self.root / plan["stage"] + owned(stage, directory=True, mode=0o700) + require({p.name for p in stage.iterdir()} == { + "manager-before.py", "manager-after.py", "channel-before.json", "channel-after.json"}, + "Foreign manager upgrade staging entry.") + for entry in stage.iterdir(): + owned(entry, mode=0o600) + before = private_json(stage / "channel-before.json") + after = private_json(stage / "channel-after.json") + require(digest(stage / "manager-before.py") == plan["from_sha256"] + and digest(stage / "manager-after.py") == plan["to_sha256"] + and digest(stage / "channel-before.json") == plan["channel_sha256"] + and before.get("manager_sha256") == plan["from_sha256"] + and after == dict(before, manager_sha256=plan["to_sha256"]), + "Manager upgrade staging changed.") + owned(self.root / "manager.py", mode=0o600) + private_json(self.root / ".channel.json") + private_json(self.root / ".selection.json") + old_receipt = (stage / "channel-before.json").read_bytes() + new_receipt = (stage / "channel-after.json").read_bytes() + current_receipt = (self.root / ".channel.json").read_bytes() + current_hash = digest(self.root / "manager.py") + require((current_hash == plan["from_sha256"] and current_receipt == old_receipt) + or (current_hash == plan["to_sha256"] and current_receipt in (old_receipt, new_receipt)), + "Manager upgrade publication state changed.") + require(digest(self.root / ".selection.json") == plan["selection_sha256"], + "Selection changed during manager upgrade; inspection required.") + owned(self.root / "bin", directory=True, mode=0o700) + owned(self.root / "bin/hack-next", mode=0o755) + require((self.root / "bin/hack-next").read_bytes() == launcher_bytes(), + "Custom launcher cannot be upgraded.") + return plan + + def upgrade_manager(self): + """Explicitly replace a known manager, with recoverable fail-closed publication.""" + self.validate(manager_upgrade=True) + pending = os.path.lexists(self.root / MANAGER_UPGRADE) + source = canonical(Path(__file__).absolute()) + owned(source) + target = source.read_bytes() + target_hash = hashlib.sha256(target).hexdigest() + if not pending: + current_hash = digest(self.root / "manager.py") + if current_hash == target_hash: + return + require(current_hash in MANAGER_PREDECESSORS, + "Unknown or customized manager; explicit upgrade supports only reviewed predecessors.") + require((self.root / "bin/hack-next").read_bytes() == launcher_bytes(), + "Custom launcher cannot be upgraded.") + # All retained versions share this manager. Keep the existing executor + # ownership checks, including status/down/status, for each of them. + for version in self.state["installed"]: + self.require_quiescent(version, manager_upgrade=True) + self.validate(manager_upgrade=True) + if not pending: + stage = self.root / (".stage-" + uuid.uuid4().hex) + stage.mkdir(mode=0o700) + before = (self.root / ".channel.json").read_bytes() + after = dict(parse_json(before), manager_sha256=target_hash) + write_file(stage / "manager-before.py", (self.root / "manager.py").read_bytes()) + write_file(stage / "manager-after.py", target) + write_file(stage / "channel-before.json", before) + write_file(stage / "channel-after.json", json_bytes(after)) + plan = {"schema": "hack.prerelease-manager-upgrade/v1", "stage": stage.name, + "from_sha256": current_hash, "to_sha256": target_hash, + "channel_sha256": hashlib.sha256(before).hexdigest(), + "selection_sha256": digest(self.root / ".selection.json")} + write_file(stage / "journal.json", json_bytes(plan)) + sync_directory(stage) + self.validate() + os.rename(str(stage / "journal.json"), str(self.root / MANAGER_UPGRADE)) + sync_directory(stage) + sync_directory(self.root) + self.validate(manager_upgrade=True) + plan = self.read_manager_upgrade() + stage = self.root / plan["stage"] + if digest(self.root / "manager.py") != target_hash: + atomic_file(self.root / "manager.py", (stage / "manager-after.py").read_bytes()) + self.validate(manager_upgrade=True) + if (self.root / ".channel.json").read_bytes() != (stage / "channel-after.json").read_bytes(): + atomic_file(self.root / ".channel.json", (stage / "channel-after.json").read_bytes()) + self.validate(manager_upgrade=True) + (self.root / MANAGER_UPGRADE).unlink() + sync_directory(self.root) + self.validate() + + def verify_version(self, version, checksum): + directory = self.root / "versions" / version + owned(directory, directory=True, mode=0o700) + require({p.name for p in directory.iterdir()} == {"bundle", "native-home", "cli-home", ".receipt.json"}, + "Incomplete or foreign version installation.") + receipt_path = directory / ".receipt.json" + receipt = private_json(receipt_path) + require(digest(receipt_path) == checksum, "Candidate installation receipt changed.") + require(set(receipt) == {"schema", "metadata", "archive_sha256", "checksums", "homes"} + and receipt["schema"] == "hack.prerelease-version/v1" + and isinstance(receipt["archive_sha256"], str) + and SHA256.fullmatch(receipt["archive_sha256"]), "Malformed version receipt.") + identity, manifest = verify_bundle(directory / "bundle", version) + require(receipt["metadata"] == identity and receipt["checksums"] == manifest, + "Candidate identity or checksum receipt changed.") + require(isinstance(receipt["homes"], dict) and set(receipt["homes"]) == {"native-home", "cli-home"}, + "Malformed candidate home receipt.") + for name in ("native-home", "cli-home"): + info = owned(directory / name, directory=True, mode=0o700) + require(receipt["homes"][name] == {"device": info.st_dev, "inode": info.st_ino}, + "Candidate home identity changed.") + return directory + + def environment(self, version): + directory = self.root / "versions" / version + environment = dict(os.environ) + environment.pop("HACK_GLOBAL_CONFIG_PATH", None) + environment.update({"HACK_RUNTIME_BACKEND": "native", + "HACK_NATIVE_BINARY": str(directory / "bundle/hack-native"), + "HACK_NATIVE_HOME": str(directory / "native-home"), + "HACK_HOME": str(directory / "cli-home")}) + return environment + + def require_quiescent(self, version, manager_upgrade=False): + if version is None: + return + self.validate(manager_upgrade=manager_upgrade) + directory = self.verify_version(version, self.state["installed"][version]) + verify_signatures(directory / "bundle") + for action in ("status", "down", "status"): + command = [str(directory / "bundle/hack-native"), "--candidate-root", + str(directory / "native-home"), "runtime", action, "--json"] + try: + result = subprocess.run(command, env=self.environment(version), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refusal("Candidate quiescence is unavailable; selection preserved.") from error + require(result.returncode == 0 and len(result.stdout) <= 1024 * 1024, + "Candidate quiescence check failed; failure never means idle.") + status = parse_json(result.stdout) + phase = status.get("phase") + require(phase in ("uninitialized", "stopped") and status.get("process_alive") is False, + "Candidate runtime is active or ambiguous; stop it through its supported CLI first.") + if phase == "uninitialized": + require(not list((directory / "native-home").iterdir()), + "Uninitialized home contains state; inspect it through its owning CLI.") + self.validate(manager_upgrade=manager_upgrade) + + def select(self, version, installed=None): + self.require_quiescent(self.state["selected"]) + if version is not None: + self.require_quiescent(version) + # Recheck receipts at the effect boundary, then commit exactly one pointer. + self.validate() + state = dict(self.state) + if installed is not None: + state["installed"] = installed + if version != state["selected"]: + state["previous"] = state["selected"] + state["selected"] = version + atomic_json(self.root / ".selection.json", state) + self.state = state + + def install(self, version, archive=None, checksum=None, upgrade=False): + version_number(version) + current = self.state["selected"] + if version in self.state["installed"]: + require(version == current, "Version is already installed; use rollback to select it.") + if archive is not None: + archive = canonical(archive) + owned(archive) + require(archive is None or digest(archive) == checksum + == private_json(self.root / "versions" / version / ".receipt.json")["archive_sha256"], + "Installed version differs from the supplied archive.") + return + require(upgrade or current is None, "A candidate is selected; use upgrade with a newer version.") + require(current is None or version_number(version) > version_number(current), + "Upgrade must increase the prerelease version; use rollback for an installed version.") + self.require_quiescent(current) + stage = self.root / (".stage-" + uuid.uuid4().hex) + stage.mkdir(mode=0o700) + created = stage.lstat() + try: + if archive is None: + archive, checksum, release_metadata = official_archive(stage, version) + else: + archive = canonical(archive) + release_metadata = None + installation = stage / "installation" + installation.mkdir(mode=0o700) + identity, manifest = extract_archive(archive, checksum, installation / "bundle", version, + release_metadata) + # The retained launcher always executes its recorded manager. Never select + # a new layout that an older manager cannot subsequently validate/rollback. + # Manager replacement is a separate explicit, recoverable operation. + require(set(manifest) <= PAYLOAD + or digest(self.root / "manager.py") == digest(Path(__file__)), + "Optional candidate payloads require this channel's retained manager to match the installer. " + "Run upgrade-manager with this reviewed installer, or use a fresh --root; " + "the existing channel and its selection are unchanged.") + homes = {} + for name in ("native-home", "cli-home"): + (installation / name).mkdir(mode=0o700) + info = (installation / name).stat() + homes[name] = {"device": info.st_dev, "inode": info.st_ino} + write_file(installation / ".receipt.json", json_bytes({ + "schema": "hack.prerelease-version/v1", "metadata": identity, + "archive_sha256": checksum, "checksums": manifest, "homes": homes})) + sync_directory(installation) + # Final refusal happens before publication. A power loss after rename retains + # an uncommitted directory for inspection, while the old selection stays valid. + self.require_quiescent(current) + self.validate() + destination = self.root / "versions" / version + require(not destination.exists(), "Refusing to overwrite a candidate version.") + os.rename(str(installation), str(destination)) + sync_directory(destination.parent) + installed = dict(self.state["installed"]) + installed[version] = digest(destination / ".receipt.json") + state = dict(self.state, installed=installed, selected=version, previous=current) + atomic_json(self.root / ".selection.json", state) + self.state = state + finally: + # Downloads, partial extraction and an unpublished installation live only + # in this call's stage. Cleanup never decides the outcome: validate() + # tolerates a stage that could not be removed, and the selection above + # is either committed or unchanged. + with contextlib.suppress(OSError): + remove_created_stage(stage, created) + + def run(self, arguments): + version = self.state["selected"] + require(version is not None, "No candidate selected. Use your ordinary stable hack command.") + directory = self.verify_version(version, self.state["installed"][version]) + verify_signatures(directory / "bundle") + # Shared launcher locks permit ps/down alongside foreground up, while an + # exclusive selection switch refuses any in-flight launcher. Background + # runtime state is gated by the owning executor on later switches. + child = subprocess.Popen([str(directory / "bundle/hack-v5"), *arguments], + env=self.environment(version)) + handlers = {} + def forward(signum, _frame): + child.send_signal(signum) + try: + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + handlers[signum] = signal.signal(signum, forward) + code = child.wait() + return code if code >= 0 else 128 - code + finally: + for signum, handler in handlers.items(): + signal.signal(signum, handler) + + +def parser(): + arguments = argparse.ArgumentParser(description=__doc__) + arguments.add_argument("--root", type=Path, default=Path.home() / ".hack-next", + help="private canonical installation root (default ~/.hack-next)") + commands = arguments.add_subparsers(dest="command", required=True) + for command in ("install", "upgrade"): + install = commands.add_parser(command) + install.add_argument("--version", required=True) + install.add_argument("--archive", type=Path, help="explicit local archive instead of GitHub") + install.add_argument("--sha256", help="required expected digest for --archive") + rollback = commands.add_parser("rollback") + rollback.add_argument("--version", help="retained version; default previous selection") + commands.add_parser("stable", help="deselect candidate; retain all bundles and homes") + commands.add_parser("status") + commands.add_parser("upgrade-manager", help="explicitly upgrade a known channel manager, or finish its interrupted upgrade") + run = commands.add_parser("run") + run.add_argument("arguments", nargs=argparse.REMAINDER) + return arguments + + +def main(argv=None): + args = parser().parse_args(argv) + os.umask(0o077) + require(platform.system() == "Darwin" and platform.machine() == "arm64", + "Native prereleases support Apple Silicon macOS only.") + channel = Channel(args.root) + if args.command in ("install", "upgrade"): + version_number(args.version) + require(bool(args.archive) == bool(args.sha256), "--archive and --sha256 must be supplied together.") + channel.initialize() + with channel.lock(shared=args.command == "run", manager_upgrade=args.command == "upgrade-manager"): + if args.command in ("install", "upgrade"): + channel.install(args.version, args.archive, args.sha256, args.command == "upgrade") + elif args.command == "upgrade-manager": + channel.upgrade_manager() + elif args.command == "rollback": + version = args.version or channel.state["previous"] + require(version is not None and version in channel.state["installed"], "No retained rollback version.") + channel.select(version) + elif args.command == "stable": + channel.select(None) + elif args.command == "run": + arguments = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments + return channel.run(arguments) + print(json.dumps({"selected": channel.state["selected"], "previous": channel.state["previous"], + "installed": sorted(channel.state["installed"], key=version_number), + "launcher": str(channel.root / "bin/hack-next")})) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (Refusal, OSError, ValueError) as error: + print("hack-next: " + str(error), file=sys.stderr) + sys.exit(1) diff --git a/tests/native-candidate-launcher.test.ts b/tests/native-candidate-launcher.test.ts index 8bf40e18e..38a0b8b2b 100644 --- a/tests/native-candidate-launcher.test.ts +++ b/tests/native-candidate-launcher.test.ts @@ -1,55 +1,150 @@ -import { expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { chmod, copyFile, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; -test("candidate launcher selects its adjacent executor and preserves arguments and exit", async () => { - const root = await realpath(await mkdtemp(join(tmpdir(), "hack candidate "))); - try { - await copyFile("scripts/hack-v5.sh", join(root, "hack-v5")); - await Bun.write(join(root, "hack-native"), "#!/bin/sh\nexit 0\n"); - await chmod(join(root, "hack-native"), 0o755); - await Bun.write( - join(root, "hack-cli"), - '#!/bin/sh\nprintf "%s\\n" "$HACK_RUNTIME_BACKEND" "$HACK_NATIVE_BINARY" "$HACK_NATIVE_HOME" "$@"\nexit 7\n' - ); - await chmod(join(root, "hack-cli"), 0o755); - const env = { - ...process.env, +/** + * The bundled `hack-v5` entrypoint runs against a stub `hack-cli` that only + * prints the selection it received, so no candidate software, Docker engine or + * real HOME is reached. + */ +const PRINTED = [ + "HACK_RUNTIME_BACKEND", + "HACK_NATIVE_BINARY", + "HACK_NATIVE_HOME", + "HACK_HOME", + "HACK_CANDIDATE_ENTRYPOINT", +] as const; +const STUB_CLI = `#!/bin/sh\n${PRINTED.map( + (name) => `printf "%s\\n" "\${${name}-}"` +).join("\n")}\nprintf "%s\\n" "$@"\nexit 7\n`; + +let root: string; + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "hack candidate "))); + await copyFile("scripts/hack-v5.sh", join(root, "hack-v5")); + await Bun.write(join(root, "hack-native"), "#!/bin/sh\nexit 0\n"); + await chmod(join(root, "hack-native"), 0o755); + await Bun.write(join(root, "hack-cli"), STUB_CLI); + await chmod(join(root, "hack-cli"), 0o755); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +function launcherEnv(env: Readonly>) { + return privateCliEnv({ + root, + env: { HACK_NATIVE_HOME: join(root, "private home"), - HACK_RUNTIME_BACKEND: "compose", HACK_NATIVE_BINARY: "/wrong/executor", - }; - const child = Bun.spawn( - ["sh", join(root, "hack-v5"), "exec", "a b", "--", "$literal"], - { - env, - stdout: "pipe", - stderr: "pipe", - } - ); - expect(await new Response(child.stdout).text()).toBe( - `native\n${root}/hack-native\n${root}/private home\nexec\na b\n--\n$literal\n` + HACK_HOME: join(root, "caller hack home"), + ...env, + }, + }); +} + +async function launch( + env: Readonly>, + args: readonly string[] = ["exec", "a b", "--", "$literal"] +) { + const child = Bun.spawn(["sh", join(root, "hack-v5"), ...args], { + env: launcherEnv(env), + stdout: "pipe", + stderr: "pipe", + }); + return { + stdout: await new Response(child.stdout).text(), + stderr: await new Response(child.stderr).text(), + exitCode: await child.exited, + }; +} + +test.each([ + ["unset", undefined], + ["empty", ""], + ["native", "native"], +])("candidate launcher selects native and its adjacent executor when the backend is %s", async (_label, backend) => { + const result = await launch({ HACK_RUNTIME_BACKEND: backend }); + expect(result.stdout).toBe( + [ + "native", + `${root}/hack-native`, + `${root}/private home`, + `${root}/caller hack home`, + "hack-v5", + "exec", + "a b", + "--", + "$literal", + "", + ].join("\n") + ); + expect(result.exitCode).toBe(7); +}); + +test("explicit Compose selection is respected with the caller's Hack home and no native selectors", async () => { + const result = await launch({ HACK_RUNTIME_BACKEND: "compose" }); + expect(result.stdout).toBe( + [ + "compose", + "", + "", + `${root}/caller hack home`, + "hack-v5", + "exec", + "a b", + "--", + "$literal", + "", + ].join("\n") + ); + expect(result.exitCode).toBe(7); + // Compose needs no native home, so an absent or relative one does not refuse. + for (const home of [undefined, "relative"]) { + const noHome = await launch( + { HACK_RUNTIME_BACKEND: "compose", HACK_NATIVE_HOME: home }, + ["ps"] ); - expect(await child.exited).toBe(7); - for (const home of ["", "relative"]) { - const refused = Bun.spawn(["sh", join(root, "hack-v5"), "up"], { - env: { ...env, HACK_NATIVE_HOME: home }, - stdout: "pipe", - stderr: "pipe", - }); - expect(await refused.exited).toBe(64); - expect(await new Response(refused.stdout).text()).toBe(""); - } - await rm(join(root, "hack-native")); - const incomplete = Bun.spawn(["sh", join(root, "hack-v5"), "up"], { - env, - stdout: "pipe", - stderr: "pipe", - }); - expect(await incomplete.exited).toBe(69); - expect(await new Response(incomplete.stdout).text()).toBe(""); - } finally { - await rm(root, { recursive: true, force: true }); + expect(noHome.exitCode).toBe(7); + expect(noHome.stdout.split("\n").slice(0, 3)).toEqual([ + "compose", + "", + "", + ]); } }); + +test.each([ + "docker", + "Native", + "native ", +])("unknown backend %p refuses before running the CLI", async (backend) => { + const result = await launch({ HACK_RUNTIME_BACKEND: backend }, ["up"]); + expect(result.exitCode).toBe(64); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "HACK_RUNTIME_BACKEND must be native or compose" + ); +}); + +test("native selection refuses without an absolute private home", async () => { + for (const home of [undefined, "", "relative"]) { + const refused = await launch({ HACK_NATIVE_HOME: home }, ["up"]); + expect(refused.exitCode).toBe(64); + expect(refused.stdout).toBe(""); + } +}); + +test.each([ + undefined, + "compose", +])("incomplete bundle refuses on backend %p", async (backend) => { + await rm(join(root, "hack-native")); + const incomplete = await launch({ HACK_RUNTIME_BACKEND: backend }, ["up"]); + expect(incomplete.exitCode).toBe(69); + expect(incomplete.stdout).toBe(""); +}); diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py index 8d41a0c1f..f767cee17 100644 --- a/tests/python/test_prerelease_install.py +++ b/tests/python/test_prerelease_install.py @@ -135,11 +135,13 @@ def setUp(self): self.run_patch = mock.patch.object(installer.subprocess, "run", side_effect=self.process) self.run_patch.start() self.addCleanup(self.run_patch.stop) + # A manually exported native selection naming a foreign executor and home: + # the launcher must still pin its own. Backend selection has its own tests. self.env_patch = mock.patch.dict(os.environ, { "HOME": str(self.stable_home), "HACK_HOME": str(self.stable_home / ".hack"), "HACK_GLOBAL_CONFIG_PATH": str(self.stable_home / ".hack/config.json"), "HACK_NATIVE_HOME": str(self.stable_home / "foreign-native"), - "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "docker", + "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "native", "HACK_NATIVE_ADAPTATION": "/private/project/adaptation.json", "HACK_NATIVE_DEPENDENCIES": "/private/project/dependencies.json", "HACK_NATIVE_AWS_PROFILE": "fixture-qa", @@ -1334,6 +1336,8 @@ def test_manager_and_selected_receipt_changes_refuse_status(self): def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launchers(self): self.install() + # No backend selection: the channel default is native with private homes. + os.environ.pop("HACK_RUNTIME_BACKEND") child = mock.Mock() child.wait.return_value = 23 with self.channel.lock(shared=True): @@ -1347,6 +1351,7 @@ def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launche self.assertEqual(environment["HACK_NATIVE_HOME"], str(self.channel.root / "versions/5.0.0-next.1/native-home")) self.assertEqual(environment["HACK_NATIVE_BINARY"], str(self.channel.root / "versions/5.0.0-next.1/bundle/hack-native")) self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_CANDIDATE_ENTRYPOINT"], "hack-next") for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_DEPENDENCIES", "HACK_NATIVE_AWS_PROFILE", "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): self.assertEqual(environment[name], os.environ[name]) @@ -1358,6 +1363,96 @@ def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launche with self.channel.lock(shared=True), mock.patch.object(installer.subprocess, "Popen", return_value=child): self.assertEqual(self.channel.run(["ps"]), 143) + def launch(self, channel=None): + """Run the selected candidate through `channel` with a stand-in child process.""" + channel = channel or self.channel + child = mock.Mock() + child.wait.return_value = 0 + # Every manager imports the same standard-library subprocess module. + with channel.lock(shared=True), \ + mock.patch.object(installer.subprocess, "Popen", return_value=child) as spawn: + self.assertEqual(channel.run(["ps", "--path", "a path"]), 0) + self.assertEqual(spawn.call_args.args[0][1:], ["ps", "--path", "a path"]) + return Path(spawn.call_args.args[0][0]), spawn.call_args.kwargs["env"] + + def test_launcher_respects_explicit_backend_and_keeps_native_default(self): + self.install() + version = self.channel.root / "versions/5.0.0-next.1" + for selection in (None, "", "native"): + with self.subTest(backend=selection), mock.patch.dict(os.environ): + if selection is None: + os.environ.pop("HACK_RUNTIME_BACKEND") + else: + os.environ["HACK_RUNTIME_BACKEND"] = selection + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_NATIVE_BINARY"], str(version / "bundle/hack-native")) + self.assertEqual(environment["HACK_NATIVE_HOME"], str(version / "native-home")) + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.assertNotIn("HACK_GLOBAL_CONFIG_PATH", environment) + with mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": "compose"}): + command, environment = self.launch() + # Compose runs the same verified CLI with the caller's own Hack home, so the + # existing global Caddy/CoreDNS and local CA apply; native selectors are removed. + self.assertEqual(command, version / "bundle/hack-cli") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "compose") + self.assertEqual(environment["HACK_CANDIDATE_ENTRYPOINT"], "hack-next") + self.assertEqual(environment["HACK_HOME"], str(self.stable_home / ".hack")) + self.assertEqual(environment["HACK_GLOBAL_CONFIG_PATH"], str(self.stable_home / ".hack/config.json")) + self.assertNotIn("HACK_NATIVE_BINARY", environment) + self.assertNotIn("HACK_NATIVE_HOME", environment) + for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): + self.assertEqual(environment[name], os.environ[name]) + # Unknown selections refuse before verification or spawn; inherited `docker` is + # not silently replaced by either backend. + for selection in ("docker", "Native", "native "): + with self.subTest(backend=selection), \ + mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": selection}), \ + mock.patch.object(installer.subprocess, "Popen") as spawn, \ + mock.patch.object(installer, "verify_signatures") as signatures: + with self.channel.lock(shared=True): + with self.assertRaisesRegex(installer.Refusal, "must be native or compose"): + self.channel.run(["up"]) + spawn.assert_not_called() + signatures.assert_not_called() + + def test_released_next5_manager_needs_upgrade_manager_for_compose_selection(self): + source = SOURCE.parent.parent / "tests/fixtures/prerelease-manager-storage-v5.py" + predecessor = "e2e2bcbacf58b21d1086047c94727e5e814776384439700adcf5aabe5c49c2fa" + self.assertEqual(installer.digest(source), predecessor) + self.assertIn(predecessor, installer.MANAGER_PREDECESSORS) + spec = importlib.util.spec_from_file_location("storage_v5_predecessor", source) + previous = importlib.util.module_from_spec(spec) + spec.loader.exec_module(previous) + self.channel = previous.Channel(self.root / "storage-v5") + self.channel.initialize() + archive, checksum = self.archive("5.0.0-next.1", with_current_payload) + with self.channel.lock(): + self.channel.install("5.0.0-next.1", archive, checksum) + version = self.channel.root / "versions/5.0.0-next.1" + for home in ("native-home", "cli-home"): + (version / home / "marker").write_text(home) + before = self.retained_snapshot() + with mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": "compose"}): + # The released manager always selects native and its private homes. + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.channel = self.upgrade_manager() + self.assertEqual((self.channel.root / "manager.py").read_bytes(), SOURCE.read_bytes()) + self.assertEqual(self.retained_snapshot(), before) + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-cli") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "compose") + self.assertEqual(environment["HACK_HOME"], str(self.stable_home / ".hack")) + os.environ.pop("HACK_RUNTIME_BACKEND") + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.assertEqual(self.retained_snapshot(), before) + def test_host_and_local_archive_cli_contract(self): for system, machine in (("Linux", "aarch64"), ("Darwin", "x86_64")): with mock.patch.object(installer.platform, "system", return_value=system), \ diff --git a/tests/update-command.test.ts b/tests/update-command.test.ts index cf684e170..56f65decd 100644 --- a/tests/update-command.test.ts +++ b/tests/update-command.test.ts @@ -20,6 +20,7 @@ const ENV_KEYS = [ "HACK_NATIVE_HOME", "HACK_HOME", "HACK_ASSETS_DIR", + "HACK_CANDIDATE_ENTRYPOINT", ] as const; const STABLE_BYTES = "unrelated standalone stable Hack sentinel\n"; const refuseNetwork: typeof globalThis.fetch = Object.assign( @@ -50,6 +51,7 @@ beforeEach(async () => { process.env.HACK_RUNTIME_BACKEND = "native"; process.env.HACK_NATIVE_BINARY = join(root, "candidate", "hack-native"); process.env.HACK_NATIVE_HOME = join(root, "candidate-home"); + Reflect.deleteProperty(process.env, "HACK_CANDIDATE_ENTRYPOINT"); }); afterEach(async () => { @@ -146,6 +148,39 @@ test("native update --check --tag also refuses with a human diagnostic", async ( expect(await readFile(stable, "utf8")).toBe(STABLE_BYTES); }); +test.each([ + ["hack-next", "compose"], + ["hack-v5", "compose"], + ["hack-next", undefined], +])("candidate launcher %s on backend %p refuses before lookup or fetch", async (entrypoint, backend) => { + // An explicit Compose selection through a candidate launcher drops the native + // marker; the launcher's own marker must still keep update off the stable install. + process.env.HACK_CANDIDATE_ENTRYPOINT = entrypoint; + Reflect.deleteProperty(process.env, "HACK_NATIVE_BINARY"); + Reflect.deleteProperty(process.env, "HACK_NATIVE_HOME"); + if (backend === undefined) { + Reflect.deleteProperty(process.env, "HACK_RUNTIME_BACKEND"); + } else { + process.env.HACK_RUNTIME_BACKEND = backend; + } + const before = await stableIdentity(); + const fetch = spyOn(globalThis, "fetch").mockImplementation(refuseNetwork); + const lookup = spyOn(Bun, "which").mockImplementation(() => { + throw new Error("unexpected stable binary lookup"); + }); + const result = await capturedRun(["update", "--yes", "--json"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: + "Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it.", + }); + expect(fetch).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); + expect(await readFile(stable, "utf8")).toBe(STABLE_BYTES); + expect(await stableIdentity()).toEqual(before); +}); + test.each([ "compose", undefined, From a3eb34e9f247638c2e1757f0586937fa91aec21d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 13:04:41 -0400 Subject: [PATCH 08/11] docs(env): add hack project target to the command and delivery matrix The new read-only target inspector accepts --env and --service, so the env matrix source check requires a row for it. It reports only the overlay selection and never reads env files or values. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/env.md b/docs/env.md index 3452d237e..99d6adefe 100644 --- a/docs/env.md +++ b/docs/env.md @@ -262,6 +262,7 @@ into a host command, and `--target` on env and host commands chooses the `host` | `hack host shell` | `--env`, `--scope`, `--target` | When the shell starts. | Start a new shell. | Runs on the host, not in a container. | | `hack config validate` | `--env` | Per invocation; resolves the native selection without reading managed env documents. | Not needed. | Authored native projects only; cannot be combined with `--file`. | | `hack config plan` | `--env` | Per invocation; metadata only (names, scopes, secret flags), never decrypted. | Not needed. | Authored native projects only. | +| `hack project target` | `--env`, `--service` | Per invocation, read-only; reports the overlay selection (explicit, project default or base) without reading env files or values. | Not needed. | Selection only: overlay existence is not checked; no runtime required. | | `hack session start` | `--env`, `--service` | When the workspace is created; the name gains `.env-` (`.env-base` for base spellings), and `--up` runs `hack up -d` with the same selection. | Start a new workspace or pass `--env` to `hack session exec`. | Injects into the workspace shell, not into containers. | | `hack session exec` | `--env`, `--service` | Per command; omitted reuses the selection encoded in the workspace name. | Each command resolves again. | Injects into the workspace command, not into containers. | From 6530b261bb15f9dd26da4dea2c2a04006582cb4a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 13:06:11 -0400 Subject: [PATCH 09/11] fix(runtime): retire authored starts refused before journal reservation A native consumer that refuses before reserving its runtime journal (for example native_graph_admission for a repository@sha256 image, the M3 next.5 repro) leaves no receipt, so Removed retirement could never succeed and the start intent stayed pending with no supported recovery. graph native inspect now refuses the distinct native_graph_unreserved on macOS only when the run's journal directory is exactly absent under private state directories and no foreground owner is published. Startup retires the exact start intent and source for that refusal after its consumer settled, reporting outcome `unreserved` with the original native code. `down --recover` retires a pending start the same way under fresh startup admission. Every other inspection result keeps all evidence; receipt and ownership checks are unchanged. Refs HACK-1272 Co-Authored-By: Claude Opus 5.5 --- docs/reference/native-authored-commands.md | 35 +++- packages/runtime-core/README.md | 6 +- .../provider/graph/native/foreground/tests.rs | 41 +++++ .../src/provider/graph/native/journal.rs | 34 ++++ .../src/provider/graph/native/runtime.rs | 13 ++ src/backends/native-authored-project-run.ts | 146 +++++++++++----- src/backends/native-authored-project-start.ts | 72 +++++++- .../native-authored-unreserved-start.ts | 112 +++++++++++++ src/backends/native-runtime-client.ts | 8 + src/lib/native-authored-command.ts | 35 +++- tests/native-authored-command.test.ts | 79 +++++++++ tests/native-authored-project-start.test.ts | 156 +++++++++++++++++- 12 files changed, 688 insertions(+), 49 deletions(-) create mode 100644 src/backends/native-authored-unreserved-start.ts diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index e27d7f184..8c42abcd4 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -68,7 +68,7 @@ provider contact. A refusal names no option value, path or project detail. | --- | --- | --- | | `up` | Whole-project foreground; `--branch`, `--profile` and `--env` (including `--env base`) keep their compiler contracts | `--detach`, `--json`, `--recover`, service subsets, single-service, command, workdir, follow, tail and format options, other unsupported options | | `down` | Whole-project owner-mediated stop of a ready generation through its live foreground owner or private no-host endpoint | `--json`, `--detach`, `--profile` and `--env` overrides, service subsets including an empty selection, single-service, command, workdir, follow, tail and format options | -| `down --recover` | Explicit retirement of a complete stored generation whose owners are dead; omit `--profile` | `--json`, `--detach`, explicit empty or named `--profile`, `--env` overrides, service subsets, single-service, command, workdir, follow, tail and format options | +| `down --recover` | Explicit retirement of a complete stored generation whose owners are dead, or of a pending startup intent that never reserved runtime state; omit `--profile` | `--json`, `--detach`, explicit empty or named `--profile`, `--env` overrides, service subsets, single-service, command, workdir, follow, tail and format options | | `ps` | `ps` and `ps --json` for the exact saved project and explicit `--branch` | `--profile`, `--env`, service subsets or a single service, `--recover`, `--detach`, command, workdir, follow, tail and format options | | `logs` | `logs SERVICE --no-follow` with `--tail` from 1 through 1000 (default 200), plain or `--json` | Follow mode (the default), omitted, multiple or malformed service selection, non-integer or out-of-range `--tail`, pretty format, time and query filters, `--profile`, `--env`, `--recover`, `--detach`, command and workdir options | | `exec` | `exec SERVICE -- COMMAND ARG...` with closed stdin, no TTY and an optional absolute `--workdir` | Empty command or empty first argument, NUL bytes, more than 256 arguments, an argument over 16 KiB or 64 KiB in total, relative `--workdir`, interactive or TTY input, `--json`, `--profile`, `--env`, service subsets, follow, tail and format options, `--recover`, `--detach` | @@ -90,6 +90,21 @@ inspection also refuses, the captured native startup code takes precedence over the inspection code. That preserves the failure's owning boundary; it does not prove cleanup. Retained bindings and recovery requirements stay in force. These diagnostics do not grant retry or cleanup authority. + +A native consumer can refuse after the startup intent is reserved but before it +reserves any runtime journal. For example, a `repository@sha256:` image reaches +execution and refuses `native_graph_admission`. Such a run has no receipt, so the +Removed retirement inspection can never succeed. After the consumer process has +settled, startup asks the native runtime to inspect the exact run. Startup retires +the startup intent and its source envelope only when the runtime refuses +`native_graph_unreserved`: the run's journal directory is exactly absent under +private state directories, and no foreground owner is published for the run. The +failure then reports outcome `unreserved` with the original native code, and `ps` +reports `not_started`. Any other inspection result keeps the attempt retained. +Examples include `native_graph_receipt` for damaged or foreign evidence, +`native_graph_foreground` for a published owner, and a stopped runtime. This +applies only to attempts without lifecycle hooks, host processes or a live-stop +owner. A canceled `unreserved` startup exits 130, like confirmed removal. Readiness requires authenticated current observations and unchanged input before publication. The foreground owner remains attached until owned shutdown finishes; Ctrl-C requests that shutdown. Exact durable Removed evidence retires the tagged @@ -107,7 +122,10 @@ Only fresh runtime observations supply state and health. Stored readiness and ph do not prove that a service is still live. A project with no saved attempt reports `not_started`; a retained startup intent -without a Ready mapping reports `pending` with no confirmed service rows. When a +without a Ready mapping reports `pending` with no confirmed service rows. That +state covers a startup in progress and a retained incomplete attempt. When no +startup is running, `down --recover` can retire the attempt if it never reserved +runtime state (see below). When a Ready generation is saved but the candidate runtime is stopped (native code `runtime_not_running`, or the authenticated `native_graph_foreground` control code with that exact cause), `ps` exits 0 and reports `runtime_not_running` with the @@ -184,6 +202,19 @@ nor acquires managed values or starts a provider. Omit `--profile` to recover the stored generation; explicit empty or named profile overrides both refuse. +When the selection has a retained startup intent but no Ready mapping and no +recovery intent, `down --recover` takes a different path. It handles only an +incomplete startup that never reserved runtime state. One example is an attempt +retained by an earlier CLI before this retirement existed. The command first +takes a fresh startup admission. A live or interrupted foreground CLI still holds +that admission, so the command refuses as busy in that case. It also requires that +no Ready mapping, lifecycle-hook owner or live-stop owner exists. It then inspects +the exact run. It retires the start intent and the validated source envelope only +when the native runtime refuses `native_graph_unreserved`. It removes the source +first, so an interrupted retirement keeps the start intent blocking. Any receipt, +any other refusal, or a successful inspection retains every file. This path does +not compile input, acquire values, serve a run or touch guest resources. + The closed version 3 publication and version 1 recovery selector remain supported with their original calendar boot qualifier; clock drift conservatively refuses that legacy recovery. New version 4 owners use version 2 UUID selectors. Missing, diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index eb308245d..db04d5344 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -264,7 +264,11 @@ ordinary frontend selection yet and does not implement reactive health or hooks. The optional feature exposes this bounded consumer through a distinct public CLI: `graph native plan --source-file FILE --json`, then `graph native run --source-file FILE --expect-review SHA --json`. -`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. These commands +`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. On macOS, +`inspect` refuses `native_graph_unreserved` before guest access only when the run's journal +directory is exactly absent under private state directories and no foreground owner is +published for the run. It is a point-in-time observation, not proof that no consumer can +still start. A partial, public or present journal keeps the ordinary receipt refusal. These commands are explicit bounded prerequisites, not full ordinary project parity. Their project network does not publish ports or grant host-service access. Normal native `hack up` continues to require the full source/storage/routing/host and foreground-owner contract. diff --git a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs index af74ff024..165e4a529 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs @@ -586,6 +586,47 @@ fn abandoned_publication_is_evidence_and_never_implicit_recovery_authority() { assert!(fixture.owner_root().join("owner.json").exists()); } +#[test] +fn inspect_names_only_an_exactly_absent_unpublished_journal_as_unreserved() { + // HACK-1272: a startup refused before reservation must be distinguishable from + // damaged or foreign receipt evidence, without weakening either refusal. + let fixture = Fixture::new(); + let code = |fixture: &Fixture| inspect(&fixture.candidate, RUN).unwrap_err().code; + assert_eq!(code(&fixture), "native_graph_unreserved"); + assert!( + !journal::directory(&fixture.candidate, RUN) + .unwrap() + .exists() + ); + + // A published (live or abandoned) owner is evidence, never "unreserved". + let receipt = fixture.receipt(); + let mut publication = owner::Publication::bind(&fixture.candidate, &receipt.review).unwrap(); + assert_eq!(code(&fixture), "native_graph_foreground"); + publication.finish().unwrap(); + assert_eq!(code(&fixture), "native_graph_unreserved"); + + // A partial journal directory keeps the ordinary receipt refusal. + let root = journal::directory(&fixture.candidate, RUN).unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .recursive(true) + .create(&root) + .unwrap(); + assert_ne!(code(&fixture), "native_graph_unreserved"); + fs::remove_dir(&root).unwrap(); + + // A public ancestor refuses instead of proving absence. + let parent = root.parent().unwrap(); + fs::set_permissions(parent, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(code(&fixture), "native_graph_receipt"); + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).unwrap(); + + // A reserved journal is validated as before. + journal::reserve(&fixture.candidate, &receipt).unwrap(); + assert_ne!(code(&fixture), "native_graph_unreserved"); +} + #[test] fn direct_operation_guard_refuses_replaced_lock_path() { let fixture = Fixture::new(); diff --git a/packages/runtime-core/src/provider/graph/native/journal.rs b/packages/runtime-core/src/provider/graph/native/journal.rs index 576f85a77..75908f5f4 100644 --- a/packages/runtime-core/src/provider/graph/native/journal.rs +++ b/packages/runtime-core/src/provider/graph/native/journal.rs @@ -555,6 +555,40 @@ fn valid_network_name(value: &str) -> bool { || matches!(*byte, b'-' | b'_' | b'.') }) } +/// Stable refusal for a run that never reserved native runtime state. +#[cfg(target_os = "macos")] +pub(super) fn unreserved() -> CandidateError { + error( + "native_graph_unreserved", + "Native runtime state for this run was never reserved: its journal is absent and no foreground owner is published. This point-in-time observation does not prove that no consumer can still start; no state was created, repaired or adopted.", + ) +} +/// True only when this run's journal directory is exactly absent beneath private +/// existing ancestors. A present, partial or unreadable path returns false so the +/// ordinary receipt validation still owns it. Read-only; nothing is created. +#[cfg(target_os = "macos")] +pub(super) fn absent(candidate: &Candidate, run: &str) -> Result { + let root = directory(candidate, run)?; + match fs::symlink_metadata(&root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return Ok(false), + } + for path in root + .ancestors() + .skip(1) + .take_while(|path| path.starts_with(&candidate.state_root)) + { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => state::check_private_directory(path).map_err(|_| refused())?, + Err(_) => return Err(refused()), + } + } + Ok( + fs::symlink_metadata(&root) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound), + ) +} pub(super) fn directory(candidate: &Candidate, run: &str) -> Result { if !hex(run, 32) { return Err(refused()); diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index aa8acd468..074c93ea7 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -1286,7 +1286,20 @@ fn logs_with( } /// Read-only same-incarnation inspection. A historical ready phase is not current health. +/// +/// A run whose journal is exactly absent and whose foreground owner is not +/// published refuses `native_graph_unreserved` before any guest access. Absence +/// is a point-in-time observation: only a caller that separately proved its +/// consumer settled may treat it as "never reserved". Every other journal state +/// keeps the ordinary receipt validation below. pub fn inspect(candidate: &Candidate, run: &str) -> Result { + #[cfg(target_os = "macos")] + if journal::absent(candidate, run)? { + super::foreground::require_unpublished(candidate, run)?; + if journal::absent(candidate, run)? { + return Err(journal::unreserved()); + } + } let engine = Engine::connect_cleanup(candidate)?; let (receipt, _) = journal::load( candidate, diff --git a/src/backends/native-authored-project-run.ts b/src/backends/native-authored-project-run.ts index 316d8f7b7..308311c52 100644 --- a/src/backends/native-authored-project-run.ts +++ b/src/backends/native-authored-project-run.ts @@ -45,6 +45,10 @@ import { requestNativeAuthoredLiveStop, } from "./native-authored-live-stop.ts"; import type { NativeAuthoredProcessIncarnation } from "./native-authored-process-incarnation.ts"; +import { + NATIVE_GRAPH_UNRESERVED, + NativeRuntimeRequestError, +} from "./native-runtime-client.ts"; const LIMIT = 64 * 1024; const SOURCE_LIMIT = 1024 * 1024; @@ -140,6 +144,19 @@ export type NativeAuthoredProjectAdmission = { readonly expectedRun?: NativeAuthoredProjectRunSelection; readonly cleaned: NativeAuthoredReceipt; }) => Promise; + /** + * Retire a start intent whose native consumer never reserved runtime state + * (HACK-1272). `refusal` must be the fresh `native_graph_unreserved` inspection + * refusal for this exact run, obtained while this admission is held and after + * the caller proved no consumer for the run can still start. The ready mapping, + * hook owner and live-stop owner must be absent. The run's retained source is + * removed first (absence is accepted) so an interruption keeps the start + * intent blocking. No receipt, journal or guest state is read or changed here. + */ + readonly retireUnreserved: (opts: { + readonly expectedStart: NativeAuthoredProjectStartSelection; + readonly refusal: unknown; + }) => Promise; }; function refused(): never { throw new Error( @@ -540,43 +557,7 @@ export async function withNativeAuthoredProjectRecoveryStorage( ); }, async readSource(run) { - const current = await readPrivate(sourcePath(run), SOURCE_LIMIT); - const value: unknown = JSON.parse(current.text); - if ( - !( - isRecord(value) && - keys( - value, - value.version === 3 || value.version === 5 - ? "branch,env_metadata,hook_permit,kind,overlay,profiles,project,run,version" - : "branch,env_metadata,kind,overlay,profiles,project,run,version" - ) && - (value.version === 2 || - ((value.version === 3 || value.version === 5) && - validHookPin(value.hook_permit))) - ) || - value.kind !== "native-graph-source" || - value.project !== store.identity.projectRoot || - value.branch !== store.identity.branch || - value.run !== run || - !Array.isArray(value.profiles) || - value.profiles.length > 64 || - value.profiles.some( - (profile: unknown) => - typeof profile !== "string" || !validBranch(profile) - ) || - !( - value.overlay === "inherit" || - value.overlay === "base" || - (isRecord(value.overlay) && - keys(value.overlay, "named") && - typeof value.overlay.named === "string" && - validBranch(value.overlay.named)) - ) || - !parseNativeEnvMetadata(value.env_metadata) - ) { - return refused(); - } + const current = await readRetainedSource(store, sourcePath(run), run); await store.check(); return { dev: current.info.dev, @@ -587,6 +568,70 @@ export async function withNativeAuthoredProjectRecoveryStorage( }); }); } +/** Read one retained private source envelope and require its exact kind, scope and run. */ +async function readRetainedSource(store: Store, path: string, run: string) { + const current = await readPrivate(path, SOURCE_LIMIT); + const value: unknown = JSON.parse(current.text); + if ( + !( + isRecord(value) && + keys( + value, + value.version === 3 || value.version === 5 + ? "branch,env_metadata,hook_permit,kind,overlay,profiles,project,run,version" + : "branch,env_metadata,kind,overlay,profiles,project,run,version" + ) && + (value.version === 2 || + ((value.version === 3 || value.version === 5) && + validHookPin(value.hook_permit))) + ) || + value.kind !== "native-graph-source" || + value.project !== store.identity.projectRoot || + value.branch !== store.identity.branch || + value.run !== run || + !Array.isArray(value.profiles) || + value.profiles.length > 64 || + value.profiles.some( + (profile: unknown) => typeof profile !== "string" || !validBranch(profile) + ) || + !( + value.overlay === "inherit" || + value.overlay === "base" || + (isRecord(value.overlay) && + keys(value.overlay, "named") && + typeof value.overlay.named === "string" && + validBranch(value.overlay.named)) + ) || + !parseNativeEnvMetadata(value.env_metadata) + ) { + return refused(); + } + return current; +} +/** Remove this run's exact validated source envelope; an absent source is accepted. */ +async function removeRetainedSource( + store: Store, + path: string, + run: string, + check: () => Promise +): Promise { + let source: Awaited>; + try { + source = await readRetainedSource(store, path, run); + } catch (error) { + if (hasCode(error, "ENOENT")) { + return; + } + return refused(); + } + await check(); + const current = await readPrivate(path, SOURCE_LIMIT); + if (!sameFile(source.info, current.info) || current.text !== source.text) { + return refused(); + } + await unlink(path); + await synchronizeDirectories(store.held); +} async function read(store: Store): Promise { return await readRecord(store, store.file, selected); } @@ -1097,6 +1142,33 @@ export async function withNativeAuthoredProjectAdmission( await synchronizeDirectories(store.held); }); }, + async retireUnreserved(input) { + const expected = selection(input.expectedStart, started); + const run = expected.record.review.provenance.run; + if ( + !( + input.refusal instanceof NativeRuntimeRequestError && + input.refusal.nativeCode === NATIVE_GRAPH_UNRESERVED && + HEX32.test(run) + ) || + (await loadNativeAuthoredHookOwner(hookStore)) !== null + ) { + return refused(); + } + const sourcePath = join(store.root.path, `${run}.source.json`); + await withMutation(async (check) => { + await unchanged(store, store.startFile, expected, started); + await absent(store.file); + await absent(liveStopPath(store)); + await removeRetainedSource(store, sourcePath, run, check); + // If retirement stops here, the durable start continues to block a new run. + await unchanged(store, store.startFile, expected, started); + await absent(store.file); + await check(); + await unlink(store.startFile); + await synchronizeDirectories(store.held); + }); + }, }; try { return await action(Object.freeze(capability)); diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index 6133d9cd9..b2b28a943 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -63,6 +63,7 @@ import { } from "./native-project-process.ts"; import { invokeNativeRuntime, + NATIVE_GRAPH_UNRESERVED, NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; @@ -71,7 +72,12 @@ const RUN = /^[a-f0-9]{32}$/; const CODE = /^[a-z][a-z0-9_]{0,63}$/; const PRIVATE_LIMIT = 256 * 1024; type Inputs = Awaited>; -type Outcome = "not-started" | "removed" | "retained"; +/** + * `unreserved`: the native consumer settled after refusing before it reserved + * any runtime journal or published a receipt, and the exact startup intent and + * source were retired. Nothing is retained. + */ +type Outcome = "not-started" | "removed" | "retained" | "unreserved"; const STAGES = [ "selection", "admission", @@ -137,6 +143,8 @@ export class NativeAuthoredProjectStartError extends Error { const detail = { "not-started": "no native consumer was started", removed: "exact native cleanup was confirmed", + unreserved: + "native runtime state was never reserved; the startup intent was retired and nothing is retained", retained: "startup evidence is retained; inspect owned state before retrying", }[opts.outcome]; @@ -428,6 +436,42 @@ async function retireAttempt(opts: { await attempt.source.remove(); } +/** + * HACK-1272: a consumer that refused before journal reservation (for example + * `native_graph_admission` for a non-content-ID image) leaves no receipt, so + * Removed retirement can never succeed. Retire the exact start intent and + * source only when the settled consumer's failure was never observed as a + * receipt and fresh inspection names the run `native_graph_unreserved` (an + * absent journal and no published foreground owner). Every other inspection + * refusal keeps all evidence. + */ +async function retireUnreservedAttempt(opts: { + readonly admission: NativeAuthoredProjectAdmission; + readonly attempt: Attempt; + readonly failure: unknown; + readonly refusal: unknown; +}): Promise { + if ( + opts.failure === undefined || + opts.attempt.observed !== undefined || + opts.attempt.ready !== undefined || + !( + opts.refusal instanceof NativeRuntimeRequestError && + opts.refusal.nativeCode === NATIVE_GRAPH_UNRESERVED + ) + ) { + return false; + } + // The exact source this attempt wrote is still unchanged; retirement removes + // it before the start intent so an interruption keeps the intent blocking. + await opts.attempt.source.assertFresh(); + await opts.admission.retireUnreserved({ + expectedStart: opts.attempt.start, + refusal: opts.refusal, + }); + return true; +} + const HOOK_STAGES: Record = { "up.before": "hook-up-before", "up.after": "hook-up-after", @@ -534,6 +578,7 @@ async function runPreparedLifecycle(ctx: { }) => void; readonly published: () => void; readonly removed: () => void; + readonly unreserved: () => void; readonly hookRetired: () => void; readonly nativeCode: (code: string | undefined) => void; }): Promise { @@ -677,7 +722,25 @@ async function runPreparedLifecycle(ctx: { await ctx.hookOwner.retire(); ctx.hookRetired(); } - await retireAttempt({ ...retirement, beforeRetire: liveStop?.retire }); + try { + await retireAttempt({ ...retirement, beforeRetire: liveStop?.retire }); + } catch (refusal) { + if ( + ctx.hookOwner !== undefined || + liveStop !== undefined || + !(await retireUnreservedAttempt({ + admission: ctx.admission, + attempt: ctx.admitted, + failure, + refusal, + })) + ) { + throw refusal; + } + ctx.unreserved(); + ctx.completeStop(false); + throw failure; + } ctx.removed(); ctx.completeStop(failure === undefined); if (failure) { @@ -1065,6 +1128,9 @@ export async function serveNativeAuthoredProject( removed: () => { outcome = "removed"; }, + unreserved: () => { + outcome = "unreserved"; + }, hookRetired: () => { hookOwner = undefined; }, @@ -1101,7 +1167,7 @@ export async function serveNativeAuthoredProject( host: hostProcesses, hookStop, storageTool, - retained: outcome !== "removed", + retained: outcome !== "removed" && outcome !== "unreserved", }); } } diff --git a/src/backends/native-authored-unreserved-start.ts b/src/backends/native-authored-unreserved-start.ts new file mode 100644 index 000000000..51e71ac31 --- /dev/null +++ b/src/backends/native-authored-unreserved-start.ts @@ -0,0 +1,112 @@ +import { + loadNativeAuthoredProjectRun, + type NativeAuthoredProjectRunScope, + withNativeAuthoredProjectAdmission, + withNativeAuthoredProjectStatus, +} from "./native-authored-project-run.ts"; +import { + invokeNativeRuntime, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +function refused(): never { + throw new Error( + "Native startup intent is not a provably unreserved attempt; selected state is retained. Values omitted." + ); +} + +/** + * Read-only selection for explicit recovery: true only when a startup intent is + * retained without a Ready mapping or a frontend recovery intent. Any refusal + * (including a saved recovery intent) selects the stored-generation owner, which + * keeps its own refusals. Nothing is created, locked or repaired here. + */ +export async function nativeAuthoredUnreservedStartSelected( + scope: NativeAuthoredProjectRunScope +): Promise { + try { + return await withNativeAuthoredProjectStatus( + { ...scope }, + async (saved) => await Promise.resolve(saved.pending) + ); + } catch { + return false; + } +} + +/** + * Explicit `down --recover` for a startup intent whose native consumer never + * reserved runtime state (HACK-1272). It holds a fresh startup admission, so no + * live or interrupted foreground CLI (and therefore no consumer it spawned) can + * still own this attempt; a stale admission lock refuses as busy. Under that + * admission it requires no Ready mapping, hook owner or live-stop owner, asks the + * native runtime to inspect the exact run, and retires the start intent and its + * source only for the exact `native_graph_unreserved` refusal (journal absent and + * no foreground owner published). Any receipt, any other refusal, or a successful + * inspection retains every file. No input is acquired and nothing is served, + * replayed or cleaned in the guest. + */ +export async function retireNativeAuthoredUnreservedStart(opts: { + readonly scope: NativeAuthoredProjectRunScope; + readonly runtime: NativeRuntimeSelection; + readonly timeoutMs: number; + readonly signal?: AbortSignal; + /** Exact public native-request seam for isolated owner tests. */ + readonly request?: typeof invokeNativeRuntime; +}): Promise<{ readonly run: string }> { + const scope = { ...opts.scope }; + const runtime = { ...opts.runtime }; + const { timeoutMs, signal } = opts; + const request = opts.request ?? invokeNativeRuntime; + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > 300_000 || + signal?.aborted || + runtime.home !== scope.nativeHome + ) { + return refused(); + } + const deadline = performance.now() + timeoutMs; + const remaining = () => { + const value = Math.floor(deadline - performance.now()); + if (value < 1 || signal?.aborted) { + return refused(); + } + return value; + }; + return await withNativeAuthoredProjectAdmission(scope, async (admission) => { + const start = await admission.loadStart(); + if ( + start === null || + (await admission.hooksRetained()) || + (await admission.liveStopRetained()) || + (await loadNativeAuthoredProjectRun(scope)) !== null + ) { + return refused(); + } + const run = start.record.review.provenance.run; + remaining(); + let refusal: unknown; + try { + await request({ + runtime, + cwd: scope.projectRoot, + args: ["graph", "native", "inspect", "--run-id", run, "--json"], + timeoutMs: remaining(), + signal, + boundNativeAuthoredReadDrain: true, + }); + } catch (error) { + refusal = error; + } + if (refusal === undefined) { + // A journal exists; this attempt belongs to receipt-owned recovery. + return refused(); + } + remaining(); + await admission.assertHeld(); + await admission.retireUnreserved({ expectedStart: start, refusal }); + return { run }; + }); +} diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 765b5d77e..7d9e8a9be 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -41,6 +41,14 @@ export class NativeRuntimeRequestError extends ClosedCliError { } } +/** + * Native inspection code for a run whose journal is exactly absent and whose + * foreground owner is not published: no runtime state was ever reserved. It is + * point-in-time only; a caller must separately prove the run's consumer settled + * (or cannot exist) before treating it as retirement authority. + */ +export const NATIVE_GRAPH_UNRESERVED = "native_graph_unreserved"; + /** Native code for a read against a candidate runtime that is not running. */ export const NATIVE_RUNTIME_NOT_RUNNING = "runtime_not_running"; diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 0187256bb..6690afa85 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -25,6 +25,10 @@ import { NativeAuthoredProjectStartError, serveNativeAuthoredProject, } from "../backends/native-authored-project-start.ts"; +import { + nativeAuthoredUnreservedStartSelected, + retireNativeAuthoredUnreservedStart, +} from "../backends/native-authored-unreserved-start.ts"; import { resolveNativeAuthoredExecStaleEnvWarning } from "../backends/native-exec-stale-env.ts"; import { parseNativeHttpsSelection } from "../backends/native-project-start.ts"; import { @@ -205,7 +209,10 @@ function assertForegroundOptions(options: NativeComposeCommandOptions): void { function startupFailure(error: unknown): number { if (error instanceof NativeAuthoredProjectStartError) { - if (error.canceled && error.outcome === "removed") { + if ( + error.canceled && + (error.outcome === "removed" || error.outcome === "unreserved") + ) { return 130; } throw new HackCliError({ @@ -296,7 +303,7 @@ const STATUS_NOTES: Readonly< > = { not_started: "Native project has not been started.", pending: - "Native project startup is pending; no current service status is confirmed.", + "Native project startup is pending or incomplete; no current service status is confirmed. If no startup is running, `hack down --recover` retires an attempt that never reserved runtime state.", runtime_not_running: "Native runtime is not running; no services are running.", }; @@ -369,6 +376,9 @@ type Seams = { /** Execution seam for command-selection controls; the CLI always uses the real owner. */ readonly serve?: typeof serveNativeAuthoredProject; readonly recover?: typeof recoverNativeAuthoredProject; + /** Pending-start selection and unreserved retirement seams (HACK-1272). */ + readonly unreservedSelected?: typeof nativeAuthoredUnreservedStartSelected; + readonly retireUnreserved?: typeof retireNativeAuthoredUnreservedStart; readonly observe?: typeof nativeAuthoredProjectPs; readonly logs?: typeof nativeAuthoredProjectLogs; readonly stop?: typeof stopNativeAuthoredProject; @@ -408,6 +418,27 @@ async function runStatus(input: LifecycleInput): Promise { } async function runRecovery(input: LifecycleInput): Promise { + if ( + await ( + input.seams.unreservedSelected ?? nativeAuthoredUnreservedStartSelected + )(input.scope) + ) { + // A startup intent without a Ready generation: only a provably unreserved + // attempt is retired; every other retained attempt refuses unchanged. + await (input.seams.retireUnreserved ?? retireNativeAuthoredUnreservedStart)( + { + runtime: input.runtime, + scope: input.scope, + timeoutMs: input.startupTimeoutMs, + signal: input.signal, + } + ); + logger.info({ + message: + "Retired an incomplete native startup that never reserved runtime state; nothing else was changed.", + }); + return 0; + } await (input.seams.recover ?? recoverNativeAuthoredProject)({ runtime: input.runtime, scope: input.scope, diff --git a/tests/native-authored-command.test.ts b/tests/native-authored-command.test.ts index cbb484264..b491bef7c 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -226,6 +226,85 @@ macTest( } ); +macTest( + "HACK-1272: down --recover retires a pending unreserved start without the generation owner", + async () => { + const selected = await fixture(); + const options = { + ...selected.options, + operation: "down" as const, + recover: true, + instance: "alpha", + }; + const scope = { + projectRoot: selected.root, + projectDir: join(selected.root, ".hack"), + nativeHome: join(selected.root, "candidate"), + branch: "alpha", + }; + const retired: unknown[] = []; + const outcome = (retire: () => Promise<{ readonly run: string }>) => + tryNativeAuthoredCommand({ + ...selected, + options, + serve: () => { + throw new Error("Recovery cannot invoke startup."); + }, + recover: () => { + throw new Error("A pending start has no stored generation."); + }, + unreservedSelected: (value) => { + expect(value).toEqual(scope); + return Promise.resolve(true); + }, + retireUnreserved: (value) => { + retired.push(value); + return retire(); + }, + }).catch((error: unknown) => error); + expect(await outcome(() => Promise.resolve({ run: "a".repeat(32) }))).toBe( + 0 + ); + expect(retired).toHaveLength(1); + expect(retired[0]).toMatchObject({ scope, timeoutMs: 1500 }); + const refused = await outcome(() => + Promise.reject(new Error("Private retained detail.")) + ); + expect(refused).toHaveProperty("code", "E_LIFECYCLE_FAILED"); + expect(String(refused)).not.toContain("Private retained detail"); + expect(await readdir(join(selected.root, ".hack"))).toEqual([]); + } +); + +macTest( + "HACK-1272: a canceled unreserved startup exits 130 like confirmed removal", + async () => { + const selected = await fixture(); + for (const [outcome, expected] of [ + ["unreserved", 130], + ["retained", "E_STARTUP_INCOMPLETE"], + ] as const) { + const result = await tryNativeAuthoredCommand({ + ...selected, + serve: () => + Promise.reject( + new NativeAuthoredProjectStartError({ + outcome, + canceled: true, + stage: "runtime", + nativeCode: "native_graph_canceled", + }) + ), + }).catch((error: unknown) => error); + if (typeof expected === "number") { + expect(result).toBe(expected); + } else { + expect(result).toHaveProperty("code", expected); + } + } + } +); + macTest( "recovery refuses env/profile/effect overrides before runtime pin or stored owner selection", async () => { diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 437adc5ec..3fcfd9d0f 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -6,12 +6,14 @@ import { mkdir, mkdtemp, readdir, + readFile, realpath, rm, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { nativeAuthoredProjectPs } from "../src/backends/native-authored-project-observe.ts"; import { loadNativeAuthoredProjectRun, stopNativeAuthoredProject, @@ -26,6 +28,14 @@ import { NATIVE_STORAGE_TOOL_MANIFEST, nativeStorageToolManifest, } from "../src/backends/native-authored-storage-tool.ts"; +import { + nativeAuthoredUnreservedStartSelected, + retireNativeAuthoredUnreservedStart, +} from "../src/backends/native-authored-unreserved-start.ts"; +import { + type invokeNativeRuntime, + NativeRuntimeRequestError, +} from "../src/backends/native-runtime-client.ts"; import { type ComposeEnvFingerprintRecord, evaluateComposeServiceEnvFreshness, @@ -198,6 +208,10 @@ type FixtureOptions = { readonly semanticAfterHook?: boolean; readonly planFailure?: boolean; readonly failBeforeReceipt?: boolean; + /** Native startup code for failBeforeReceipt; defaults to graph_network_intent. */ + readonly failBeforeReceiptCode?: string; + /** Inspection after failBeforeReceipt proves the journal was never reserved. */ + readonly unreservedInspection?: boolean; readonly foreignPlan?: boolean; readonly foreignNamespace?: boolean; readonly branch?: string | null; @@ -444,7 +458,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ const digests=Object.fromEntries(Object.entries(services).map(([name,values])=>[name,Object.fromEntries(Object.entries(values).map(([key,value])=>[key,createHash('sha256').update(value).digest('hex')]))])); await writeFile(${JSON.stringify(delivery)},JSON.stringify({version:input.version,kind:input.kind,review:input.review,run:input.run,lifetime_seconds:input.lifetime_seconds,keys,digests,expectedDigest:${JSON.stringify(digest)}})); } - if(${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:'graph_network_intent',message:${JSON.stringify(CANARY)}}));process.exit(2)} + if(${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:${JSON.stringify(options.failBeforeReceiptCode ?? "graph_network_intent")},message:${JSON.stringify(CANARY)}}));process.exit(2)} const receipt={version:2,kind:'native-graph-runtime',owner:'f'.repeat(32),boot:'00000000-0000-0000-0000-000000000001',review,phase:'ready-observed',readiness:{web:'started'},resources:{'network:default':{kind:'network',key:'default',name:'hkn-'+input.run+'-network-0',id:'1'.repeat(64),image:null,phase:'created',outbound:true},'container:web':{kind:'container',key:'web',name:'hkn-'+input.run+'-container-0',id:'2'.repeat(64),image:'sha256:'+ '3'.repeat(64),phase:'started',networks:['default']}}}; if(${options.storage === true && options.liveStop === true}){ const namespace=provenance.namespace, owner='9'.repeat(32); @@ -469,7 +483,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ } await finish(); }else{ - if(action==='inspect'&&${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:'native_graph_receipt',message:${JSON.stringify(CANARY)}}));process.exit(2)} + if(action==='inspect'&&${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:${JSON.stringify(options.unreservedInspection === true ? "native_graph_unreserved" : "native_graph_receipt")},message:${JSON.stringify(CANARY)}}));process.exit(2)} if(action==='inspect'&&${JSON.stringify(options.cleanup)}==='missing'){console.error(JSON.stringify({code:'graph_owner_recovery',message:'private-missing-journal-detail'}));process.exit(2)} const receipt=JSON.parse(await readFile(journal,'utf8')); const observations={web:receipt.phase==='removed'?null:{state:'running',health:'none'}}; @@ -505,7 +519,7 @@ async function artifacts(scope: Awaited>["scope"]) { } async function failure( value: Promise, - outcome: "not-started" | "removed" | "retained" + outcome: "not-started" | "removed" | "retained" | "unreserved" ) { const error: unknown = await value.catch((caught: unknown) => caught); expect(error).toBeInstanceOf(NativeAuthoredProjectStartError); @@ -534,7 +548,141 @@ macTest( expect(retained).toHaveLength(2); expect(retained.some((name) => name.endsWith(".start.json"))).toBe(true); expect(retained.some((name) => name.endsWith(".source.json"))).toBe(true); - } + }, + 30_000 +); + +macTest( + "HACK-1272: a consumer refused before journal reservation retires its exact start intent", + async () => { + // M3 next.5 repro: a repository@digest image passes planning, then the + // native consumer refuses native_graph_admission before reserving a journal. + // Retirement inspection can never authenticate a Removed receipt; only the + // exact native_graph_unreserved refusal may retire the attempt. + const selected = await fixture({ + failBeforeReceipt: true, + failBeforeReceiptCode: "native_graph_admission", + unreservedInspection: true, + }); + const error = await failure( + serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 60_000, + }), + "unreserved" + ); + expect(error).toHaveProperty("nativeCode", "native_graph_admission"); + expect(error).toHaveProperty("stage", "runtime"); + expect(String(error)).toContain("never reserved"); + expect(await artifacts(selected.scope)).toEqual([]); + const status = await nativeAuthoredProjectPs({ + runtime: selected.runtime, + scope: selected.scope, + invoke: () => { + throw new Error("No runtime request for an absent attempt."); + }, + }); + expect(status.status).toBe("not_started"); + const calls = (await readFile(selected.calls, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)[4]); + expect(calls).toEqual(["plan", "serve", "inspect"]); + }, + 90_000 +); + +macTest( + "HACK-1272: down --recover retires only a provably unreserved retained start", + async () => { + // Reproduce the next.5 retained state: inspection named the absent journal + // native_graph_receipt, so start and source were retained and ps is pending. + const selected = await fixture({ + failBeforeReceipt: true, + failBeforeReceiptCode: "native_graph_admission", + }); + await failure( + serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 60_000, + }), + "retained" + ); + expect(await artifacts(selected.scope)).toHaveLength(2); + const ps = () => + nativeAuthoredProjectPs({ + runtime: selected.runtime, + scope: selected.scope, + invoke: () => { + throw new Error("No runtime request without a Ready mapping."); + }, + }); + expect((await ps()).status).toBe("pending"); + expect(await nativeAuthoredUnreservedStartSelected(selected.scope)).toBe( + true + ); + const requests: string[][] = []; + const refusing = + (code: string | null): typeof invokeNativeRuntime => + (input) => { + requests.push([...input.args]); + if (code === null) { + return Promise.resolve({ receipt: {}, observations: {} }); + } + return Promise.reject( + new NativeRuntimeRequestError({ + message: "synthetic refusal", + nativeCode: code, + }) + ); + }; + const recover = (code: string | null) => + retireNativeAuthoredUnreservedStart({ + scope: selected.scope, + runtime: selected.runtime, + timeoutMs: 10_000, + request: refusing(code), + }); + // Damaged/foreign receipt evidence, a published owner, a stopped runtime + // and a present journal all keep every file. + for (const code of [ + "native_graph_receipt", + "native_graph_foreground", + "runtime_not_running", + null, + ]) { + expect( + await recover(code).catch((caught: unknown) => caught) + ).toBeInstanceOf(Error); + expect(await artifacts(selected.scope)).toHaveLength(2); + expect((await ps()).status).toBe("pending"); + } + expect(await recover("native_graph_unreserved")).toEqual({ run }); + expect(requests.at(-1)).toEqual([ + "graph", + "native", + "inspect", + "--run-id", + run, + "--json", + ]); + expect(await artifacts(selected.scope)).toEqual([]); + expect((await ps()).status).toBe("not_started"); + expect(await nativeAuthoredUnreservedStartSelected(selected.scope)).toBe( + false + ); + // Nothing remains to retire; a second recovery refuses without a request. + const before = requests.length; + expect( + await recover("native_graph_unreserved").catch( + (caught: unknown) => caught + ) + ).toBeInstanceOf(Error); + expect(requests).toHaveLength(before); + }, + 90_000 ); test("startup diagnostics accept only closed compiler codes", () => { From 706e956713f8c2951b930e4c165739b0f74e3c0b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 13:30:44 -0400 Subject: [PATCH 10/11] test(run): keep the P11 exit-status fixture independent of host Hack state The hosted macOS test job printed CoreDNS/Caddy reachability warnings on hack run stdout, which the exact stdout assertion rejected. Turn internal DNS/TLS off in the fixture so no probe runs, and call /bin/bash directly. Co-Authored-By: Claude Opus 5.5 --- tests/run-child-exit-status.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/run-child-exit-status.test.ts b/tests/run-child-exit-status.test.ts index 7c76bf490..08cfdef27 100644 --- a/tests/run-child-exit-status.test.ts +++ b/tests/run-child-exit-status.test.ts @@ -60,9 +60,11 @@ async function fixture() { join(project, ".hack", "docker-compose.yml"), "name: run-exit\nservices:\n app:\n image: alpine:3.20\n" ); + // Internal DNS/TLS off: no CoreDNS/Caddy probes, whose warnings would + // otherwise depend on the host's global Hack state. await writeFile( join(project, ".hack", "hack.config.json"), - '{"name":"run-exit","dev_host":"run-exit.hack"}\n' + '{"name":"run-exit","dev_host":"run-exit.hack","internal":{"dns":false,"tls":false}}\n' ); const log = join(root, "docker-log.jsonl"); await writeFile(log, ""); @@ -137,7 +139,7 @@ if (args.includes("config")) { await collect( Bun.spawn( [ - "bash", + "/bin/bash", "-c", `set ${opts.pipefail ? "-o" : "+o"} pipefail; "$@" | cat; echo "pipeline=$? hack=\${PIPESTATUS[0]}"`, "hack-pipe", From 5352415d77a7078b9835b8ffd9fb8e2c80162393 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 15:41:57 -0400 Subject: [PATCH 11/11] fix(agents): close unreadable-input faults in the target inspector A filesystem fault while reading target inputs (a mode-000 or directory Compose file) escaped as E_UNEXPECTED with the raw OS message and absolute path. Map errno errors to the closed input_permission_denied and input_unreadable refusals, and let the adapter validator pass a detail-less error only for parser E_USAGE refusals. Strengthen the no-registry-write proof by comparing against the registry bytes written in beforeAll, and cover the validator's authority and runtime.observed checks. Co-Authored-By: Claude Opus 5.5 --- docs/reference/ax-target-result-contract.md | 17 ++- docs/reference/mcp-tools.md | 4 +- src/lib/target-inspect.ts | 60 ++++++++- tests/project-target-inspect.test.ts | 135 +++++++++++++++++++- 4 files changed, 204 insertions(+), 12 deletions(-) diff --git a/docs/reference/ax-target-result-contract.md b/docs/reference/ax-target-result-contract.md index 2fc94c72f..4a6fe2213 100644 --- a/docs/reference/ax-target-result-contract.md +++ b/docs/reference/ax-target-result-contract.md @@ -154,6 +154,14 @@ fixed text that never repeats caller input, paths or registry values. | `stale_generation` | `E_USAGE` | `generation` | `reinspect` | | `generation_changed_during_read` | `E_USAGE` | `generation` | `reinspect` | | `generation_input_too_large` | `E_CONFIG_INVALID` | `generation` | `fix_project` | +| `input_permission_denied` | `E_PERMISSION` | `project` | `fix_project` | +| `input_unreadable` | `E_CONFIG_INVALID` | `project` | `fix_project` | + +A filesystem fault while reading an input (for example a Compose file with no +read permission, or a directory where a config file belongs) is reported as +`input_permission_denied` (`EACCES`, `EPERM`) or `input_unreadable` (any other +filesystem error). The OS message is never surfaced, because it contains the +absolute path. `retry` is advice for the caller. Like suggested actions and application logs, it carries no execution authority. @@ -166,6 +174,12 @@ or `expectGeneration` before spawning, as `invalid_arguments` with `spawned: false`. These are input-shape refusals; every resolution refusal above is identical on both surfaces. +These parser refusals are the only detail-less errors in the contract. A +non-filesystem fault outside the catalogue (a defect) still makes the CLI print +`E_UNEXPECTED` with free text and no `detail`. That envelope is off contract: +the MCP adapter accepts a detail-less error only when its code is `E_USAGE`, so +it returns `data: null` for any other. + ## Result semantics for future operations These rules are recorded for the operations that will consume targets (WU20, @@ -193,12 +207,13 @@ WU21). The inspector itself only produces snapshots. | Ambiguous equal names, moved checkout, unknown project | closed refusals | same | paired fixture | | Two registrations owning one checkout | `ambiguous_project` | same | paired fixture | | Malformed Hack config or Compose file, oversized input | `config_parse_failed`, `compose_parse_failed`, `generation_input_too_large` | same | paired fixture | +| Unreadable input (directory in place of a file, no read permission) | `input_unreadable`, `input_permission_denied` | same | paired fixture (permission case skipped as root) | | Detached linked worktree, no project, missing path | closed refusals | same | paired fixture | | Unknown service | `service_not_found` | same | paired fixture | | Config changed during read | `generation_changed_during_read` | same code path | domain test | | No registry, state or Git writes | yes | yes (audit log only) | paired fixture | | Malformed selector | `invalid_selector` / parser `E_USAGE` | `invalid_arguments`, not spawned | paired fixture | -| Drifted CLI output | n/a | `data: null`; foreign fields dropped | stubbed CLI fixture | +| Drifted CLI output, detail-less non-`E_USAGE` error, data claiming authority | n/a | `data: null`; foreign fields dropped | stubbed CLI fixture | The fixtures live in `tests/project-target-inspect.test.ts`. diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 78f557921..2e8b10d2c 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -210,7 +210,9 @@ provisional; see the [AX target and result contract](./ax-target-result-contract given, so the CLI owns every resolution refusal (for example `conflicting_selectors`) and the MCP result matches the CLI exactly. - `data` is the CLI's JSON envelope, re-validated against the contract, or - `null` when the output does not match it. `stdout` is always `""`, because + `null` when the output does not match it. A detail-less error is accepted + only as a parser `E_USAGE` refusal, so an `E_UNEXPECTED` fault message never + reaches `data`. `stdout` is always `""`, because `data` carries the same envelope. `stderr` is returned as captured. - The command never probes the engine, writes the registry or changes Git. diff --git a/src/lib/target-inspect.ts b/src/lib/target-inspect.ts index 75931fef1..c4bdbf22d 100644 --- a/src/lib/target-inspect.ts +++ b/src/lib/target-inspect.ts @@ -216,6 +216,18 @@ export const TARGET_REFUSALS = Object.freeze({ retry: "fix_project", message: "A project config input exceeds the inspector size bound.", }, + input_permission_denied: { + code: "E_PERMISSION", + stage: "project", + retry: "fix_project", + message: "A project input could not be read: permission denied.", + }, + input_unreadable: { + code: "E_CONFIG_INVALID", + stage: "project", + retry: "fix_project", + message: "A project input could not be read.", + }, } as const satisfies Record); export type TargetRefusalReason = keyof typeof TARGET_REFUSALS; @@ -341,8 +353,12 @@ export function targetRefusalEnvelope(opts: { } /** - * Inspect one target. Returns a refusal envelope for every closed failure; - * only unexpected faults (for example an unreadable directory) throw. + * Inspect one target. Returns a refusal envelope for every closed failure. + * A filesystem fault while reading inputs (for example a permission-denied + * Compose file, or a directory where a config file belongs) is mapped to the + * closed `input_permission_denied` / `input_unreadable` refusals, so the OS + * message and its absolute path never reach the caller. Only non-filesystem + * faults (programming errors) still throw. * * `afterResolve` is a test seam that runs after the target is resolved and * before the generation is re-read; production callers never pass it. @@ -366,10 +382,39 @@ export async function inspectTarget(opts: { : { observedGeneration: error.observedGeneration }), }); } + const fault = filesystemFaultCode(error); + if (fault !== null) { + return targetRefusalEnvelope({ + reason: + fault === "EACCES" || fault === "EPERM" + ? "input_permission_denied" + : "input_unreadable", + }); + } throw error; } } +/** Shape of an errno code such as `EACCES` or `EISDIR`. */ +const ERRNO_CODE_PATTERN = /^E[A-Z0-9]+$/; + +/** + * The errno code of a Node/Bun filesystem or syscall error, or null for any + * other value. Such errors carry a string `code` and `syscall`; their + * `message` includes the absolute path, which is why it is never surfaced. + */ +function filesystemFaultCode(error: unknown): string | null { + if ( + isRecord(error) && + typeof error.code === "string" && + ERRNO_CODE_PATTERN.test(error.code) && + typeof error.syscall === "string" + ) { + return error.code; + } + return null; +} + type ValidatedSelector = { readonly project: string | null; readonly path: string | null; @@ -836,8 +881,11 @@ const KNOWN_ERROR_CODES: ReadonlySet = new Set(HACK_ERROR_CODES); /** * Parse a `hack project target --json` envelope. Returns a rebuilt envelope * containing only contract fields, or null when the value does not match the - * provisional contract. Error envelopes with a known code but no `detail` - * (the CLI's shared option-grammar refusals) are accepted as-is. + * provisional contract. The only error envelope accepted without a `detail` + * is an `E_USAGE` refusal (the CLI's shared option-grammar refusals, raised + * before the inspector runs). Any other detail-less error, such as an + * `E_UNEXPECTED` fault whose message is free OS text, is off contract and + * becomes null, so it is never passed on. */ export function parseTargetInspectEnvelope( value: unknown @@ -862,7 +910,9 @@ export function parseTargetInspectEnvelope( } const errorCode = code as HackErrorCode; if (detail === undefined) { - return { ok: false, error: { code: errorCode, message } }; + return errorCode === "E_USAGE" + ? { ok: false, error: { code: errorCode, message } } + : null; } const parsedDetail = parseRefusalDetail(detail); if (!parsedDetail) { diff --git a/tests/project-target-inspect.test.ts b/tests/project-target-inspect.test.ts index 3446e8d16..16f41355c 100644 --- a/tests/project-target-inspect.test.ts +++ b/tests/project-target-inspect.test.ts @@ -48,6 +48,9 @@ type Fixture = { /** MCP client whose CLI command is a stub that prints `fake-envelope.json`. */ readonly stubClient: Client; readonly cliEnv: Record; + /** Registry bytes and mtime exactly as written before any CLI run. */ + readonly registryText: string; + readonly registryMtimeMs: number; }; let fixture: Fixture; @@ -216,6 +219,19 @@ beforeAll(async () => { join(oversized, ".hack", "docker-compose.yml"), `services:\n api:\n image: busybox\n#${"x".repeat(OVERSIZED_INPUT_BYTES)}\n` ); + // Unreadable inputs: a directory where the Compose file belongs, and a + // Compose file with no read permission. Their OS errors carry the path. + const dirCompose = join(root, "dir-compose"); + await writeProject({ repo: dirCompose, name: "dir", services: ["api"] }); + await rm(join(dirCompose, ".hack", "docker-compose.yml")); + await mkdir(join(dirCompose, ".hack", "docker-compose.yml")); + const lockedCompose = join(root, "locked-compose"); + await writeProject({ + repo: lockedCompose, + name: "locked", + services: ["api"], + }); + await chmod(join(lockedCompose, ".hack", "docker-compose.yml"), 0o000); await mkdir(join(root, "outside"), { recursive: true }); @@ -251,6 +267,8 @@ beforeAll(async () => { })}\n` ); await rm(moved, { recursive: true, force: true }); + const registryText = await readFile(join(state, "projects.json"), "utf8"); + const registryMtimeMs = (await stat(join(state, "projects.json"))).mtimeMs; const wrapper = join(root, "hack-source-cli"); await writeFile( @@ -294,7 +312,17 @@ beforeAll(async () => { await stubServer.connect(stubServerTransport); await stubClient.connect(stubClientTransport); - fixture = { root, state, alpha, beta, client, stubClient, cliEnv }; + fixture = { + root, + state, + alpha, + beta, + client, + stubClient, + cliEnv, + registryText, + registryMtimeMs, + }; }, CASE_TIMEOUT_MS); afterAll(async () => { @@ -582,6 +610,31 @@ describe("paired CLI/MCP target fixtures", () => { CASE_TIMEOUT_MS ); + test( + "an unreadable input is a closed refusal without the OS message or path", + async () => { + const path = join(fixture.root, "dir-compose"); + expectRefusal(await inspectBoth({ path }), "input_unreadable"); + const cli = await runCli({ path }); + expect(JSON.stringify(cli.envelope)).not.toContain(fixture.root); + expect(JSON.stringify(cli.envelope)).not.toContain("EISDIR"); + }, + CASE_TIMEOUT_MS + ); + + // Root reads a mode-000 file, so the permission fault cannot be produced. + test.skipIf(process.getuid?.() === 0)( + "a permission-denied input is a closed refusal without the OS message", + async () => { + const path = join(fixture.root, "locked-compose"); + expectRefusal(await inspectBoth({ path }), "input_permission_denied"); + const cli = await runCli({ path }); + expect(JSON.stringify(cli.envelope)).not.toContain(fixture.root); + expect(JSON.stringify(cli.envelope)).not.toContain("EACCES"); + }, + CASE_TIMEOUT_MS + ); + test( "two registrations that own one checkout are refused as ambiguous", async () => { @@ -698,9 +751,11 @@ describe("paired CLI/MCP target fixtures", () => { test( "inspection writes nothing: no registry, state or Git change", async () => { + // The baseline is the registry as beforeAll wrote it, not as it is now: + // every earlier paired case already ran the CLI (directly and through + // MCP) against this HACK_HOME, so a last-seen touch made by any of them + // would already be in a baseline read here and go unnoticed. const registryPath = join(fixture.state, "projects.json"); - const before = await readFile(registryPath, "utf8"); - const beforeStat = await stat(registryPath); const worktreesBefore = runGit({ cwd: fixture.alpha, home: join(fixture.root, "user-home"), @@ -708,8 +763,8 @@ describe("paired CLI/MCP target fixtures", () => { }); await runCli({ projectName: "alpha", branch: "fresh-instance" }); await runCli({ path: join(fixture.root, "alpha-login") }); - expect(await readFile(registryPath, "utf8")).toBe(before); - expect((await stat(registryPath)).mtimeMs).toBe(beforeStat.mtimeMs); + expect(await readFile(registryPath, "utf8")).toBe(fixture.registryText); + expect((await stat(registryPath)).mtimeMs).toBe(fixture.registryMtimeMs); // Only the registry and the MCP audit log exist in the state dir. expect((await readdir(fixture.state)).sort()).toEqual([ "mcp-audit.log", @@ -774,11 +829,46 @@ describe("MCP adapter re-validation", () => { error: { ...refusal.error, message: "drifted text" }, }) ).toBeNull(); + // A detail-less fault outside the catalogue carries free OS text. + expect( + await stubbedData({ + ok: false, + error: { + code: "E_UNEXPECTED", + message: "EACCES: permission denied, open '/x/.hack/y.yml'", + }, + }) + ).toBeNull(); + // Success data that claims authority or a runtime observation. + const valid = await validSuccessEnvelope(); + expect(await stubbedData(valid)).toEqual(valid); + expect( + await stubbedData({ + ok: true, + data: { ...valid.data, authority: "owner" }, + }) + ).toBeNull(); }, CASE_TIMEOUT_MS ); }); +/** A real success envelope from the inspector, for validator mutations. */ +async function validSuccessEnvelope(): Promise<{ + readonly ok: true; + readonly data: Record; +}> { + const result = await inspectTarget({ + cwd: fixture.root, + registryPath: join(fixture.state, "projects.json"), + selector: { path: fixture.beta }, + }); + if (!result.ok) { + throw new Error(`expected a target, got ${JSON.stringify(result)}`); + } + return { ok: true, data: { ...result.data } }; +} + describe("target inspector domain", () => { test("a config change during the read is refused, not snapshotted", async () => { const result = await inspectTarget({ @@ -877,4 +967,39 @@ describe("target inspector domain", () => { }) ).toBeNull(); }); + + test("the adapter validator only passes parser E_USAGE without detail", () => { + const usage = { + ok: false, + error: { code: "E_USAGE", message: "Unknown option '--x'." }, + }; + expect(parseTargetInspectEnvelope(usage)).toEqual(usage); + for (const code of ["E_UNEXPECTED", "E_PERMISSION", "E_CONFIG_INVALID"]) { + expect( + parseTargetInspectEnvelope({ + ok: false, + error: { code, message: "EISDIR: illegal operation on a directory" }, + }) + ).toBeNull(); + } + }); + + test("the adapter validator refuses data that claims authority or readiness", async () => { + const valid = await validSuccessEnvelope(); + expect(parseTargetInspectEnvelope(valid)).toEqual(valid); + const patched = (patch: Record) => + parseTargetInspectEnvelope({ + ok: true, + data: { ...valid.data, ...patch }, + }); + expect(patched({ authority: "owner" })).toBeNull(); + expect(patched({ authority: undefined })).toBeNull(); + expect( + patched({ runtime: { observed: true, reason: "not_probed" } }) + ).toBeNull(); + expect( + patched({ runtime: { observed: false, reason: "probed" } }) + ).toBeNull(); + expect(patched({ runtime: null })).toBeNull(); + }); });