From 30e71c9da4945d1ef7791510d7e08e82b69c5168 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 13:52:39 -0400 Subject: [PATCH 01/11] perf(storage): reuse unchanged storage witness proofs across warm commands A warm, image-only, unrouted `up` or `restart` with no hooks now runs four directory-xattr storage proofs instead of six. Each proof is three fresh helper containers. - The warm enrollment tail (no new volume) reuses the effect-entry proof of the same generation under the same lease. It uses the existing adjacentProof record, which needs a byte-identical material binding, so any receipt write in between forces a full proof. - Finalization skips the second proof pass when there is no route, after hook, or new or saved file material. In that case the completion step only reads. Proof reuse stays within one command and one lease. Nothing is persisted, so every command still opens with a full proof. Each performed verification proof now emits a passive `storage.proof` phase-trace span. The hosted native-config-compose scenario uses it to assert four proofs on each of two consecutive warm ups. Refs: HACK-1123 Co-Authored-By: Claude Opus 5.5 --- docs/reference/native-compose-phase-trace.md | 7 +- .../native-compose-storage-witness.md | 35 ++++- src/lib/native-compose-command-storage.ts | 11 +- src/lib/native-compose-command.ts | 38 +++-- src/lib/native-compose-file-command.ts | 11 ++ src/lib/native-compose-phase-trace.ts | 1 + src/lib/native-compose-storage-witness.ts | 6 +- tests/e2e/scenarios/native-config-compose.ts | 62 ++++++++ tests/native-compose-phase-trace.test.ts | 10 +- ...compose-storage-activation-command.test.ts | 81 ++++++++-- .../native-compose-storage-fence-cost.test.ts | 11 +- ...ompose-storage-witness-xattr-owner.test.ts | 143 +++++++++++++++++- 12 files changed, 369 insertions(+), 47 deletions(-) diff --git a/docs/reference/native-compose-phase-trace.md b/docs/reference/native-compose-phase-trace.md index 7f1a68bdc..30a2850bf 100644 --- a/docs/reference/native-compose-phase-trace.md +++ b/docs/reference/native-compose-phase-trace.md @@ -33,7 +33,7 @@ environment values, subprocess output or exception text. The canonical call sites cover `oneoff.post-remove-owned`, `oneoff.post-remove-fresh`, `oneoff.post-remove-guard`, `storage.enroll`, -`guard.fresh-before`, `guard.ownership`, `guard.storage`, `guard.fresh-after`, +`storage.proof`, `guard.fresh-before`, `guard.ownership`, `guard.storage`, `guard.fresh-after`, `finish.pending`, `finish.witness-work`, and the finalization phases `finalize.fresh`, `finalize.generation`, `finalize.projection`, `finalize.owned`, `finalize.remember-storage`, `finalize.witnesses`, @@ -41,7 +41,10 @@ phases `finalize.fresh`, `finalize.generation`, `finalize.projection`, recheck repeats the phase with a new span. Conditional phases appear only when the existing command takes that path. `guard.storage` measures the ordinary storage verification await inside the workload ownership guard; `storage.enroll` measures -its preceding enrollment phase. Nested spans describe nested awaits, so their +its preceding enrollment phase. `storage.proof` measures one performed +directory-xattr verification kernel proof (three helper carriers) and nests inside +whichever phase requested it; a verification that reuses an adjacent proof under +the same lease emits no `storage.proof` span. Nested spans describe nested awaits, so their durations must not be added as independent CPU time. Output stops after 256 records or 64 KiB, or if the clock or sink fails. Diagnostic diff --git a/docs/reference/native-compose-storage-witness.md b/docs/reference/native-compose-storage-witness.md index 4476dc8c3..8146831af 100644 --- a/docs/reference/native-compose-storage-witness.md +++ b/docs/reference/native-compose-storage-witness.md @@ -103,15 +103,44 @@ projection, recovery or newly declared volume. A speculative ownership read can preview that merge but cannot publish it. A birth addition repeats the original fully fenced path. Each qualifying phase keeps a fresh closing kernel proof and exact source, generation and receipt-incarnation checks. No proof is reused across -pending publication or effects. Opening preparation, enrollment, pre-spawn and -both final receipt proofs remain separate. The one-volume offline counter is +pending publication or effects. The opening preparation proof and the +post-workload receipt proof remain separate. The one-volume offline counter is three full proofs before effect entry rather than five; it establishes call ordering and count, not CPU savings or atomic observation across the daemon and private filesystem. +Within one command and one mutation lease, a verification whose code since the +previous proof performed only reads may reuse that completed kernel proof. It +still runs every host check (anchors, expectation, completion and binding). It +skips the three helper carriers only when the engine, generation and reference +are the same and its opening material binding (receipt identity and hash, lease +token, checkout anchor and generation) is byte-identical to the binding that +opened and closed the recorded proof. A receipt publication in between, such as +pending intent, a storage birth or completion, changes that binding and forces a +full proof. A refused or failed verification clears the record. Nothing is +persisted: every later command or lease, including the first after an engine +restart, opens with a full proof. Callers claim adjacency from code structure, +never from timing: + +- the warm `up`/`restart` enrollment tail (no newly declared volume) reuses the + effect-entry proof of the same generation; a new volume seeds and proves in + full; +- the first pre-spawn guard of an `up`/`restart` without build, routing or file + material reuses the enrollment-tail record; +- saved `exec` reuses its opening proof before the process. Its closing proof + after the process stays fresh. + +Final receipt verification proves once after the workload when no route, after +hook, or new or saved file material needs a completion step. Otherwise it proves +again after that step. A warm image-only, unrouted `up` without hooks therefore +proves four times (opening, prepublication, effect-entry and post-workload) +rather than six. With `HACK_NATIVE_COMPOSE_PHASE_TRACE=1`, each performed +verification proof emits one `storage.proof` span; a reused proof emits none. +These counts establish call ordering and helper invocations, not wall time. + The one-off `run` path separately omits the enrollment-tail read that would immediately repeat its mandatory pre-spawn witness read. `up` and `restart` -keep that tail read. Cold enrollment still seeds once and verifies before +keep that tail read; when warm it is the adjacent reuse above. Cold enrollment still seeds once and verifies before publishing its enrolled reference. The one-volume cold-run control reaches the actual pre-spawn boundary after eleven helper calls rather than fourteen; it intercepts the workload there and does not qualify Docker execution or timing. diff --git a/src/lib/native-compose-command-storage.ts b/src/lib/native-compose-command-storage.ts index c9384e64c..ddcd7abc1 100644 --- a/src/lib/native-compose-command-storage.ts +++ b/src/lib/native-compose-command-storage.ts @@ -314,7 +314,16 @@ export async function prepareNativeComposeCommandStorage(opts: { // guard. Up and restart can arm other effects before that guard. if (operation !== "run") { stage = "storage-verify"; - await verify(generation); + // Warm enrollment (no new volume) performs only the read-only + // selection compare and cold-absence check above, after the + // effect-entry proof of this same generation under this lease. It + // may reuse that proof; a receipt write since then changes the + // material binding and forces a full proof. A new volume seeds and + // always proves in full. + await verify( + generation, + newVolumes.length === 0 ? { adjacentProof: true } : {} + ); } } catch (error) { retainNativeComposeEffectRefusal(error, { diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index 758d25dc9..02e674912 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -56,6 +56,7 @@ import { assertNativeComposeFileRunSupported, assertNativeComposeSavedFileEngines, type NativeComposeCommandFiles, + nativeComposeSavedFilesPresent, prepareNativeComposeCommandFiles, prepareNativeComposeSavedFileStop, retireNativeComposeSavedFiles, @@ -1653,21 +1654,30 @@ async function executePreparedGeneration(opts: { signal, }); }, - retireFiles: async () => { - await files?.assertReady(selection, generation); - await retireNativeComposeSavedFiles({ - mutation, - store, - saved: previous, - selection, - signal, - }); - }, + // Without new or saved file material the retirement only reads, so + // leaving it out lets finalization take its single proof pass when no + // route or after hook needs a completion step either. + ...(files !== null || nativeComposeSavedFilesPresent(previous) + ? { + retireFiles: async () => { + await files?.assertReady(selection, generation); + await retireNativeComposeSavedFiles({ + mutation, + store, + saved: previous, + selection, + signal, + }); + }, + } + : {}), }); - // Up/restart enrollment ends with a storage proof. With no build, routing or - // file material, the first pre-spawn guard follows it with only read-only - // source and ownership checks (arming is a no-op), so that guard may reuse the - // adjacent proof. Every later guard and finalization proof stays fresh. + // Up/restart enrollment ends with a storage proof (a warm enrollment reuses + // the effect-entry proof; a new volume proves in full). With no build, + // routing or file material, the first pre-spawn guard follows it with only + // read-only source and ownership checks (arming is a no-op), so that guard + // may reuse the adjacent proof. Every later guard and the post-workload + // finalization proof stay fresh. let adjacentGuardProof = (operation === "up" || operation === "restart") && builds.length === 0 && diff --git a/src/lib/native-compose-file-command.ts b/src/lib/native-compose-file-command.ts index 7368665a4..59e3c1b0d 100644 --- a/src/lib/native-compose-file-command.ts +++ b/src/lib/native-compose-file-command.ts @@ -463,6 +463,17 @@ export async function prepareNativeComposeCommandFiles(opts: { } } +/** Whether any saved generation carries file material that + * `retireNativeComposeSavedFiles` would retire. With none, that retirement + * performs only read-only document loads and no file or engine effect. */ +export function nativeComposeSavedFilesPresent( + saved: readonly Saved[] +): boolean { + return saved.some((selected) => + Object.hasOwn(selected.document, NATIVE_COMPOSE_FILES_EXTENSION) + ); +} + /** Saved cleanup acquires neither authored inputs nor credentials and never infers child reaping. */ export async function retireNativeComposeSavedFiles(opts: { readonly mutation: NativeComposeMutation; diff --git a/src/lib/native-compose-phase-trace.ts b/src/lib/native-compose-phase-trace.ts index 540f833b0..0bcebca28 100644 --- a/src/lib/native-compose-phase-trace.ts +++ b/src/lib/native-compose-phase-trace.ts @@ -7,6 +7,7 @@ const PHASES = [ "oneoff.post-remove-fresh", "oneoff.post-remove-guard", "storage.enroll", + "storage.proof", "guard.fresh-before", "guard.ownership", "guard.storage", diff --git a/src/lib/native-compose-storage-witness.ts b/src/lib/native-compose-storage-witness.ts index 7586eb73b..726307b07 100644 --- a/src/lib/native-compose-storage-witness.ts +++ b/src/lib/native-compose-storage-witness.ts @@ -18,6 +18,7 @@ import { publishNativeComposeStorageWitnessEnrollment, runNativeComposeMaterialAction, } from "./native-compose-generation.ts"; +import { measureNativeComposePhase } from "./native-compose-phase-trace.ts"; import { type HeldDirectory, holdDirectory, @@ -1274,10 +1275,11 @@ function takeLeaseProof(opts: { const recorded = proofs.get(key); proofs.delete(key); return { - /** Run the kernel proof unless an adjacent caller opens with the recorded binding. */ + /** Run the kernel proof unless an adjacent caller opens with the recorded + * binding. Only a performed proof emits the passive `storage.proof` span. */ async prove(opening: string, run: () => Promise): Promise { if (!(opts.adjacent && recorded === opening)) { - await run(); + await measureNativeComposePhase("storage.proof", run); } }, /** Record a completed proof only when its binding never moved. */ diff --git a/tests/e2e/scenarios/native-config-compose.ts b/tests/e2e/scenarios/native-config-compose.ts index e77445915..5c784551a 100644 --- a/tests/e2e/scenarios/native-config-compose.ts +++ b/tests/e2e/scenarios/native-config-compose.ts @@ -146,6 +146,36 @@ function object(text: string): Record { return value; } +/** + * Storage verification proof counts from one CLI's phase trace. Each performed + * kernel proof (three helper carriers) emits one `storage.proof` span; a proof + * reused within the command's lease emits none. + */ +function storageProofTrace(stderr: string): { + readonly proofs: number; + readonly unfinished: number; + readonly proofMs: number; + readonly finalizations: number; +} { + const rows = stderr + .split("\n") + .filter((line) => line.startsWith('{"diagnostic":"native-compose-phase",')) + .map((line) => object(line)); + const spans = (phase: string, boundary: string) => + rows.filter((row) => row.phase === phase && row.boundary === boundary); + const ended = spans("storage.proof", "end"); + return { + proofs: spans("storage.proof", "begin").length, + unfinished: spans("storage.proof", "begin").length - ended.length, + proofMs: ended.reduce( + (total, row) => + total + (typeof row.durationMs === "number" ? row.durationMs : 0), + 0 + ), + finalizations: spans("finalize.witnesses", "begin").length, + }; +} + function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } @@ -1280,6 +1310,38 @@ export const nativeConfigComposeScenario: Scenario = { await checkSql(marker); await Bun.write(projectPath, projectText); await cli(["up", "--detach", "--json"]); + // HACK-1123: two consecutive unchanged warm ups each prove storage four + // times (opening, prepublication, effect-entry, post-workload); the + // enrollment tail and first guard reuse the effect-entry proof, and with + // no route, hook or file material finalization proves once. + const warmUps: (ReturnType & { + readonly totalMs: number; + })[] = []; + for (const attempt of [1, 2]) { + const warm = await raw(["up", "--detach", "--json"], { + HACK_NATIVE_COMPOSE_PHASE_TRACE: "1", + }); + expectExit({ + result: warm, + codes: [0], + message: `Traced unchanged warm up ${attempt} must succeed`, + }); + warmUps.push({ + ...storageProofTrace(warm.stderr), + totalMs: Math.round(warm.durationMs), + }); + } + ctx.log(`warm up storage proofs: ${JSON.stringify(warmUps)}`); + expect({ + that: warmUps.every( + (warm) => + warm.proofs === 4 && + warm.unfinished === 0 && + warm.finalizations === 1 + ), + message: + "Unchanged warm up must perform exactly four storage proofs and one finalization witness check", + }); const beforeRestart = await snapshot(); const beforeRestartBoot = await probe("exec", marker); stage("owned workload removal/rename and retained SQL data verified"); diff --git a/tests/native-compose-phase-trace.test.ts b/tests/native-compose-phase-trace.test.ts index a34785627..85dd53b73 100644 --- a/tests/native-compose-phase-trace.test.ts +++ b/tests/native-compose-phase-trace.test.ts @@ -339,14 +339,8 @@ test( "finalize.witnesses", "finalize.pending", "finalize.pending-check", - "finalize.before-complete", - "finalize.fresh", - "finalize.generation", - "finalize.owned", - "finalize.remember-storage", - "finalize.witnesses", - "finalize.pending", - "finalize.pending-check", + // No route, after hook or file material: no completion step, so no + // second finalization pass. "finalize.save", ]); expect(rows.every((row, index) => row.sequence === index + 1)).toBe(true); diff --git a/tests/native-compose-storage-activation-command.test.ts b/tests/native-compose-storage-activation-command.test.ts index e7c5fae6d..131162a95 100644 --- a/tests/native-compose-storage-activation-command.test.ts +++ b/tests/native-compose-storage-activation-command.test.ts @@ -382,11 +382,13 @@ test("public down recover completes only removed readonly original work and next }, 500_000); // One proof is root, verify and root-after through three fresh helper carriers. -// Warm up/restart keep the opening, both effect-phase, enrollment and both -// finalization proofs; only the first pre-spawn guard reuses the adjacent -// enrollment proof (no build, routing or file material). Saved exec keeps its -// opening proof, reuses it for the adjacent pre-spawn check and proves again -// after the process. A cold up seeds once and keeps every later proof. +// Warm up/restart keep the opening and both effect-phase proofs. The warm +// enrollment tail (no new volume) and then the first pre-spawn guard (no build, +// routing or file material) reuse the adjacent effect-entry proof. With no +// route, after hook or file material, finalization keeps one fresh +// post-workload proof. Saved exec keeps its opening proof, reuses it for the +// adjacent pre-spawn check and proves again after the process. A cold up seeds +// once and proves its new volume in full. Consecutive warm ups cost the same. test( "source CLI helper invocations per command keep every non-adjacent proof", async () => @@ -414,12 +416,17 @@ test( "seed", ...proofs(3), "workload", - ...proofs(2), + ...proofs(1), + ]); + expect(await counted(["up", "--detach", "--json"])).toEqual([ + ...proofs(3), + "workload", + ...proofs(1), ]); expect(await counted(["up", "--detach", "--json"])).toEqual([ - ...proofs(4), + ...proofs(3), "workload", - ...proofs(2), + ...proofs(1), ]); expect(await counted(["exec", "web", "--", "true"])).toEqual([ ...proofs(1), @@ -427,9 +434,9 @@ test( ...proofs(1), ]); expect(await counted(["restart", "--json"])).toEqual([ - ...proofs(4), + ...proofs(3), "workload", - ...proofs(2), + ...proofs(1), ]); expect( (await operations(root)).filter((op) => op === "seed") @@ -438,3 +445,57 @@ test( }), 390_000 ); + +// Negative control for the single finalization pass: an after hook is a host +// effect between readiness and the ready receipt, so its effect phases keep +// their proofs and finalization keeps its second fresh proof after the +// completion step. The eight post-workload proofs equal the count before +// proof reuse; only the warm enrollment tail drops (six to five before the +// workload). +test( + "source CLI warm up with an after hook keeps both finalization proofs", + async () => + await ownedColdCase(async (owner) => { + const root = await fixture("", false, { + storage, + hooks: { + up: { + after: [ + { + name: "after", + command: { exec: [process.execPath, "-e", "0"] }, + }, + ], + }, + }, + cleanupAllowed: owner.cleanupAllowed, + }); + const eventsFile = join(root, "storage-events"); + const events = async () => + (await Bun.file(eventsFile).exists()) + ? (await Bun.file(eventsFile).text()).trim().split("\n") + : []; + const counted = async (args: readonly string[]) => { + const before = (await events()).length; + const result = await owner.invoke(root, args); + expect(result.code, await disposition(root, result.code)).toBe(0); + return (await events()).slice(before); + }; + const proofs = (count: number) => + Array.from({ length: count }, () => ["root", "verify", "root"]).flat(); + expect(await counted(["up", "--detach", "--json"])).toEqual([ + "root", + "seed", + ...proofs(3), + "workload", + ...proofs(8), + ]); + expect(await counted(["up", "--detach", "--json"])).toEqual([ + ...proofs(5), + "workload", + ...proofs(8), + ]); + expect(await Bun.file(join(root, "carriers")).json()).toEqual([]); + }), + 390_000 +); diff --git a/tests/native-compose-storage-fence-cost.test.ts b/tests/native-compose-storage-fence-cost.test.ts index c0322ce87..7d641d7d4 100644 --- a/tests/native-compose-storage-fence-cost.test.ts +++ b/tests/native-compose-storage-fence-cost.test.ts @@ -152,11 +152,14 @@ test("storage fence count per command: every proof kept, exact duplicates gone, direct: 0, observations: 2 * PROOF_OBSERVATIONS, }); + // Warm up proves four times: opening, prepublication, effect-entry (reused + // by the enrollment tail and first guard) and one post-workload finalization. + // A reused proof makes no engine fence or target observation. expect(await counted(root, ["up", "--detach", "--json"])).toEqual({ code: 0, - cli: FILE_ENGINE_FENCES + 6 * PROOF_FENCES, + cli: FILE_ENGINE_FENCES + 4 * PROOF_FENCES, direct: 0, - observations: 6 * PROOF_OBSERVATIONS, + observations: 4 * PROOF_OBSERVATIONS, }); // Admitted Unix selection: the same fences, one fresh read each. Only the // first storage fence is a CLI read, checked against one extra direct read. @@ -170,8 +173,8 @@ test("storage fence count per command: every proof kept, exact duplicates gone, expect(await counted(root, ["up", "--detach", "--json"], selected)).toEqual({ code: 0, cli: FILE_ENGINE_FENCES + 1, - direct: 6 * PROOF_FENCES, - observations: 6 * PROOF_OBSERVATIONS, + direct: 4 * PROOF_FENCES, + observations: 4 * PROOF_OBSERVATIONS, }); // Admitted context selection (DOCKER_HOST unset; the client config's current // context names the socket): the same counts as an explicit Unix host. diff --git a/tests/native-compose-storage-witness-xattr-owner.test.ts b/tests/native-compose-storage-witness-xattr-owner.test.ts index 2cb45c0bc..8c1dd21f3 100644 --- a/tests/native-compose-storage-witness-xattr-owner.test.ts +++ b/tests/native-compose-storage-witness-xattr-owner.test.ts @@ -663,7 +663,7 @@ test("completion publication refusal retains its first boundary after complete h test.each([ "up", "restart", -] as const)("warm no-op %s keeps opening, two closing and all later witness proofs", async (operation) => { +] as const)("warm no-op %s reuses the effect-entry proof for enrollment and keeps every other proof", async (operation) => { const { store, transport } = await rememberedActive(); const command = commandPorts(transport); let finalizers = 0; @@ -697,7 +697,7 @@ test.each([ finalizers++; expect( transport.calls.filter((call) => call === "verify") - ).toHaveLength(6); + ).toHaveLength(5); }, effect: async () => { expect(owned).toBe(2); @@ -710,8 +710,17 @@ test.each([ expect((await store.loadCurrent()).pending?.generationId).toBe( generation.generationId ); + // Warm enrollment (no new volume) reuses the effect-entry proof of + // this generation: only read-only checks ran since it. await storage.enroll(generation, document); + expect( + transport.calls.filter((call) => call === "verify") + ).toHaveLength(3); + // An ordinary (non-adjacent) verification always proves in full. await storage.verify(generation); + expect( + transport.calls.filter((call) => call === "verify") + ).toHaveLength(4); transport.calls.push("workload"); return { value: 0, outcome: "complete" }; }, @@ -719,12 +728,140 @@ test.each([ expect(result).toEqual({ value: 0, outcome: "complete" }); }); expect(finalizers).toBe(1); - expect(transport.calls.filter((call) => call === "verify")).toHaveLength(7); + expect(transport.calls.filter((call) => call === "verify")).toHaveLength(6); expect(transport.calls).not.toContain("seed"); expect(transport.calls).not.toContain("provision"); expect((await store.loadCurrent()).pending).toBeNull(); }, 30_000); +test("warm enrollment proves in full when the material binding moved since the effect-entry proof", async () => { + const { store, transport } = await rememberedActive(); + const command = commandPorts(transport); + const original = generationOwner.assertNativeComposeMaterialAuthority; + let moved = false; + // Stand-in for a receipt publication (for example a storage birth) between + // the effect-entry proof and the enrollment tail: the next opening binding + // carries a different receipt hash than the one the lease recorded. + const authority = spyOn( + generationOwner, + "assertNativeComposeMaterialAuthority" + ).mockImplementation(async (input) => { + const binding = await original(input); + if (moved) { + moved = false; + return { + ...binding, + receipt: { ...binding.receipt, hash: "0".repeat(64) }, + }; + } + return binding; + }); + try { + await store.withMutation(async (mutation) => { + const storage = await prepareNativeComposeCommandStorage({ + store, + mutation, + operation: "up", + selected: [selection], + signal: transport.controller.signal, + ports: command.ports, + }); + if (!storage) { + throw new Error("Expected retained storage"); + } + const generation = await publish(mutation); + transport.state.generation = generation; + const document = await store.readGenerationDocument(generation); + await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + captureStorage: () => [volume], + storageWitnesses: storage.effectWitnesses, + effect: async () => { + expect(helperOperations(transport)).toHaveLength(9); + moved = true; + await storage.enroll(generation, document); + expect(moved).toBe(false); + expect(helperOperations(transport)).toHaveLength(12); + transport.calls.push("workload"); + return { value: 0, outcome: "complete" }; + }, + }); + }); + } finally { + authority.mockRestore(); + } + expect(transport.calls).toContain("workload"); + expect(transport.calls).not.toContain("seed"); +}, 30_000); + +test("an adjacent verification never reuses a proof across a receipt publication or a later lease", async () => { + const { store, transport, generation, reference } = await rememberedActive(); + const command = commandPorts(transport); + const proofs = () => + transport.calls.filter((call) => call === "verify").length; + await store.withMutation(async (mutation) => { + const storage = await prepareNativeComposeCommandStorage({ + store, + mutation, + operation: "up", + selected: [selection], + signal: transport.controller.signal, + ports: command.ports, + }); + if (!storage) { + throw new Error("Expected retained storage"); + } + expect(proofs()).toBe(1); + // Positive control: only reads since the opening proof. + await storage.verify(generation, { adjacentProof: true }); + expect(proofs()).toBe(1); + // The effect publishes pending and then the completed receipt. Its last + // recorded proof precedes that completion publication. + const result = await mutation.runEffect({ + generation, + operation: "up", + assertFresh: async () => {}, + assertOwned: async () => {}, + captureStorage: () => [volume], + storageWitnesses: storage.effectWitnesses, + effect: async () => ({ value: 0, outcome: "complete" }), + }); + expect(result.outcome).toBe("complete"); + const published = proofs(); + await storage.verify(generation, { adjacentProof: true }); + expect(proofs()).toBe(published + 1); + }); + // A later command holds a new lease (as after an engine restart): nothing + // recorded in the previous one is visible, so an adjacent claim still proves. + await store.withMutation(async (mutation) => { + const before = proofs(); + await verifyNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId, + reference, + carrier: transport.carrier, + adjacentProof: true, + }); + expect(proofs()).toBe(before + 1); + // A different engine never matches the recorded proof or the binding. + await expect( + verifyNativeComposeStorageXattrWitness({ + authority: mutation.materialAuthority, + generation, + engineId: "e".repeat(64), + reference, + carrier: transport.carrier, + adjacentProof: true, + }) + ).rejects.toThrow(witnessRefusal); + expect(proofs()).toBe(before + 1); + }); +}, 30_000); + test.each( (["prepublication", "effect-entry"] as const).flatMap((phase) => (["ownership", "source-await"] as const).map((boundary) => ({ From 0853a134a369fc7c9cee24c35b368c0ae645bd86 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 14:20:34 -0400 Subject: [PATCH 02/11] ci: keep the Bun crash loop inside its budget and default to the focused file The HACK-1211 experiment produced two iterations per Bun version: one focused pass and one full `bun test` that started on a 600 s estimate and overran the 30-minute budget, because the full suite takes 30-35 minutes on hosted macOS. - Never start an iteration whose estimate exceeds the remaining budget. The estimate is a conservative prior (focused 120 s, full 2400 s) until a run completes, then the longest completed run times 1.2. A command that does not fit is recorded once in summary.json `skippedForBudget` and leaves the rotation. Deadline stops remain a safety net (`overrun`). - Add `--mode=focused|full|alternate` (default focused: only tests/native-https-owner.test.ts) and `--role=baseline|candidate`. - The workflow gains a validated `mode` dispatch input; the label-triggered matched pair uses focused with baseline/candidate roles. Full mode needs a budget of at least 40 minutes. The job timeout stays budget + 15. - summary.json gains per-command counts (iterations, passes, crashes, other failures, overruns, skipped-for-budget) and a one-line verdict such as "baseline 0/97 focused crashes". - The shared classifier also treats exit 134 (SIGABRT) and 138 (SIGBUS) as crash exits. Co-Authored-By: Claude Opus 5.5 --- .ai/skills/hack-repo-verify/SKILL.md | 2 +- .github/workflows/bun-crash-experiment.yml | 58 ++- scripts/bun-crash-loop.ts | 429 +++++++++++++++----- scripts/lib/bun-crash-classify.ts | 9 +- tests/bun-crash-loop.test.ts | 449 +++++++++++++++++---- 5 files changed, 739 insertions(+), 208 deletions(-) diff --git a/.ai/skills/hack-repo-verify/SKILL.md b/.ai/skills/hack-repo-verify/SKILL.md index 39a1657d3..4b01408db 100644 --- a/.ai/skills/hack-repo-verify/SKILL.md +++ b/.ai/skills/hack-repo-verify/SKILL.md @@ -70,7 +70,7 @@ untracked candidate additions. Keep private artifacts excluded from the index. | Native authored command slices | `tests/native-authored-command.test.ts` (unsupported matrix), `tests/native-authored-command-dispatch.test.ts`, `tests/native-authored-project-{start,run,observe,logs,exec,recovery}.test.ts` and the [command contract](../../../docs/reference/native-authored-commands.md); the dispatch "source CLI" cases need the prepared compiler (`bun run build:config-compiler` or absolute `HACK_CONFIG_COMPILER_BINARY`) and fail without it; `native-authored-project-start` and `native-authored-project-exec` are nondeterministic on a local macOS host, so a single local pass or failure is not proof; live acceptance runs host-serialized on one prepared macOS provider slot | | Native VM/routing/reclamation | [candidate guide](../../../docs/guides/native-candidate.md), relevant acceptance gate and isolated host fixture; Linux checks cannot qualify macOS effects | | Resource/performance claims | [performance skill](../hack-repo-performance/SKILL.md), matched workload and measured boundaries | -| Bun runtime crashes | Read-only history: `bun run ci:bun-crash-census -- --since --until ` (failed `test` job logs; `--scan-passing` for all). Matched reproduction: the gated [experiment](../../../.github/workflows/bun-crash-experiment.yml), which spends hosted macOS minutes and needs an explicit go-ahead; never loop on a shared host. Both share [the classifier](../../../scripts/lib/bun-crash-classify.ts) | +| Bun runtime crashes | Read-only history: `bun run ci:bun-crash-census -- --since --until ` (failed `test` job logs; `--scan-passing` for all). Matched reproduction: the gated [experiment](../../../.github/workflows/bun-crash-experiment.yml), which spends hosted macOS minutes and needs an explicit go-ahead; never loop on a shared host. Its default `focused` mode loops `tests/native-https-owner.test.ts` (where both original segfaults happened); `full` needs a budget of at least 40 minutes because one full suite takes 30-35. A run never starts unless its estimate fits the remaining budget, so read `summary.json` for the verdict and per-mode crash, failure and skipped-for-budget counts. Both share [the classifier](../../../scripts/lib/bun-crash-classify.ts) | Read [CI](../../../.github/workflows/ci.yml) for current hosted gates. A local pass does not mean CI ran. Existing unsupported remote/gateway/native-app checks are diff --git a/.github/workflows/bun-crash-experiment.yml b/.github/workflows/bun-crash-experiment.yml index 170bd2e11..6802c92b4 100644 --- a/.github/workflows/bun-crash-experiment.yml +++ b/.github/workflows/bun-crash-experiment.yml @@ -8,8 +8,15 @@ name: Bun crash experiment # go-ahead. # # A labeled pull request runs the matched pair on the pull request's head: the -# 1.3.9 baseline and the 1.4.2 candidate pinned on next, with the same job and -# budget. A dispatch runs one Bun version at a chosen source revision. +# 1.3.9 baseline and the 1.4.2 candidate pinned on next, with the same job, +# budget and the default `focused` mode. A dispatch runs one Bun version at a +# chosen source revision and mode. +# +# Modes: `focused` loops tests/native-https-owner.test.ts, the file in which both +# original segfaults happened (about 16 s per run); `full` loops the full +# `bun test` (about 30-35 min per run on hosted macOS); `alternate` alternates +# the two. The loop never starts a run whose estimate exceeds the remaining +# budget; it records the command as skipped for budget instead. on: workflow_dispatch: inputs: @@ -22,9 +29,17 @@ on: type: string default: "" budget_minutes: - description: Loop budget in minutes (5-240); the job timeout is budget + 15 + description: Loop budget in minutes (5-240; full mode needs at least 40); the job timeout is budget + 15 type: number default: 30 + mode: + description: focused loops the owner test file, full loops the full suite, alternate alternates them + type: choice + options: + - focused + - full + - alternate + default: focused pull_request: types: [labeled] @@ -46,7 +61,8 @@ jobs: permissions: contents: read outputs: - bun_versions: ${{ steps.plan.outputs.bun_versions }} + runs: ${{ steps.plan.outputs.runs }} + mode: ${{ steps.plan.outputs.mode }} source_revision: ${{ steps.plan.outputs.source_revision }} budget_seconds: ${{ steps.plan.outputs.budget_seconds }} timeout_minutes: ${{ steps.plan.outputs.timeout_minutes }} @@ -58,6 +74,7 @@ jobs: INPUT_BUN_VERSION: ${{ inputs.bun_version }} INPUT_SOURCE_REVISION: ${{ inputs.source_revision }} INPUT_BUDGET_MINUTES: ${{ inputs.budget_minutes }} + INPUT_MODE: ${{ inputs.mode }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} EVENT_SHA: ${{ github.sha }} run: | @@ -67,13 +84,15 @@ jobs: echo "Invalid bun_version" exit 1 fi - versions="[\"$INPUT_BUN_VERSION\"]" + runs="[{\"bun_version\":\"$INPUT_BUN_VERSION\",\"role\":\"\"}]" revision="${INPUT_SOURCE_REVISION:-$EVENT_SHA}" budget="$INPUT_BUDGET_MINUTES" + mode="$INPUT_MODE" else - versions='["1.3.9","1.4.2"]' + runs='[{"bun_version":"1.3.9","role":"baseline"},{"bun_version":"1.4.2","role":"candidate"}]' revision="$PR_HEAD_SHA" budget=30 + mode=focused fi if ! [[ "$revision" =~ ^[0-9a-f]{40}$ ]]; then echo "Invalid source_revision" @@ -83,20 +102,35 @@ jobs: echo "Invalid budget_minutes" exit 1 fi + case "$mode" in + focused | full | alternate) ;; + *) + echo "Invalid mode" + exit 1 + ;; + esac + # The loop's full-suite prior is 40 minutes (DEFAULT_FULL_ESTIMATE_SECONDS + # in scripts/bun-crash-loop.ts); a smaller full-mode budget could never + # start a run and would only spend hosted macOS minutes on setup. + if [ "$mode" = "full" ] && [ "$budget" -lt 40 ]; then + echo "full mode needs budget_minutes of at least 40" + exit 1 + fi { - echo "bun_versions=$versions" + echo "runs=$runs" + echo "mode=$mode" echo "source_revision=$revision" echo "budget_seconds=$((budget * 60))" echo "timeout_minutes=$((budget + 15))" } >> "$GITHUB_OUTPUT" loop: - name: Bun crash loop (${{ matrix.bun_version }}) + name: Bun crash loop (${{ matrix.bun_version }}, ${{ needs.plan.outputs.mode }}) needs: plan strategy: fail-fast: false matrix: - bun_version: ${{ fromJSON(needs.plan.outputs.bun_versions) }} + include: ${{ fromJSON(needs.plan.outputs.runs) }} runs-on: blacksmith-6vcpu-macos-15 timeout-minutes: ${{ fromJSON(needs.plan.outputs.timeout_minutes) }} permissions: @@ -144,15 +178,19 @@ jobs: else echo "Source revision has no test preflight" fi - - name: Loop the owner test and the full suite within the budget + - name: Loop the selected tests within the budget env: BUN_ENABLE_CRASH_REPORTING: "0" EXPECTED_BUN_VERSION: ${{ matrix.bun_version }} SOURCE_REVISION: ${{ needs.plan.outputs.source_revision }} BUDGET_SECONDS: ${{ needs.plan.outputs.budget_seconds }} + LOOP_MODE: ${{ needs.plan.outputs.mode }} + LOOP_ROLE: ${{ matrix.role }} run: >- bun "$RUNNER_TEMP/bun-crash-instrument/scripts/bun-crash-loop.ts" --budget-seconds="$BUDGET_SECONDS" + --mode="$LOOP_MODE" + ${LOOP_ROLE:+"--role=$LOOP_ROLE"} --expect-bun-version="$EXPECTED_BUN_VERSION" --source-revision="$SOURCE_REVISION" --out-dir="$RUNNER_TEMP/bun-crash-loop" diff --git a/scripts/bun-crash-loop.ts b/scripts/bun-crash-loop.ts index 220aa1d21..a8e9a1581 100644 --- a/scripts/bun-crash-loop.ts +++ b/scripts/bun-crash-loop.ts @@ -2,14 +2,26 @@ /** * Fixed-budget Bun crash loop for the gated `bun-crash-experiment` workflow. * - * It alternates `bun test tests/native-https-owner.test.ts` with the full - * `bun test`, classifies every run with the shared classifier, keeps each run's - * log, and writes a closed, value-free summary. + * Modes: + * - `focused` (default) loops `bun test tests/native-https-owner.test.ts`, the file + * in which both original segfaults happened; + * - `full` loops the full `bun test`; + * - `alternate` alternates the two. + * Every run is classified with the shared classifier and keeps its own log, and + * the loop writes a closed, value-free summary with per-command counts and a + * one-line verdict. * - * Budget invariant: an iteration starts only when its estimated duration (the - * larger of its initial estimate and its longest observed run) ends at or before - * the deadline. An iteration that is still running at the deadline is stopped and - * recorded as `overrun`, never as a crash, unless its log carries the crash banner. + * Budget invariant: an iteration starts only when its estimated duration ends at + * or before the deadline. Until a command has completed a run, its estimate is a + * conservative prior; afterwards it is the longest completed run of that command + * times {@link ESTIMATE_HEADROOM}. A command whose estimate does not fit is + * recorded once as skipped for budget and leaves the rotation: its estimate cannot + * change without a run and the remaining budget only shrinks, so it could never + * fit again. The loop ends when every command has been skipped. + * + * A run still going at the deadline (an estimate that was too small) is stopped + * and recorded as `overrun`, never as a crash, unless its log carries the crash + * banner. With honest estimates this does not happen; the stop is a safety net. * * The loop never opens or fetches crash-report links. It sets * `BUN_ENABLE_CRASH_REPORTING=0` so Bun does not upload a report itself; the logs @@ -39,6 +51,19 @@ import { export type LoopCommandId = "focused" | "full"; +/** Which commands the loop runs: one file, the full suite, or both in turn. */ +export type LoopMode = "focused" | "full" | "alternate"; + +export const LOOP_MODES: readonly LoopMode[] = ["focused", "full", "alternate"]; +export const DEFAULT_LOOP_MODE: LoopMode = "focused"; + +/** Closed labels for the matched pair; a dispatched run has none. */ +export type LoopRole = "baseline" | "candidate"; +const LOOP_ROLES: readonly LoopRole[] = ["baseline", "candidate"]; + +/** The test file in which both original segfaults happened. */ +export const FOCUSED_TEST_FILE = "tests/native-https-owner.test.ts"; + export interface LoopCommand { readonly id: LoopCommandId; readonly argv: readonly string[]; @@ -66,15 +91,35 @@ export interface IterationRecord { readonly durationMs: number; } +/** A command the loop did not start because its estimate exceeded the remaining budget. */ +export interface SkippedForBudget { + readonly command: LoopCommandId; + readonly reason: "budget"; + /** Loop time at which the skip was decided. */ + readonly atMs: number; + readonly remainingMs: number; + readonly estimateMs: number; +} + export interface LoopResult { readonly budgetMs: number; readonly elapsedMs: number; + /** The configured commands, in rotation order. */ + readonly commands: readonly LoopCommandId[]; readonly iterations: readonly IterationRecord[]; - readonly estimatesMs: Readonly>; + readonly skippedForBudget: readonly SkippedForBudget[]; + /** The estimate each command had when the loop ended. */ + readonly estimatesMs: Readonly>>; } export const DEFAULT_FOCUSED_ESTIMATE_SECONDS = 120; -export const DEFAULT_FULL_ESTIMATE_SECONDS = 600; +/** + * The full CLI suite takes about 30 to 35 minutes on hosted macOS, so the prior + * is 40 minutes. The workflow's full-mode budget floor mirrors this value. + */ +export const DEFAULT_FULL_ESTIMATE_SECONDS = 2400; +/** Multiplier on the longest completed run, to absorb run-to-run variance. */ +export const ESTIMATE_HEADROOM = 1.2; const MIN_BUDGET_SECONDS = 60; const MAX_BUDGET_SECONDS = 6 * 60 * 60; const KILL_GRACE_MS = 10_000; @@ -85,50 +130,75 @@ export class LoopError extends Error {} export function loopCommands(opts: { readonly bunExecutable: string; + readonly mode: LoopMode; readonly focusedEstimateSeconds: number; readonly fullEstimateSeconds: number; }): readonly LoopCommand[] { - return [ - { - id: "focused", - argv: [opts.bunExecutable, "test", "tests/native-https-owner.test.ts"], - initialEstimateMs: opts.focusedEstimateSeconds * 1000, - }, - { - id: "full", - argv: [opts.bunExecutable, "test"], - initialEstimateMs: opts.fullEstimateSeconds * 1000, - }, - ]; + const focused: LoopCommand = { + id: "focused", + argv: [opts.bunExecutable, "test", FOCUSED_TEST_FILE], + initialEstimateMs: opts.focusedEstimateSeconds * 1000, + }; + const full: LoopCommand = { + id: "full", + argv: [opts.bunExecutable, "test"], + initialEstimateMs: opts.fullEstimateSeconds * 1000, + }; + switch (opts.mode) { + case "focused": + return [focused]; + case "full": + return [full]; + case "alternate": + return [focused, full]; + default: + throw new LoopError("Invalid mode"); + } +} + +/** + * The estimate for the next start of a command: its conservative prior until a + * run completes, then the longest completed run times {@link ESTIMATE_HEADROOM}. + */ +export function nextEstimateMs(opts: { + readonly priorMs: number; + readonly longestCompletedMs: number | null; +}): number { + return opts.longestCompletedMs === null + ? opts.priorMs + : Math.ceil(opts.longestCompletedMs * ESTIMATE_HEADROOM); } /** - * Picks the next command, or null when none can finish by the deadline. - * The command at `cursor` is preferred; when it does not fit, the next command in - * sequence order that fits runs instead. Equality with the deadline fits. + * Decides the next step for the active rotation: run the command at `cursor` + * when it ends at or before the deadline, otherwise skip it for budget. Equality + * with the deadline fits; nothing fits once the deadline is reached. + * Returns null when no command is left. */ -export function planNextCommand(opts: { +export function planNextIteration(opts: { readonly nowMs: number; readonly deadlineMs: number; - readonly commands: readonly LoopCommand[]; + readonly active: readonly LoopCommand[]; readonly cursor: number; - readonly estimatesMs: Readonly>; -}): { readonly command: LoopCommand; readonly cursor: number } | null { - const count = opts.commands.length; - if (count === 0 || opts.nowMs >= opts.deadlineMs) { + readonly estimatesMs: Readonly>>; +}): { + readonly action: "run" | "skip"; + readonly command: LoopCommand; + readonly position: number; +} | null { + const count = opts.active.length; + if (count === 0) { return null; } - const preferred = ((opts.cursor % count) + count) % count; - for (let offset = 0; offset < count; offset += 1) { - const command = opts.commands[(preferred + offset) % count]; - if ( - command !== undefined && - opts.nowMs + opts.estimatesMs[command.id] <= opts.deadlineMs - ) { - return { command, cursor: preferred + 1 }; - } + const position = ((opts.cursor % count) + count) % count; + const command = opts.active[position]; + if (command === undefined) { + return null; } - return null; + const estimate = opts.estimatesMs[command.id] ?? command.initialEstimateMs; + const fits = + opts.nowMs < opts.deadlineMs && opts.nowMs + estimate <= opts.deadlineMs; + return { action: fits ? "run" : "skip", command, position }; } function classifyIteration(run: ExecutedRun): { @@ -143,7 +213,7 @@ function classifyIteration(run: ExecutedRun): { return classifyBunRun(run); } -/** Runs commands until none can finish within the budget. */ +/** Runs commands until every command has been skipped for budget. */ export async function runBunCrashLoop(opts: { readonly budgetMs: number; readonly commands: readonly LoopCommand[]; @@ -153,36 +223,62 @@ export async function runBunCrashLoop(opts: { context: { readonly index: number; readonly deadlineMs: number } ) => Promise; readonly onIteration?: (record: IterationRecord) => void; + readonly onSkip?: (skip: SkippedForBudget) => void; }): Promise { const startMs = opts.now(); const deadlineMs = startMs + opts.budgetMs; - const estimatesMs = {} as Record; + const estimatesMs: Partial> = {}; + const longestCompletedMs: Partial> = {}; for (const command of opts.commands) { estimatesMs[command.id] = command.initialEstimateMs; } + const active = [...opts.commands]; const iterations: IterationRecord[] = []; + const skippedForBudget: SkippedForBudget[] = []; let cursor = 0; for (;;) { - const next = planNextCommand({ - nowMs: opts.now(), + const nowMs = opts.now(); + const next = planNextIteration({ + nowMs, deadlineMs, - commands: opts.commands, + active, cursor, estimatesMs, }); if (next === null) { break; } - cursor = next.cursor; + if (next.action === "skip") { + const skip: SkippedForBudget = { + command: next.command.id, + reason: "budget", + atMs: Math.max(0, nowMs - startMs), + remainingMs: Math.max(0, deadlineMs - nowMs), + estimateMs: estimatesMs[next.command.id] ?? 0, + }; + skippedForBudget.push(skip); + opts.onSkip?.(skip); + // The command after the skipped one moves into its position. + active.splice(next.position, 1); + cursor = next.position; + continue; + } + cursor = next.position + 1; const index = iterations.length + 1; - const started = opts.now(); const run = await opts.execute(next.command, { index, deadlineMs }); - const durationMs = Math.max(0, opts.now() - started); - estimatesMs[next.command.id] = Math.max( - estimatesMs[next.command.id], - durationMs - ); + const durationMs = Math.max(0, opts.now() - nowMs); const { outcome, reason } = classifyIteration(run); + if (!run.stoppedAtDeadline) { + const longest = Math.max( + longestCompletedMs[next.command.id] ?? 0, + durationMs + ); + longestCompletedMs[next.command.id] = longest; + estimatesMs[next.command.id] = nextEstimateMs({ + priorMs: next.command.initialEstimateMs, + longestCompletedMs: longest, + }); + } const record: IterationRecord = { index, command: next.command.id, @@ -201,32 +297,114 @@ export async function runBunCrashLoop(opts: { return { budgetMs: opts.budgetMs, elapsedMs: Math.max(0, opts.now() - startMs), + commands: opts.commands.map((command) => command.id), iterations, + skippedForBudget, estimatesMs, }; } -const OUTCOMES: readonly IterationOutcome[] = [ - "pass", - "failure", - "crash", - "overrun", -]; - -export function summarizeIterations( - iterations: readonly IterationRecord[] -): Record> { - const empty = (): Record => ({ - pass: 0, - failure: 0, - crash: 0, - overrun: 0, +/** Per-command counts. `failures` are non-crash failures. */ +export interface CommandCounts { + iterations: number; + passes: number; + crashes: number; + failures: number; + overruns: number; + skippedForBudget: number; +} + +/** Counts for each configured command, in rotation order. */ +export function countIterations( + result: Pick +): Partial> { + const counts: Partial> = {}; + for (const id of result.commands) { + counts[id] = { + iterations: 0, + passes: 0, + crashes: 0, + failures: 0, + overruns: 0, + skippedForBudget: 0, + }; + } + const outcomeField = { + pass: "passes", + crash: "crashes", + failure: "failures", + overrun: "overruns", + } as const satisfies Record; + for (const record of result.iterations) { + const row = counts[record.command]; + if (row !== undefined) { + row.iterations += 1; + row[outcomeField[record.outcome]] += 1; + } + } + for (const skip of result.skippedForBudget) { + const row = counts[skip.command]; + if (row !== undefined) { + row.skippedForBudget += 1; + } + } + return counts; +} + +/** + * One closed line, for example `baseline 0/97 focused crashes` or + * `Bun 1.4.2 0/40 focused crashes, 0/1 full crashes`. + */ +export function renderVerdict(opts: { + readonly result: Pick< + LoopResult, + "commands" | "iterations" | "skippedForBudget" + >; + readonly role: LoopRole | null; + readonly bunVersion: string | null; +}): string { + const counts = countIterations(opts.result); + const subject = opts.role ?? `Bun ${opts.bunVersion ?? "unknown"}`; + const parts = opts.result.commands.map((id) => { + const row = counts[id]; + return `${row?.crashes ?? 0}/${row?.iterations ?? 0} ${id} crashes`; }); - const totals = { focused: empty(), full: empty() }; - for (const record of iterations) { - totals[record.command][record.outcome] += 1; + const overruns = opts.result.commands.reduce( + (sum, id) => sum + (counts[id]?.overruns ?? 0), + 0 + ); + let verdict = `${subject} ${parts.join(", ")}`; + if (opts.result.iterations.length === 0) { + verdict += "; no iteration fit the budget"; + } else if (overruns > 0) { + verdict += `; ${overruns} overrun`; } - return totals; + return verdict; +} + +/** The closed, value-free `summary.json` document. */ +export function buildLoopSummary(opts: { + readonly result: LoopResult; + readonly mode: LoopMode; + readonly role: LoopRole | null; + readonly bunVersion: string | null; + readonly sourceRevision: string | null; + readonly diagnosticReports: number; +}) { + return { + verdict: renderVerdict(opts), + bunVersion: opts.bunVersion, + role: opts.role, + mode: opts.mode, + sourceRevision: opts.sourceRevision, + diagnosticReports: opts.diagnosticReports, + budgetMs: opts.result.budgetMs, + elapsedMs: opts.result.elapsedMs, + counts: countIterations(opts.result), + estimatesMs: opts.result.estimatesMs, + skippedForBudget: opts.result.skippedForBudget, + iterations: opts.result.iterations, + }; } const seconds = (ms: number): string => (ms / 1000).toFixed(1); @@ -234,31 +412,36 @@ const seconds = (ms: number): string => (ms / 1000).toFixed(1); /** Markdown for `$GITHUB_STEP_SUMMARY`. Only closed tokens, numbers and validated identifiers. */ export function renderLoopSummary(opts: { readonly result: LoopResult; + readonly mode: LoopMode; + readonly role: LoopRole | null; readonly bunVersion: string | null; readonly sourceRevision: string | null; readonly diagnosticReports: number; }): string { - const totals = summarizeIterations(opts.result.iterations); + const counts = countIterations(opts.result); const lines = [ "## Bun crash experiment", "", + `Verdict: ${renderVerdict(opts)}`, + "", "| Field | Value |", "| --- | --- |", `| Bun | ${opts.bunVersion ?? "unknown"} |`, + `| Role | ${opts.role ?? "-"} |`, + `| Mode | ${opts.mode} |`, `| Source revision | ${opts.sourceRevision ?? "unknown"} |`, `| Budget seconds | ${seconds(opts.result.budgetMs)} |`, `| Loop seconds | ${seconds(opts.result.elapsedMs)} |`, `| Iterations | ${opts.result.iterations.length} |`, `| Crash reports (bun*.ips) | ${opts.diagnosticReports} |`, "", - "| Command | Runs | Pass | Failure | Crash | Overrun | Estimate seconds |", - "| --- | --- | --- | --- | --- | --- | --- |", + "| Command | Runs | Pass | Crash | Other failure | Overrun | Skipped for budget | Estimate seconds |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", ]; - for (const id of ["focused", "full"] as const) { - const row = totals[id]; - const runs = OUTCOMES.reduce((sum, outcome) => sum + row[outcome], 0); + for (const id of opts.result.commands) { + const row = counts[id]; lines.push( - `| ${id} | ${runs} | ${row.pass} | ${row.failure} | ${row.crash} | ${row.overrun} | ${seconds(opts.result.estimatesMs[id] ?? 0)} |` + `| ${id} | ${row?.iterations ?? 0} | ${row?.passes ?? 0} | ${row?.crashes ?? 0} | ${row?.failures ?? 0} | ${row?.overruns ?? 0} | ${row?.skippedForBudget ?? 0} | ${seconds(opts.result.estimatesMs[id] ?? 0)} |` ); } lines.push( @@ -271,6 +454,11 @@ export function renderLoopSummary(opts: { `| ${record.index} | ${record.command} | ${record.outcome} | ${record.reason} | ${record.exitCode ?? "-"} | ${record.signal ?? "-"} | ${seconds(record.durationMs)} |` ); } + for (const skip of opts.result.skippedForBudget) { + lines.push( + `| - | ${skip.command} | skipped | budget | - | - | ${seconds(skip.estimateMs)} estimated, ${seconds(skip.remainingMs)} left |` + ); + } lines.push( "", "Logs and crash reports are in the run artifact. Crash-report links are never opened.", @@ -366,6 +554,8 @@ export function executeWithLog(opts: { export interface LoopArguments { readonly budgetSeconds: number; readonly outDir: string; + readonly mode: LoopMode; + readonly role: LoopRole | null; readonly expectBunVersion: string | null; readonly sourceRevision: string | null; readonly focusedEstimateSeconds: number; @@ -383,28 +573,49 @@ function parseSeconds(name: string, value: string, min: number, max: number) { return parsed; } -export function parseLoopArguments(argv: readonly string[]): LoopArguments { +function parseClosed( + name: string, + value: string, + allowed: readonly T[] +): T { + const match = allowed.find((candidate) => candidate === value); + if (match === undefined) { + throw new LoopError(`Invalid ${name}`); + } + return match; +} + +const KNOWN_FLAGS: ReadonlySet = new Set([ + "budget-seconds", + "out-dir", + "mode", + "role", + "expect-bun-version", + "source-revision", + "focused-estimate-seconds", + "full-estimate-seconds", +]); + +/** `--name=value` flags; unknown, malformed or repeated flags are rejected. */ +function readFlags(argv: readonly string[]): ReadonlyMap { const values = new Map(); for (const arg of argv) { const match = /^--([a-z-]+)=(.*)$/.exec(arg); - if (!match?.[1] || match[2] === undefined || values.has(match[1])) { + if ( + !match?.[1] || + match[2] === undefined || + values.has(match[1]) || + !KNOWN_FLAGS.has(match[1]) + ) { throw new LoopError("Invalid argument"); } values.set(match[1], match[2]); } - const known = new Set([ - "budget-seconds", - "out-dir", - "expect-bun-version", - "source-revision", - "focused-estimate-seconds", - "full-estimate-seconds", - ]); - for (const key of values.keys()) { - if (!known.has(key)) { - throw new LoopError("Invalid argument"); - } - } + return values; +} + +export function parseLoopArguments(argv: readonly string[]): LoopArguments { + const values = readFlags(argv); const budget = values.get("budget-seconds"); if (budget === undefined) { throw new LoopError("Missing --budget-seconds"); @@ -421,6 +632,7 @@ export function parseLoopArguments(argv: readonly string[]): LoopArguments { if (outDir !== undefined && outDir.length === 0) { throw new LoopError("Invalid --out-dir"); } + const role = values.get("role"); return { budgetSeconds: parseSeconds( "--budget-seconds", @@ -429,6 +641,12 @@ export function parseLoopArguments(argv: readonly string[]): LoopArguments { MAX_BUDGET_SECONDS ), outDir: resolve(outDir ?? join(tmpdir(), "bun-crash-loop")), + mode: parseClosed( + "--mode", + values.get("mode") ?? DEFAULT_LOOP_MODE, + LOOP_MODES + ), + role: role === undefined ? null : parseClosed("--role", role, LOOP_ROLES), expectBunVersion: expect ?? null, sourceRevision: revision ?? null, focusedEstimateSeconds: parseSeconds( @@ -461,6 +679,7 @@ async function main(): Promise { budgetMs: args.budgetSeconds * 1000, commands: loopCommands({ bunExecutable: process.execPath, + mode: args.mode, focusedEstimateSeconds: args.focusedEstimateSeconds, fullEstimateSeconds: args.fullEstimateSeconds, }), @@ -471,37 +690,37 @@ async function main(): Promise { `iteration=${record.index} command=${record.command} outcome=${record.outcome} reason=${record.reason} seconds=${seconds(record.durationMs)}\n` ); }, + onSkip: (skip) => { + process.stdout.write( + `skipped command=${skip.command} reason=${skip.reason} estimate_seconds=${seconds(skip.estimateMs)} remaining_seconds=${seconds(skip.remainingMs)}\n` + ); + }, }); const diagnosticReports = await collectDiagnosticReports({ source: join(homedir(), "Library", "Logs", "DiagnosticReports"), destination: join(args.outDir, "diagnostic-reports"), sinceMs: loopStartMs, }); - const summary = renderLoopSummary({ + const report = { result, + mode: args.mode, + role: args.role, bunVersion, sourceRevision: args.sourceRevision, diagnosticReports, - }); + }; await Bun.write( join(args.outDir, "summary.json"), - `${JSON.stringify( - { - bunVersion, - sourceRevision: args.sourceRevision, - diagnosticReports, - ...result, - }, - null, - 2 - )}\n` + `${JSON.stringify(buildLoopSummary(report), null, 2)}\n` ); + const summary = renderLoopSummary(report); const stepSummary = process.env.GITHUB_STEP_SUMMARY; if (stepSummary) { await appendFile(stepSummary, summary); } else { process.stdout.write(summary); } + process.stdout.write(`verdict: ${renderVerdict(report)}\n`); if (result.iterations.length === 0) { throw new LoopError("No iteration fits the budget"); } diff --git a/scripts/lib/bun-crash-classify.ts b/scripts/lib/bun-crash-classify.ts index 443176811..0e4134fac 100644 --- a/scripts/lib/bun-crash-classify.ts +++ b/scripts/lib/bun-crash-classify.ts @@ -9,8 +9,11 @@ * crash-report links or values) is ever returned. */ -/** Exit statuses a shell reports for Bun's crash signals: SIGTRAP (133) and SIGSEGV (139). */ -export const BUN_CRASH_EXIT_CODES: readonly number[] = [133, 139]; +/** + * Exit statuses a shell reports for Bun's crash signals: SIGTRAP (133), SIGABRT + * (134), SIGBUS (138) and SIGSEGV (139). + */ +export const BUN_CRASH_EXIT_CODES: readonly number[] = [133, 134, 138, 139]; export type BunRunOutcome = "crash" | "failure" | "pass"; @@ -19,7 +22,7 @@ export type BunRunReason = | "banner" /** The process was ended by a signal it did not get from the harness. */ | "signal" - /** The exit status encodes a crash signal (133 or 139). */ + /** The exit status encodes a crash signal (133, 134, 138 or 139). */ | "signal_exit" /** Exit 0 and no crash banner. */ | "exit_zero" diff --git a/tests/bun-crash-loop.test.ts b/tests/bun-crash-loop.test.ts index e7a3ff683..ba3ef6ad3 100644 --- a/tests/bun-crash-loop.test.ts +++ b/tests/bun-crash-loop.test.ts @@ -3,14 +3,18 @@ import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { + buildLoopSummary, collectDiagnosticReports, + countIterations, type ExecutedRun, executeWithLog, type LoopCommand, loopCommands, + nextEstimateMs, parseLoopArguments, - planNextCommand, + planNextIteration, renderLoopSummary, + renderVerdict, runBunCrashLoop, } from "../scripts/bun-crash-loop.ts"; import { @@ -51,8 +55,8 @@ describe("classifyBunRun", () => { ).toEqual({ outcome: "crash", reason: "banner" }); }); - test("exit 133 or 139 without a banner is a signal crash", () => { - for (const exitCode of [133, 139]) { + test("a trap, abort, bus-error or segfault exit status without a banner is a signal crash", () => { + for (const exitCode of [133, 134, 138, 139]) { expect(classifyBunRun({ exitCode, signal: null, output: "" })).toEqual({ outcome: "crash", reason: "signal_exit", @@ -148,78 +152,188 @@ const commands: readonly LoopCommand[] = [ { id: "full", argv: ["full"], initialEstimateMs: 300_000 }, ]; -describe("planNextCommand deadline arithmetic", () => { +const PASS: ExecutedRun = { + exitCode: 0, + signal: null, + output: " 3 pass\n 0 fail\n", + stoppedAtDeadline: false, +}; + +/** + * An injected executor on an injected clock. A run that would end past the + * deadline is stopped at the deadline, as the real executor does, so starting an + * iteration that cannot fit shows up as an overrun. + */ +function simulatedExecutor(opts: { + readonly clock: { now: number }; + readonly durationMs: (command: LoopCommand) => number; + readonly run?: (command: LoopCommand, index: number) => ExecutedRun; + readonly starts?: { id: string; at: number; argv: readonly string[] }[]; +}) { + return async ( + command: LoopCommand, + context: { readonly index: number; readonly deadlineMs: number } + ): Promise => { + opts.starts?.push({ + id: command.id, + at: opts.clock.now, + argv: command.argv, + }); + const end = opts.clock.now + opts.durationMs(command); + if (end > context.deadlineMs) { + opts.clock.now = context.deadlineMs; + return { + exitCode: null, + signal: "SIGTERM", + output: "", + stoppedAtDeadline: true, + }; + } + opts.clock.now = end; + return opts.run?.(command, context.index) ?? PASS; + }; +} + +describe("planNextIteration deadline arithmetic", () => { const estimatesMs = { focused: 100_000, full: 300_000 }; test("a command that ends exactly at the deadline may start", () => { expect( - planNextCommand({ + planNextIteration({ nowMs: 700_000, deadlineMs: 1_000_000, - commands, + active: commands, cursor: 1, estimatesMs, - })?.command.id - ).toBe("full"); - }); - - test("a command that would end past the deadline falls back to one that fits", () => { - const next = planNextCommand({ - nowMs: 700_001, - deadlineMs: 1_000_000, - commands, - cursor: 1, - estimatesMs, - }); - expect(next?.command.id).toBe("focused"); - expect(next?.cursor).toBe(2); + }) + ).toMatchObject({ action: "run", position: 1, command: { id: "full" } }); }); - test("nothing starts when no command fits or the deadline has passed", () => { + test("a command that would end past the deadline is skipped, not started", () => { expect( - planNextCommand({ - nowMs: 900_001, + planNextIteration({ + nowMs: 700_001, deadlineMs: 1_000_000, - commands, - cursor: 0, + active: commands, + cursor: 1, estimatesMs, }) - ).toBeNull(); + ).toMatchObject({ action: "skip", position: 1, command: { id: "full" } }); + }); + + test("nothing starts once the deadline is reached, and an empty rotation ends", () => { expect( - planNextCommand({ + planNextIteration({ nowMs: 1_000_000, deadlineMs: 1_000_000, - commands, + active: commands, cursor: 0, estimatesMs: { focused: 0, full: 0 }, + })?.action + ).toBe("skip"); + expect( + planNextIteration({ + nowMs: 0, + deadlineMs: 1_000_000, + active: [], + cursor: 0, + estimatesMs, }) ).toBeNull(); }); + + test("the estimate is the prior until a run completes, then the longest run with headroom", () => { + expect( + nextEstimateMs({ priorMs: 2_400_000, longestCompletedMs: null }) + ).toBe(2_400_000); + expect( + nextEstimateMs({ priorMs: 120_000, longestCompletedMs: 16_500 }) + ).toBe(19_800); + }); }); describe("runBunCrashLoop", () => { - test("no iteration starts past the budget and estimates grow to observed durations", async () => { - let clock = 0; - const durations = { focused: 90_000, full: 500_000 }; - const starts: { id: string; at: number }[] = []; + test("a 30-minute alternating budget never starts the 35-minute suite and records the skip", async () => { + // The hosted shape: focused ~16.5 s, full suite ~35 min, 30-minute budget. + const clock = { now: 0 }; + const starts: { id: string; at: number; argv: readonly string[] }[] = []; + const budgetMs = 1_800_000; + const result = await runBunCrashLoop({ + budgetMs, + commands: loopCommands({ + bunExecutable: "/bin/bun", + mode: "alternate", + focusedEstimateSeconds: 120, + fullEstimateSeconds: 2400, + }), + now: () => clock.now, + execute: simulatedExecutor({ + clock, + starts, + durationMs: (command) => (command.id === "full" ? 2_100_000 : 16_500), + }), + }); + expect(starts.some((start) => start.id === "full")).toBe(false); + expect(starts).toHaveLength(108); + for (const start of starts) { + expect(start.at + 16_500).toBeLessThanOrEqual(budgetMs); + } + expect(result.iterations.every((record) => record.outcome === "pass")).toBe( + true + ); + expect(result.skippedForBudget).toEqual([ + { + command: "full", + reason: "budget", + atMs: 16_500, + remainingMs: 1_783_500, + estimateMs: 2_400_000, + }, + { + command: "focused", + reason: "budget", + atMs: 1_782_000, + remainingMs: 18_000, + estimateMs: 19_800, + }, + ]); + expect(countIterations(result)).toEqual({ + focused: { + iterations: 108, + passes: 108, + crashes: 0, + failures: 0, + overruns: 0, + skippedForBudget: 1, + }, + full: { + iterations: 0, + passes: 0, + crashes: 0, + failures: 0, + overruns: 0, + skippedForBudget: 1, + }, + }); + }); + + test("estimates follow measured durations and no start overruns the deadline", async () => { + const clock = { now: 0 }; + const starts: { id: string; at: number; argv: readonly string[] }[] = []; const result = await runBunCrashLoop({ budgetMs: 1_000_000, commands, - now: () => clock, - execute: async (command) => { - starts.push({ id: command.id, at: clock }); - clock += durations[command.id]; - return { - exitCode: 0, - signal: null, - output: "", - stoppedAtDeadline: false, - }; - }, + now: () => clock.now, + execute: simulatedExecutor({ + clock, + starts, + durationMs: (command) => (command.id === "full" ? 500_000 : 90_000), + }), }); - // After the full suite takes 500 s, its estimate is 500 s and it no longer - // fits; focused runs (estimate 100 s) continue until 950 s + 100 s > 1000 s. - expect(starts).toEqual([ + // The full suite fits its 300 s prior once; measured at 500 s (600 s with + // headroom) it no longer fits at 680 s and leaves the rotation. Focused runs + // (108 s estimate after the first) continue until 950 s + 108 s > 1000 s. + expect(starts.map(({ id, at }) => ({ id, at }))).toEqual([ { id: "focused", at: 0 }, { id: "full", at: 90_000 }, { id: "focused", at: 590_000 }, @@ -227,16 +341,141 @@ describe("runBunCrashLoop", () => { { id: "focused", at: 770_000 }, { id: "focused", at: 860_000 }, ]); - for (const start of starts) { - const estimate = start.id === "full" ? 300_000 : 100_000; - expect(start.at + estimate).toBeLessThanOrEqual(1_000_000); - } - expect(result.estimatesMs).toEqual({ focused: 100_000, full: 500_000 }); + expect(result.estimatesMs).toEqual({ focused: 108_000, full: 600_000 }); + expect( + result.skippedForBudget.map(({ command, atMs }) => [command, atMs]) + ).toEqual([ + ["full", 680_000], + ["focused", 950_000], + ]); expect(result.iterations.map((record) => record.outcome)).toEqual( new Array(6).fill("pass") ); }); + test("focused mode only ever runs the owner test file", async () => { + const clock = { now: 0 }; + const starts: { id: string; at: number; argv: readonly string[] }[] = []; + const result = await runBunCrashLoop({ + budgetMs: 600_000, + commands: loopCommands({ + bunExecutable: "/bin/bun", + mode: "focused", + focusedEstimateSeconds: 120, + fullEstimateSeconds: 2400, + }), + now: () => clock.now, + execute: simulatedExecutor({ clock, starts, durationMs: () => 20_000 }), + }); + expect(starts.length).toBeGreaterThan(20); + for (const start of starts) { + expect(start.argv).toEqual([ + "/bin/bun", + "test", + "tests/native-https-owner.test.ts", + ]); + } + expect(result.commands).toEqual(["focused"]); + expect(Object.keys(countIterations(result))).toEqual(["focused"]); + expect(result.skippedForBudget.map((skip) => skip.command)).toEqual([ + "focused", + ]); + }); + + test("full mode with a budget below the prior starts nothing and says so", async () => { + const clock = { now: 0 }; + const starts: { id: string; at: number; argv: readonly string[] }[] = []; + const result = await runBunCrashLoop({ + budgetMs: 1_800_000, + commands: loopCommands({ + bunExecutable: "/bin/bun", + mode: "full", + focusedEstimateSeconds: 120, + fullEstimateSeconds: 2400, + }), + now: () => clock.now, + execute: simulatedExecutor({ clock, starts, durationMs: () => 1 }), + }); + expect(starts).toEqual([]); + expect(result.skippedForBudget).toHaveLength(1); + expect( + renderVerdict({ result, role: "candidate", bunVersion: "1.4.2" }) + ).toBe("candidate 0/0 full crashes; no iteration fit the budget"); + }); + + test("injected crashes count as crashes, other non-zero exits as other failures", async () => { + const clock = { now: 0 }; + const injected: ExecutedRun[] = [ + PASS, + // Bun's own banner, whatever the exit status. + { + exitCode: 0, + signal: null, + output: CRASH_TRANSCRIPT, + stoppedAtDeadline: false, + }, + // Signal deaths as Bun.spawn reports them; no real process gets a signal. + { + exitCode: null, + signal: "SIGSEGV", + output: "", + stoppedAtDeadline: false, + }, + { + exitCode: null, + signal: "SIGBUS", + output: "", + stoppedAtDeadline: false, + }, + // Shell-style abort exit status without a banner. + { exitCode: 134, signal: null, output: "", stoppedAtDeadline: false }, + { + exitCode: 1, + signal: null, + output: "(fail) owner starts [5.00ms]\n 3 pass\n 1 fail\n", + stoppedAtDeadline: false, + }, + { exitCode: 2, signal: null, output: "", stoppedAtDeadline: false }, + ]; + const result = await runBunCrashLoop({ + budgetMs: 200_000, + commands: [commands[0] as LoopCommand], + now: () => clock.now, + execute: simulatedExecutor({ + clock, + durationMs: () => 10_000, + run: (_command, index) => injected[index - 1] ?? PASS, + }), + }); + expect( + result.iterations + .slice(0, 7) + .map((record) => [record.outcome, record.reason]) + ).toEqual([ + ["pass", "exit_zero"], + ["crash", "banner"], + ["crash", "signal"], + ["crash", "signal"], + ["crash", "signal_exit"], + ["failure", "test_failure"], + ["failure", "exit_code"], + ]); + // 10 s runs, 12 s estimate after the first: starts at 0..180 s, then 190 s + 12 s > 200 s. + expect(countIterations(result)).toEqual({ + focused: { + iterations: 19, + passes: 13, + crashes: 4, + failures: 2, + overruns: 0, + skippedForBudget: 1, + }, + }); + expect( + renderVerdict({ result, role: "baseline", bunVersion: "1.3.9" }) + ).toBe("baseline 4/19 focused crashes"); + }); + test("a run stopped at the deadline is an overrun unless it printed the crash banner", async () => { let clock = 0; const runs: ExecutedRun[] = [ @@ -254,21 +493,15 @@ describe("runBunCrashLoop", () => { }, ]; const result = await runBunCrashLoop({ - budgetMs: 1_000_000, + budgetMs: 250_000, commands: [commands[0] as LoopCommand], now: () => clock, execute: async () => { clock += 100_000; - return ( - runs.shift() ?? { - exitCode: 0, - signal: null, - output: "", - stoppedAtDeadline: false, - } - ); + return runs.shift() ?? PASS; }, }); + expect(result.iterations).toHaveLength(2); expect(result.iterations[0]).toMatchObject({ outcome: "overrun", reason: "deadline", @@ -278,6 +511,12 @@ describe("runBunCrashLoop", () => { outcome: "crash", reason: "banner", }); + // A stopped run is not a measured duration, so the prior stays in force. + expect(result.estimatesMs.focused).toBe(100_000); + expect(countIterations(result).focused?.overruns).toBe(1); + expect(renderVerdict({ result, role: null, bunVersion: "1.3.9" })).toBe( + "Bun 1.3.9 1/2 focused crashes; 1 overrun" + ); }); test("a signal death from the executor is recorded as a crash with its closed signal name", async () => { @@ -303,19 +542,12 @@ describe("runBunCrashLoop", () => { }, ]; const result = await runBunCrashLoop({ - budgetMs: 300_000, + budgetMs: 400_000, commands: [commands[0] as LoopCommand], now: () => clock, execute: async () => { clock += 100_000; - return ( - runs.shift() ?? { - exitCode: 0, - signal: null, - output: "", - stoppedAtDeadline: false, - } - ); + return runs.shift() ?? PASS; }, }); expect( @@ -332,7 +564,7 @@ describe("runBunCrashLoop", () => { ]); }); - test("the summary is closed and value-free", async () => { + test("the summaries are closed and value-free", async () => { let clock = 0; const result = await runBunCrashLoop({ budgetMs: 250_000, @@ -348,17 +580,37 @@ describe("runBunCrashLoop", () => { }; }, }); - const summary = renderLoopSummary({ + const report = { result, + mode: "alternate" as const, + role: null, bunVersion: "1.3.9", sourceRevision: "2b8057c1ae6f4311150b6e139a9a919bbc7788d4", diagnosticReports: 1, - }); - expect(summary).toContain("| focused | 2 | 0 | 0 | 2 | 0 |"); + }; + const summary = renderLoopSummary(report); + expect(summary).toContain( + "Verdict: Bun 1.3.9 2/2 focused crashes, 0/0 full crashes" + ); + expect(summary).toContain("| focused | 2 | 0 | 2 | 0 | 0 | 1 | 120.0 |"); + expect(summary).toContain("| full | 0 | 0 | 0 | 0 | 0 | 1 | 300.0 |"); expect(summary).toContain("| 1 | focused | crash | banner | 133 | - |"); - expect(summary).not.toContain("do-not-print"); - expect(summary).not.toContain("bun.report"); - expect(summary).not.toContain("native-https-owner"); + expect(summary).toContain("| - | full | skipped | budget |"); + const json = JSON.stringify(buildLoopSummary(report)); + expect(JSON.parse(json)).toMatchObject({ + verdict: "Bun 1.3.9 2/2 focused crashes, 0/0 full crashes", + mode: "alternate", + role: null, + counts: { + focused: { iterations: 2, crashes: 2, skippedForBudget: 1 }, + full: { iterations: 0, skippedForBudget: 1 }, + }, + }); + for (const text of [summary, json]) { + expect(text).not.toContain("do-not-print"); + expect(text).not.toContain("bun.report"); + expect(text).not.toContain("native-https-owner"); + } }); }); @@ -434,21 +686,27 @@ describe("executeWithLog", () => { }); }); -test("loop commands run the focused owner test and then the full suite", () => { - expect( +test("each mode selects its commands, and focused is the owner test file alone", () => { + const ids = (mode: "focused" | "full" | "alternate") => loopCommands({ bunExecutable: "/bin/bun", + mode, focusedEstimateSeconds: 60, - fullEstimateSeconds: 600, - }).map((command) => [command.id, command.argv, command.initialEstimateMs]) - ).toEqual([ - [ - "focused", - ["/bin/bun", "test", "tests/native-https-owner.test.ts"], - 60_000, - ], - ["full", ["/bin/bun", "test"], 600_000], - ]); + fullEstimateSeconds: 2400, + }).map((command): unknown[] => [ + command.id, + command.argv, + command.initialEstimateMs, + ]); + const focused = [ + "focused", + ["/bin/bun", "test", "tests/native-https-owner.test.ts"], + 60_000, + ]; + const full = ["full", ["/bin/bun", "test"], 2_400_000]; + expect(ids("focused")).toEqual([focused]); + expect(ids("full")).toEqual([full]); + expect(ids("alternate")).toEqual([focused, full]); }); test("loop arguments are validated and closed", () => { @@ -462,10 +720,19 @@ test("loop arguments are validated and closed", () => { ).toMatchObject({ budgetSeconds: 1800, outDir: "/tmp/x", + mode: "focused", + role: null, expectBunVersion: "1.3.9", focusedEstimateSeconds: 120, - fullEstimateSeconds: 600, + fullEstimateSeconds: 2400, }); + expect( + parseLoopArguments([ + "--budget-seconds=1800", + "--mode=alternate", + "--role=baseline", + ]) + ).toMatchObject({ mode: "alternate", role: "baseline" }); for (const argv of [ [], ["--budget-seconds=10"], @@ -474,6 +741,10 @@ test("loop arguments are validated and closed", () => { ["--budget-seconds=1800", "--unknown=1"], ["--budget-seconds=1800", "--expect-bun-version=latest"], ["--budget-seconds=1800", "--source-revision=main"], + ["--budget-seconds=1800", "--mode=both"], + ["--budget-seconds=1800", "--mode="], + ["--budget-seconds=1800", "--role=dispatch"], + ["--budget-seconds=1800", "--role="], ]) { expect(() => parseLoopArguments(argv)).toThrow(); } From dbaf032da57bc7b14cfce69e3c380c3200882daf Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 14:59:27 -0400 Subject: [PATCH 03/11] fix(native): wait only for active services at native up Native `up` emitted a `--ready` goal for every compose service, including services in profiles that were not selected. The runtime does not schedule inactive services and refuses readiness for them ("Readiness includes inactive or unknown services."), so any project with an unused profile failed with graph_readiness. Build readiness goals only for services the reviewed plan marks active, and ignore dependency edges declared by inactive services so they cannot raise an active service's goal. The runtime refusal stays as a guard; a contract test now proves it still rejects inactive and unknown services. Bridge pool capacity keeps counting inactive routed services: it is sized before the profile-aware review and must remain an upper bound. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/tests/project_contract.rs | 27 +++ src/backends/native-project-routing.ts | 8 +- src/backends/native-project-start.ts | 50 ++++-- tests/native-project-start.test.ts | 161 ++++++++++++++++++ 4 files changed, 231 insertions(+), 15 deletions(-) diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index c62d502cd..e48c9bdd5 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -1309,6 +1309,33 @@ fn execution_dependency_compiler_preserves_review_conditions_and_refuses_invalid ); } +#[test] +fn execution_dependency_compiler_refuses_readiness_for_inactive_or_unknown_services() { + use project::execution::{Condition, Graph}; + use std::collections::BTreeMap; + let fixture = Fixture::new( + "services:\n web:\n image: alpine:3.21\n tools:\n image: alpine:3.21\n profiles: [debug]\n", + ); + let plan = fixture.plan().plan; + assert!(plan.enrollment_compatible); + assert!(plan.services["web"].active); + assert!(!plan.services["tools"].active); + let active = BTreeMap::from([("web".into(), Condition::Started)]); + let graph = Graph::from_plan(&plan, &active).unwrap(); + assert_eq!(graph.services.keys().collect::>(), ["web"]); + for extra in ["tools", "ghost"] { + let mut goals = active.clone(); + goals.insert(extra.into(), Condition::Started); + let refusal = Graph::from_plan(&plan, &goals).unwrap_err(); + assert_eq!(refusal.code, "graph_readiness", "{extra}"); + assert!( + refusal.message.contains("inactive or unknown"), + "{extra}: {}", + refusal.message + ); + } +} + #[test] fn executable_inputs_preserve_argv_and_resolve_only_explicit_values() { use project::inputs; diff --git a/src/backends/native-project-routing.ts b/src/backends/native-project-routing.ts index 167bad7b8..780dd2a8c 100644 --- a/src/backends/native-project-routing.ts +++ b/src/backends/native-project-routing.ts @@ -30,7 +30,13 @@ export function hasOnlyNativeSupportedLabels(labels: unknown): boolean { ) ?? false ); } -/** Count all declared routes, including inactive profiles, before allocating the pool. */ +/** + * Count all declared routes, including inactive profiles, before allocating the pool. + * + * This runs before the profile-aware review, so it is a conservative upper bound: profile + * activation stays the reviewed plan's decision, and `reviewedNativeRoutes` must never find + * more active routes than this floor. Unlike readiness goals, an unused slot is harmless. + */ export function nativeBridgeCapacity( specs: Readonly>>> ): number { diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 1d85a18fe..c1413120a 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -334,18 +334,36 @@ async function pinNativeProjectImages(opts: { spec.image = ensured.image_id; } } -function readiness( - specs: Record>, - initializers: ReadonlySet, - routed: ReadonlySet -): string[] { +/** + * Build `--ready` goals for exactly the services the reviewed plan marks active. + * + * Compose services outside the selected profiles stay in the normalized specs (they are + * still reviewed and pinned), but the runtime does not schedule them and refuses readiness + * goals for inactive or unknown services. Dependency edges declared by inactive services + * are ignored so they cannot raise an active service's readiness goal. + */ +export function nativeReadinessArgs(opts: { + readonly specs: Readonly>>>; + readonly plan: unknown; + readonly initializers: ReadonlySet; + readonly routed: ReadonlySet; +}): string[] { + const reviewed = + isRecord(opts.plan) && isRecord(opts.plan.services) + ? opts.plan.services + : {}; + const active = (name: string): boolean => { + const service = reviewed[name]; + return isRecord(service) && service.active === true; + }; + const specs = Object.entries(opts.specs).filter(([name]) => active(name)); const ready: Record = Object.fromEntries( - Object.entries(specs).map(([name, spec]) => [ + specs.map(([name, spec]) => [ name, spec.healthcheck ? "healthy" : "started", ]) ); - for (const spec of Object.values(specs)) { + for (const [, spec] of specs) { if (!isRecord(spec.depends_on)) { continue; } @@ -361,16 +379,15 @@ function readiness( } } } - for (const name of initializers) { + for (const name of opts.initializers) { ready[name] = "completed"; } - for (const name of routed) { + for (const name of opts.routed) { ready[name] = "healthy"; } - return Object.entries(ready).flatMap(([name, condition]) => [ - "--ready", - `${name}=${condition}`, - ]); + return Object.entries(ready) + .filter(([name]) => active(name)) + .flatMap(([name, condition]) => ["--ready", `${name}=${condition}`]); } function authoritative( value: unknown, @@ -1138,7 +1155,12 @@ export async function startNativeProject(opts: { review.planId, ...nativeSharedSourceFlags(review.report.plan), ...cache.flags, - ...readiness(specs, cache.initializers, routes.services), + ...nativeReadinessArgs({ + specs, + plan: review.report.plan, + initializers: cache.initializers, + routed: routes.services, + }), ...routes.flags, "--dependencies", dependencyFile, diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 710df0980..e34f75ec4 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -24,6 +24,7 @@ import { preflightNativeRestart } from "../src/backends/native-project-restart-p import { selectNativeRetainedImages } from "../src/backends/native-project-retained-images.ts"; import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; import { + nativeReadinessArgs, parseNativeHttpsSelection, reviewedNativeHttpsRoutes, startNativeProject, @@ -1018,6 +1019,166 @@ test("routed startup reserves bridges and enrolls reviewed healthy services with expect(await startNativeProject(opts)).toBe(0); }); +const profileProbe = { + port: 3000, + path: "/health", + interval_ms: 100, + timeout_ms: 500, + retries: 3, + start_period_ms: 0, +}; +/** One active service plus a routed and an unrouted service in the `debug` profile. */ +function profileSpecs(): Record> { + return { + web: { image: `sha256:${"e".repeat(64)}` }, + admin: { + image: `sha256:${"e".repeat(64)}`, + profiles: ["debug"], + labels: { + caddy: "admin.example.com", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + healthcheck: { "x-hack-http": profileProbe }, + }, + worker: { + image: `sha256:${"e".repeat(64)}`, + profiles: ["debug"], + depends_on: { web: { condition: "service_healthy" } }, + }, + }; +} +function profilePlan(debug: boolean) { + return { + enrollment_compatible: true, + services: { + web: { active: true }, + admin: { + active: debug, + routing: { port: 3000, hostnames: ["admin.example.com"] }, + healthcheck: { native_http: profileProbe, disabled: false }, + }, + worker: { active: debug }, + }, + }; +} +function readyGoals(args: readonly string[]): string[] { + return args.flatMap((arg, index) => + args[index - 1] === "--ready" ? [arg] : [] + ); +} + +test("readiness goals cover only services the reviewed plan marks active", () => { + expect( + nativeReadinessArgs({ + specs: profileSpecs(), + plan: profilePlan(false), + initializers: new Set(), + routed: new Set(), + }) + ).toEqual(["--ready", "web=started"]); + expect( + readyGoals( + nativeReadinessArgs({ + specs: profileSpecs(), + plan: profilePlan(true), + initializers: new Set(), + routed: new Set(["admin"]), + }) + ).sort() + ).toEqual(["admin=healthy", "web=healthy", "worker=started"]); + // Services absent from the plan are unknown, and a missing plan admits nothing. + expect( + nativeReadinessArgs({ + specs: profileSpecs(), + plan: { + enrollment_compatible: true, + services: { web: { active: true } }, + }, + initializers: new Set(["worker"]), + routed: new Set(["admin"]), + }) + ).toEqual(["--ready", "web=started"]); + expect( + nativeReadinessArgs({ + specs: profileSpecs(), + plan: undefined, + initializers: new Set(), + routed: new Set(), + }) + ).toEqual([]); +}); + +async function profileStartup(profiles: readonly string[] | undefined) { + const { opts } = await fixture(false); + const prepare = opts.dependencies.prepare!; + const review = opts.dependencies.review!; + const invoke = opts.dependencies.invoke!; + const serve = opts.dependencies.serve!; + const seen: { reviewed?: readonly string[]; serve?: readonly string[] } = {}; + let bridges: string | undefined; + opts.dependencies.prepare = async (call) => ({ + ...(await prepare(call)), + normalizedComposeJson: JSON.stringify({ services: profileSpecs() }), + serviceNames: ["web", "admin", "worker"], + }); + opts.dependencies.review = async (call) => { + seen.reviewed = call.profiles; + return await review({ + ...call, + run: async (checked) => + call.run({ + ...checked, + report: { + plan: profilePlan(call.profiles?.includes("debug") === true), + }, + }), + }); + }; + opts.dependencies.invoke = async (call) => { + if (call.args[0] === "runtime" && call.args[1] === "up") { + const index = call.args.indexOf("--minimum-bridge-sockets"); + bridges = index < 0 ? undefined : call.args[index + 1]; + } + return await invoke(call); + }; + opts.dependencies.serve = async (call) => { + seen.serve = call.args; + return await serve(call); + }; + expect( + await startNativeProject(profiles ? { ...opts, profiles } : opts) + ).toBe(0); + return { + reviewed: seen.reviewed, + goals: readyGoals(seen.serve ?? []).sort(), + serveArgs: seen.serve ?? [], + bridges, + }; +} + +test("native up omits routed and unrouted inactive-profile services from readiness", async () => { + const started = await profileStartup(undefined); + expect(started.reviewed).not.toContain("debug"); + // An inactive worker's healthy edge must not raise the active web goal. + expect(started.goals).toEqual(["web=started"]); + expect(started.serveArgs).not.toContain("admin=auto"); + // Pool capacity still counts the inactive routed service on purpose. + expect(started.bridges).toBe("1"); +}); + +test("an explicit profile activates its services' readiness and routes", async () => { + const started = await profileStartup(["debug"]); + expect(started.reviewed).toContain("debug"); + expect(started.goals).toEqual([ + "admin=healthy", + "web=healthy", + "worker=started", + ]); + expect(started.serveArgs).toContain("admin=auto"); + expect(started.bridges).toBe("1"); +}); + function leaseIdentity( selection: Parameters[0] ) { From 11c44e6ab8c35c38bf00c4c3c686fce146c78494 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 15:02:09 -0400 Subject: [PATCH 04/11] feat(macos): explain the Local Network grant for .local URLs On macOS, print one closed hint line on stderr when a command opens or prints a browser URL or dev host ending in .local (hack open, hack branch open, hack status/projects, and Doctor's browser check). Browsers other than Safari need the macOS Local Network permission once for .local names (Apple TN3179), even when Hack's resolver answers with loopback. The hint is shown at most once per command, never on other platforms, never for non-.local hosts, and never inside --json payloads. Hack does not read or change the permission. Document the name-based rule, the Safari exemption and the alternatives (OAuth alias, or a custom suffix under hack.gy) in the macOS browser guide, and link it from the init and native domain docs. Co-Authored-By: Claude Opus 5.5 --- docs/guides/init-project.md | 6 + docs/guides/macos-browser-network.md | 39 +++++++ docs/guides/native-candidate.md | 3 + src/commands/branch.ts | 2 + src/commands/doctor.ts | 24 +++- src/commands/project.ts | 3 + src/commands/projects.ts | 4 + src/lib/local-network-hint.ts | 113 ++++++++++++++++++ tests/local-network-hint.test.ts | 169 +++++++++++++++++++++++++++ 9 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 src/lib/local-network-hint.ts create mode 100644 tests/local-network-hint.test.ts diff --git a/docs/guides/init-project.md b/docs/guides/init-project.md index 6072fc289..6e28cdc6e 100644 --- a/docs/guides/init-project.md +++ b/docs/guides/init-project.md @@ -24,6 +24,10 @@ Notes: `hack doctor --browser-url`; DNS setup alone does not validate HTTPS or OAuth. Pass `--dev-host` for a per-project hostname; it takes precedence over the global default. Existing project hosts and Compose routes are not migrated. +- On macOS, Safari opens `.hack.local` names directly. Chrome, Firefox and other + browsers need the macOS Local Network permission once. See + [macOS browser connectivity](macos-browser-network.md) for the reason and for + alternatives that avoid `.local`. - `hack init` also scaffolds `.hack/hack.env.default.yaml`. See `docs/env.md`. - `hack up` starts the stack on an isolated network. - `hack open` resolves the routed URL via the global proxy and prefers the OAuth alias when @@ -117,6 +121,8 @@ Use Doctor to check for missing resolver configuration before starting a new pro review the proposed global setup changes rather than removing existing resolver files. `.local` has special multicast DNS semantics ([RFC 6762, section 3](https://www.rfc-editor.org/rfc/rfc6762.html#section-3)), so generated configuration alone does not establish host or browser reachability. +On macOS, browsers other than Safari need a one-time Local Network grant for +`.local` names; see [macOS browser connectivity](macos-browser-network.md). Live DNS, route and TLS qualification remains a prerelease acceptance gate. ## What discovery checks (and what it can't) diff --git a/docs/guides/macos-browser-network.md b/docs/guides/macos-browser-network.md index 14a20c920..c8f9dd9bf 100644 --- a/docs/guides/macos-browser-network.md +++ b/docs/guides/macos-browser-network.md @@ -6,6 +6,45 @@ Apple exempts command-line tools launched from Terminal or SSH. See Apple's [Local Network privacy explanation](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy) and [permission settings guide](https://support.apple.com/guide/mac-help/mchla4f49138/mac). +## `.local` names need a one-time grant outside Safari + +New projects use `.hack.local` by default. macOS applies Local Network +privacy by name as well as by address: Apple's TN3179 treats a `.local` lookup +as local network activity because `.local` is the multicast DNS (Bonjour) +namespace. A browser that opens `https://my-app.hack.local` therefore needs +Local Network access, even though Hack's resolver answers with a loopback +address. + +- **Safari** opens `.local` names without this permission. +- **Chrome, Firefox and other browsers** need it once per app. macOS asks the + first time; if you declined, enable it in **System Settings → Privacy & + Security → Local Network**, then fully quit and reopen the browser. + +On macOS, Hack prints one hint line on stderr when a command opens or prints a +`.local` browser URL (`hack open`, `hack branch open`, `hack status` and +`hack projects`, and Doctor's browser check). The hint is shown at most once per +command and never appears inside `--json` output. Hack does not read, request or +change this permission for you. + +If you prefer not to grant the permission, use a name outside `.local`: + +- **OAuth alias:** enable `oauth.enabled` (or `hack init --oauth`) and open the + project's `.hack.gy` alias. `hack open` prefers it automatically; + `hack open --prefer alias` requires it. +- **Custom suffix:** choose a dedicated suffix under `hack.gy`, for example + `dev.hack.gy`. With the native candidate, run + `hack global dns preview --domain dev.hack.gy` and then + `hack global dns activate --domain dev.hack.gy` + (see [native candidate DNS](native-candidate.md)). With the Docker-backed + stack, set `hack config set --global default_domain ` and run + `hack global install` (see [Initialize a project](init-project.md)). + Existing project hosts are not migrated automatically. + +Avoiding `.local` avoids only the name-based rule. Address-based checks still +apply, so verify the exact origin in the browser as described below. + +## Diagnose a browser failure + Run `hack doctor` first. DNS, resolver, Caddy, project services and certificate trust have separate checks. The browser check remains unverified until you report an observation; Doctor does not read the browser's permissions or its private data. diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index fce308140..9b1d076ce 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1301,6 +1301,9 @@ not delete these files or receipts blindly. The built-in `hack`, `hack.local`, and `hack.gy` roots cannot be claimed wholesale by this command; a dedicated `v5.hack.gy` subtree is supported when its v4 parent is verified. Existing project hosts and reviewed routes are not migrated. +A suffix outside `.local`, such as `v5.hack.gy`, also avoids the macOS +Local Network grant that browsers other than Safari need for `.local` names; +see [macOS browser connectivity](macos-browser-network.md). DNS activation alone does not prove that an app route, certificate, port 443, or OAuth redirect is ready. Use `hack doctor --browser-url` for the exact app diff --git a/src/commands/branch.ts b/src/commands/branch.ts index 6261b45a2..8a8c2807f 100644 --- a/src/commands/branch.ts +++ b/src/commands/branch.ts @@ -9,6 +9,7 @@ import { import { optOpenPreference, optPath, optProject } from "../cli/options.ts"; import { DEFAULT_PROJECT_TLD, HACK_PROJECT_DIR_PRIMARY } from "../constants.ts"; import { readBranchesFile, writeBranchesFile } from "../lib/branches.ts"; +import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { parseOpenHostPreference, resolvePreferredOpenHost, @@ -325,6 +326,7 @@ const handleBranchOpen: CommandHandlerFor = async ({ const url = `https://${resolvedHost}`; logger.step({ message: `Opening ${url}` }); + noteBrowserTargetsForLocalNetworkHint([url]); return await openUrl(url); }; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ea3123c2d..4e6af7b1e 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -93,6 +93,7 @@ import { isExplicitlyNonInteractive, } from "../lib/interactivity.ts"; import { findHackLocalCaCertPath } from "../lib/local-ca.ts"; +import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { describeSupersededRoots, inspectSupersededMacRoots, @@ -901,16 +902,23 @@ const handleDoctor: CommandHandlerFor = async ({ const hasError = results.some((r) => r.status === "error"); + const browserHintTargets = doctorBrowserHintTargets({ + url: browser.url, + defaultDomain, + }); if (json) { - return finishDoctorJson({ + const code = finishDoctorJson({ results, hasError, }); + noteBrowserTargetsForLocalNetworkHint(browserHintTargets); + return code; } await renderDoctorSummary(results); emitSlowChecksNote(results); await renderMacNote(results); + noteBrowserTargetsForLocalNetworkHint(browserHintTargets); if (!args.options.fix) { await renderRecoveryGuidance(results); } @@ -931,6 +939,14 @@ const handleDoctor: CommandHandlerFor = async ({ return 0; }; +/** The browser check concerns the reported origin, else the default domain. */ +function doctorBrowserHintTargets(opts: { + readonly url: string | null; + readonly defaultDomain: string; +}): readonly string[] { + return [opts.url ?? opts.defaultDomain]; +} + function resolveDoctorStartDir(pathOption: string | undefined): string { return resolve(process.cwd(), pathOption ?? "."); } @@ -1072,10 +1088,14 @@ async function handleNativeDoctor(opts: { } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); + const browserHintTargets = opts.browser.url ? [opts.browser.url] : []; if (opts.json) { - return finishDoctorJson({ results, hasError }); + const code = finishDoctorJson({ results, hasError }); + noteBrowserTargetsForLocalNetworkHint(browserHintTargets); + return code; } await renderDoctorSummary(results); + noteBrowserTargetsForLocalNetworkHint(browserHintTargets); if (!opts.browser.url) { note( "Pass --browser-url https://your-app.hack.local --browser-result works|fails|permission-denied to check an exact app route and record a separate browser observation.", diff --git a/src/commands/project.ts b/src/commands/project.ts index d8d967637..8cca8c1f9 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -199,6 +199,7 @@ import { appendHackHostTrustEnvironment, findHackHostTrustBundlePath, } from "../lib/local-ca.ts"; +import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { buildLogSelector, resolveShouldTryLoki, @@ -8378,9 +8379,11 @@ async function emitOpenOrigin(opts: { }): Promise { if (opts.json) { process.stdout.write(`${JSON.stringify({ url: opts.url }, null, 2)}\n`); + noteBrowserTargetsForLocalNetworkHint([opts.url]); return 0; } logger.step({ message: `Opening ${opts.url}` }); + noteBrowserTargetsForLocalNetworkHint([opts.url]); return await openUrl(opts.url); } diff --git a/src/commands/projects.ts b/src/commands/projects.ts index c77759f83..44986a07d 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -15,6 +15,7 @@ import { } from "../lib/caddy-hosts.ts"; import { emitCliResult, okResult } from "../lib/cli-result.ts"; import { confirmSafe } from "../lib/interactivity.ts"; +import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { createOperationTimings, type OperationTimings, @@ -590,6 +591,9 @@ async function runProjects(opts: { }); } } + noteBrowserTargetsForLocalNetworkHint( + views.flatMap((p) => (p.devHost ? [p.devHost] : [])) + ); return 0; } diff --git a/src/lib/local-network-hint.ts b/src/lib/local-network-hint.ts new file mode 100644 index 000000000..01b069069 --- /dev/null +++ b/src/lib/local-network-hint.ts @@ -0,0 +1,113 @@ +/** + * One-line macOS Local Network hint for browser-facing `.local` URLs. + * + * macOS decides Local Network access by name: a browser that resolves a + * `.local` hostname needs the per-app Local Network permission even when + * Hack's resolver answers with loopback (Apple TN3179). Safari is exempt; + * other browsers need a one-time grant. + * + * Contract: + * - The hint text is a closed constant: no hostnames, paths or other + * interpolated data. + * - It is written to stderr only, never to stdout and never into a `--json` + * payload, so machine-readable output stays unchanged. + * - It appears only on macOS, only for hosts ending in `.local`, and at most + * once per emitter. The process-wide emitter makes that once per command. + * - It never reads or changes the permission itself. + */ + +export const LOCAL_NETWORK_HINT_MESSAGE = + "Hint: on macOS, browsers other than Safari need a one-time Local Network permission to open .local names (System Settings > Privacy & Security > Local Network). See docs/guides/macos-browser-network.md."; + +const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i; +const TRAILING_DOTS = /\.+$/; + +/** + * Extracts the hostname from an absolute URL or a bare host, lowercased and + * without a trailing root dot. Returns null for empty or unparsable input. + */ +export function browserHostFromTarget(target: string): string | null { + const trimmed = target.trim(); + if (trimmed.length === 0) { + return null; + } + const candidate = URL_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`; + let hostname: string; + try { + hostname = new URL(candidate).hostname; + } catch { + return null; + } + const normalized = hostname.toLowerCase().replace(TRAILING_DOTS, ""); + return normalized.length > 0 ? normalized : null; +} + +/** True when the target's hostname is a name under the `.local` suffix. */ +export function isLocalSuffixTarget(target: string): boolean { + const host = browserHostFromTarget(target); + return host?.endsWith(".local") === true; +} + +/** True when the hint applies to any target on the given platform. */ +export function shouldShowLocalNetworkHint(opts: { + readonly platform: string; + readonly targets: Iterable; +}): boolean { + if (opts.platform !== "darwin") { + return false; + } + for (const target of opts.targets) { + if (isLocalSuffixTarget(target)) { + return true; + } + } + return false; +} + +export interface LocalNetworkHintEmitter { + /** + * Records browser-facing URLs or hosts that Hack printed or opened, and + * writes the hint once when one of them needs it. Returns true only for + * the call that wrote the hint. + */ + note(targets: Iterable): boolean; +} + +/** Creates an emitter with injected platform and writer (used by tests). */ +export function createLocalNetworkHintEmitter(opts: { + readonly platform: string; + readonly write: (line: string) => void; +}): LocalNetworkHintEmitter { + let shown = false; + return { + note(targets) { + if (shown) { + return false; + } + if (!shouldShowLocalNetworkHint({ platform: opts.platform, targets })) { + return false; + } + shown = true; + opts.write(`${LOCAL_NETWORK_HINT_MESSAGE}\n`); + return true; + }, + }; +} + +let processEmitter: LocalNetworkHintEmitter | null = null; + +/** + * Process-wide hint for the current command. Each CLI invocation is one + * process, so this shows the hint at most once per command. + */ +export function noteBrowserTargetsForLocalNetworkHint( + targets: Iterable +): boolean { + processEmitter ??= createLocalNetworkHintEmitter({ + platform: process.platform, + write: (line) => { + process.stderr.write(line); + }, + }); + return processEmitter.note(targets); +} diff --git a/tests/local-network-hint.test.ts b/tests/local-network-hint.test.ts new file mode 100644 index 000000000..ac01cc798 --- /dev/null +++ b/tests/local-network-hint.test.ts @@ -0,0 +1,169 @@ +import { afterEach, expect, test } from "bun:test"; +import { rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + browserHostFromTarget, + createLocalNetworkHintEmitter, + isLocalSuffixTarget, + LOCAL_NETWORK_HINT_MESSAGE, + shouldShowLocalNetworkHint, +} from "../src/lib/local-network-hint.ts"; +import { projectNameFixture } from "./helpers/project-name-fixture.ts"; + +function capture(platform: string) { + const lines: string[] = []; + const emitter = createLocalNetworkHintEmitter({ + platform, + write: (line) => { + lines.push(line); + }, + }); + return { emitter, lines }; +} + +test("hint appears for .local URLs and hosts on macOS", () => { + for (const target of [ + "https://my-app.hack.local", + "https://api.my-app.hack.local/", + "https://MY-APP.HACK.LOCAL", + "my-app.hack.local", + "my-app.hack.local.", + "https://my-app.hack.local:8443", + "hack.local", + ]) { + const { emitter, lines } = capture("darwin"); + expect(emitter.note([target]), target).toBe(true); + expect(lines).toEqual([`${LOCAL_NETWORK_HINT_MESSAGE}\n`]); + } +}); + +test("hint is not shown for non-.local hosts", () => { + for (const target of [ + "https://my-app.hack", + "https://my-app.hack.gy", + "https://my-app.hack.local.gy", + "https://localhost:3000", + "https://app.localhost", + "https://notlocal", + "https://127.0.0.1", + "https://dev.example.com", + "", + " ", + "not a url at all ::", + ]) { + const { emitter, lines } = capture("darwin"); + expect(emitter.note([target]), target).toBe(false); + expect(lines).toEqual([]); + } +}); + +test("hint is never shown on other platforms", () => { + for (const platform of ["linux", "win32", "freebsd"]) { + const { emitter, lines } = capture(platform); + expect(emitter.note(["https://my-app.hack.local"])).toBe(false); + expect(lines).toEqual([]); + expect( + shouldShowLocalNetworkHint({ + platform, + targets: ["https://my-app.hack.local"], + }) + ).toBe(false); + } +}); + +test("hint is shown at most once per emitter", () => { + const { emitter, lines } = capture("darwin"); + expect(emitter.note(["https://my-app.hack.gy"])).toBe(false); + expect(emitter.note(["https://a.hack.local", "https://b.hack.local"])).toBe( + true + ); + expect(emitter.note(["https://c.hack.local"])).toBe(false); + expect(lines).toHaveLength(1); +}); + +test("hint text is closed: one line, no interpolated host", () => { + const { emitter, lines } = capture("darwin"); + emitter.note(["https://secret-project-name.hack.local"]); + expect(lines.join("")).not.toContain("secret-project-name"); + expect(LOCAL_NETWORK_HINT_MESSAGE).not.toContain("\n"); + expect(LOCAL_NETWORK_HINT_MESSAGE).toContain("Safari"); + expect(LOCAL_NETWORK_HINT_MESSAGE).toContain( + "System Settings > Privacy & Security > Local Network" + ); +}); + +test("host extraction normalizes case and the root dot", () => { + expect(browserHostFromTarget("https://API.App.Hack.Local./x")).toBe( + "api.app.hack.local" + ); + expect(browserHostFromTarget("")).toBeNull(); + expect(isLocalSuffixTarget("https://app.hack.local")).toBe(true); + expect(isLocalSuffixTarget("https://app.local.example.com")).toBe(false); +}); + +// Real CLI boundary: isolated HOME/state, no daemon, no Docker. +const cli = process.env.HACK_NAME_TEST_BINARY + ? [resolve(process.env.HACK_NAME_TEST_BINARY)] + : [process.execPath, resolve(import.meta.dir, "../index.ts")]; +let cleanupRoot: string | null = null; + +afterEach(async () => { + if (cleanupRoot) { + await rm(cleanupRoot, { recursive: true, force: true }); + cleanupRoot = null; + } +}); + +async function openJson(devHost: string | null) { + const fixture = await projectNameFixture(); + cleanupRoot = fixture.root; + const target = await fixture.createProject("app", "my-app"); + if (devHost) { + await writeFile( + target.project.configFile, + JSON.stringify({ name: "my-app", dev_host: devHost }) + ); + } + await fixture.writeRegistry([ + devHost ? { ...target.entry, devHost } : target.entry, + ]); + const child = Bun.spawn( + [...cli, "open", "--json", "--prefer", "dev", "--project", "my-app"], + { + cwd: fixture.root, + env: fixture.env, + stdout: "pipe", + stderr: "pipe", + } + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { code, stdout, stderr }; +} + +function countHints(text: string): number { + return text.split(LOCAL_NETWORK_HINT_MESSAGE).length - 1; +} + +test("open --json keeps the hint out of the JSON payload", async () => { + const result = await openJson(null); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + url: "https://name-fixture.hack.local", + }); + expect(result.stdout).not.toContain("Local Network"); + // Shown once on macOS, never elsewhere. + expect(countHints(result.stderr)).toBe(process.platform === "darwin" ? 1 : 0); +}, 15_000); + +test("open --json does not hint for a non-.local host", async () => { + const result = await openJson("name-fixture.hack.gy"); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + url: "https://name-fixture.hack.gy", + }); + expect(countHints(result.stderr)).toBe(0); +}, 15_000); From efb278e6f6e93b77b854f55355783bbe793a49de Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 15:10:22 -0400 Subject: [PATCH 05/11] feat(native): repair the hack.local DNS route to the native relay Under the native backend on macOS, `hack doctor` reports a `native hack.local route` check (JSON `hackLocalRoute`) and `hack doctor --fix` proposes and, after confirmation plus the native administrator prompt, rewrites only the Hack-managed `address=/.hack.local/` dnsmasq line to 127.0.0.1 in place. It creates /etc/resolver/hack.local with `nameserver 127.0.0.1` only when missing. Preconditions are closed refusal reasons: verified relay owner listening on 127.0.0.1, a single exact managed line, a Hack-written target, a dead old target (bounded :443 probe), a Hack-owned resolver, no pending receipt, and unchanged inputs across authorization. A private receipt (old/new line, whole-file hashes, timestamps) is published before effects; failures restore the earlier line. Rollback via `hack global dns deactivate --domain hack.local` restores the exact earlier bytes, verifies the hash and recovers interrupted repairs. `hack global dns preview|activate|deactivate` without --domain no longer targets a built-in default; it stops with a usage message naming `hack doctor --fix` and the rollback command. Co-Authored-By: Claude Opus 5.5 --- docs/guides/init-project.md | 12 +- docs/guides/native-candidate.md | 53 +- docs/reference/cli.md | 4 +- src/commands/doctor.ts | 101 +++ src/commands/global.ts | 96 ++- src/lib/native-domain-dns-host.ts | 47 +- src/lib/native-hack-local-route-host.ts | 404 +++++++++ src/lib/native-hack-local-route.ts | 970 ++++++++++++++++++++++ tests/native-ca-doctor-cli.test.ts | 22 +- tests/native-hack-local-route-cli.test.ts | 68 ++ tests/native-hack-local-route.test.ts | 661 +++++++++++++++ 11 files changed, 2392 insertions(+), 46 deletions(-) create mode 100644 src/lib/native-hack-local-route-host.ts create mode 100644 src/lib/native-hack-local-route.ts create mode 100644 tests/native-hack-local-route-cli.test.ts create mode 100644 tests/native-hack-local-route.test.ts diff --git a/docs/guides/init-project.md b/docs/guides/init-project.md index 6072fc289..6cb9a3b57 100644 --- a/docs/guides/init-project.md +++ b/docs/guides/init-project.md @@ -18,10 +18,14 @@ Notes: entire suffix, so do not select a domain used by unrelated sites. The global installer configures the Docker-backed host stack. An explicitly selected native candidate refuses it to preserve the installed v4 stack. - On macOS, use `hack global dns preview` and then the separately confirmed - `hack global dns activate` for a dedicated custom native suffix. This leaves - the v4 wildcard rules untouched. Verify the exact app origin with - `hack doctor --browser-url`; DNS setup alone does not validate HTTPS or OAuth. + On macOS, use `hack global dns preview --domain ` and then the + separately confirmed `hack global dns activate --domain ` for a + dedicated custom native suffix. This leaves the v4 wildcard rules untouched. + To point the default `*.hack.local` root at the native relay, run + `hack doctor --fix`; after confirmation it rewrites only the Hack-managed + `.hack.local` dnsmasq line, and `hack global dns deactivate --domain hack.local` + restores it. Verify the exact app origin with `hack doctor --browser-url`; + DNS setup alone does not validate HTTPS or OAuth. Pass `--dev-host` for a per-project hostname; it takes precedence over the global default. Existing project hosts and Compose routes are not migrated. - `hack init` also scaffolds `.hack/hack.env.default.yaml`. See `docs/env.md`. diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index fce308140..f911b3806 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1274,9 +1274,11 @@ browser session works. The native candidate refuses `hack global install`: that command owns the Docker global stack and could change installed v4 routing. For a dedicated custom suffix alongside v4, run `hack global dns preview --domain v5.hack.gy` -and then `hack global dns activate --domain v5.hack.gy`. Omit `--domain` to use -the configured global `default_domain`; changing that shared setting also -changes the installed v4 CLI's default. Preview is read-only; +and then `hack global dns activate --domain v5.hack.gy`. Without `--domain`, +these commands use the global `default_domain` only when it is a custom suffix; +changing that shared setting also changes the installed v4 CLI's default. With a +built-in default such as `hack.local`, they stop with a usage message that names +`hack doctor --fix` and the rollback command below. Preview is read-only; activation shows the exact scoped claim and requires interactive confirmation and a native administrator prompt. Run it from a terminal; the bounded privileged steps preserve that terminal's sudo authorization and report the underlying @@ -1313,10 +1315,53 @@ administrator prompt. It checks the owner, root and route again after that prompt and verifies trust after installation. A missing, stale or changed owner refuses repair. The native `hack global trust` path directs you to this scoped Doctor command instead of falling back to Docker's exported CA. The native -repair does not set up DNS or a browser's Local Network permission. +trust repair does not set up DNS or a browser's Local Network permission. Both authorization and installation retain the caller's controlling terminal, so terminal-scoped sudo authorization remains valid during the bounded repair. +`hack.local` stays the default dev-host root. An older Docker-era install routes +it to Docker Caddy with `address=/.hack.local/172.30.0.2` in Homebrew's +`dnsmasq.conf`, which the native loopback relay cannot serve. On macOS, native +`hack doctor` reports a `native hack.local route` check (JSON field +`hackLocalRoute`), and `hack doctor --fix` proposes a separate DNS repair. It +rewrites only that one Hack-managed line to `address=/.hack.local/127.0.0.1`, +in place. It never adds a second line for the suffix, and the `.hack` and +`.hack.gy` lines and every other byte stay unchanged. If +`/etc/resolver/hack.local` is missing, the repair creates it with +`nameserver 127.0.0.1`. The repair proceeds only when all of these hold: + +- The native relay owner is verified and listening on 127.0.0.1 at its receipted port. +- The line has the exact Hack-managed form and is the only `.hack.local` claim, + including `dnsmasq.d` include files. +- The target is one Hack wrote (`172.30.0.2` or `::1`). +- The old target does not answer on port 443 within 1.5 seconds, so a running + Docker Caddy is never broken. +- Any existing resolver file contains only `nameserver 127.0.0.1`. + +Each unmet condition is a closed refusal reason: `relay-not-verified`, +`dnsmasq-config-missing`, `no-managed-line`, `unmanaged-line`, `foreign-target`, +`foreign-resolver`, `old-target-live`, `pending-receipt`, `invalid-receipt`, +`non-interactive`, `declined` or `inputs-changed`. The repair needs interactive +confirmation and the native macOS administrator prompt; without a terminal it +refuses and names `hack doctor --fix` to run in one. After the prompt it inspects +everything again and refuses if anything changed. Before changing files it +writes a private receipt to `/native-dns/hack-local-route.json` +with the old line, new line, whole-file SHA-256 hashes and timestamps. It +replaces the file atomically, restarts the verified dnsmasq launchd job, flushes +the DNS cache and verifies live resolution. If a later step fails, it restores +the earlier line automatically. `hack global dns preview --domain hack.local` +shows the same proposal without changing anything. + +To undo the repair, run `hack global dns deactivate --domain hack.local`. It +restores the exact earlier line and verifies the whole-file hash against the +receipt. It removes the resolver file only if the repair created it and its +identity still matches, then restarts dnsmasq. If the file changed after the +repair, rollback refuses with `rollback-drift`; restore the line yourself. An +interrupted repair leaves a `pending` receipt. Doctor then refuses another repair +until the same rollback command recovers it. A Docker-era `hack doctor --fix` or +`hack global install` still rewrites all three built-in roots to whichever target +answers, which can undo the native repair. + The runtime's read-only `runtime inspect-host-listener --pid PID --port PORT --executable /absolute/caddy --json` reports the selected same-user loopback listener identity. The optional `--peer-port PEER_PORT` proves Caddy accepted diff --git a/docs/reference/cli.md b/docs/reference/cli.md index dddd00ecb..8e1b62d31 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1022,9 +1022,9 @@ hack global dns [options] | Command | Summary | | --- | --- | -| `hack global dns preview` | Preview a scoped native custom-domain DNS claim without changing the host | +| `hack global dns preview` | Preview a scoped native custom-domain DNS claim or the hack.local relay repair without changing the host | | `hack global dns activate` | Activate only the selected native custom-domain DNS suffix | -| `hack global dns deactivate` | Remove only an owned native custom-domain DNS suffix | +| `hack global dns deactivate` | Remove an owned native custom-domain DNS suffix, or undo the hack.local relay repair | ### Options diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ea3123c2d..7a516b448 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -108,6 +108,16 @@ import { inspectNativeCaTrust, repairNativeCaTrust, } from "../lib/native-ca-doctor.ts"; +import { + describeHackLocalRoute, + HACK_LOCAL_ROLLBACK_COMMAND, + type HackLocalRouteDependencies, + type HackLocalRouteReport, + inspectHackLocalRoute, + repairHackLocalRoute, + reportHackLocalRoute, +} from "../lib/native-hack-local-route.ts"; +import { createNativeHackLocalRouteDependencies } from "../lib/native-hack-local-route-host.ts"; import { isMac } from "../lib/os.ts"; import { defaultProjectSlugFromPath, @@ -170,8 +180,13 @@ interface CheckResult { readonly message: string; /** Counts and public fingerprints only; never paths or keychain contents. */ readonly supersededRoots?: SupersededRootInventory; + /** Native hack.local route state; lines and paths only, never file contents. */ + readonly hackLocalRoute?: HackLocalRouteReport; } +const HACK_LOCAL_ROUTE_CHECK = "native hack.local route"; +const HACK_LOCAL_REPAIR_CHECK = "native hack.local repair"; + const SUPERSEDED_ROOTS_CHECK = "superseded caddy roots"; /** Informational inventory: Doctor and --fix never remove a keychain root. */ @@ -344,6 +359,8 @@ const DOCTOR_SUMMARY_GROUPS = [ `dnsmasq.conf:${DEFAULT_NEW_PROJECT_TLD}`, `dnsmasq.conf:${DEFAULT_OAUTH_ALIAS_ROOT}`, "dnsmasq:53", + HACK_LOCAL_REPAIR_CHECK, + HACK_LOCAL_ROUTE_CHECK, "native host dns", `dns:${DEFAULT_PROJECT_TLD}`, `dns:${DEFAULT_NEW_PROJECT_TLD}`, @@ -1046,6 +1063,16 @@ async function handleNativeDoctor(opts: { durationMs: 0, }); } + if (isMac()) { + results.push( + ...(await checkNativeHackLocalRoute({ + progress, + fix: opts.fix, + dependencies: () => + createNativeHackLocalRouteDependencies({ runtime: opts.runtime }), + })) + ); + } if (hostname) { results.push( await runCheck( @@ -1085,6 +1112,75 @@ async function handleNativeDoctor(opts: { return hasError ? 1 : 0; } +/** + * Report, and with --fix authorize and apply, the native hack.local route + * repair. Refusals are warnings with a closed reason; only an unexpected + * failure is an error. + */ +export async function checkNativeHackLocalRoute(opts: { + readonly progress: DoctorProgress; + readonly fix: boolean; + readonly dependencies: () => Promise; +}): Promise { + const results: TimedCheckResult[] = []; + let deps: HackLocalRouteDependencies; + try { + deps = await opts.dependencies(); + } catch (error) { + return [ + { + name: HACK_LOCAL_ROUTE_CHECK, + status: "warn", + message: `Unable to inspect the hack.local route: ${error instanceof Error ? error.message : String(error)}`, + durationMs: 0, + }, + ]; + } + if (opts.fix) { + results.push( + await runCheck(opts.progress, HACK_LOCAL_REPAIR_CHECK, async () => { + const repaired = await repairHackLocalRoute(deps); + if (repaired.outcome === "repaired") { + return { + name: HACK_LOCAL_REPAIR_CHECK, + status: "ok", + message: `Rewrote "${repaired.receipt.oldLine}" to "${repaired.receipt.newLine}"${ + repaired.receipt.resolverAction === "create" + ? ` and created ${repaired.receipt.resolverPath}` + : "" + }; live DNS verified. Receipt: ${deps.paths.receiptPath}. Undo with: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + }; + } + if (repaired.outcome === "current") { + return { + name: HACK_LOCAL_REPAIR_CHECK, + status: "ok", + message: + "No repair needed; *.hack.local already routes to the native relay.", + }; + } + return { + name: HACK_LOCAL_REPAIR_CHECK, + status: "warn", + message: `Repair refused (${repaired.reason}): ${repaired.detail}`, + }; + }) + ); + } + results.push( + await runCheck(opts.progress, HACK_LOCAL_ROUTE_CHECK, async () => { + const report = reportHackLocalRoute(await inspectHackLocalRoute(deps)); + return { + name: HACK_LOCAL_ROUTE_CHECK, + status: report.status === "current" ? "ok" : "warn", + message: describeHackLocalRoute(report), + hackLocalRoute: report, + }; + }) + ); + return results; +} + export type DoctorJsonCheck = { readonly id: string; readonly status: CheckStatus; @@ -1092,6 +1188,8 @@ export type DoctorJsonCheck = { readonly durationMs: number; /** Present only on the superseded Caddy roots check. */ readonly supersededRoots?: SupersededRootInventory; + /** Present only on the native hack.local route check. */ + readonly hackLocalRoute?: HackLocalRouteReport; }; export type DoctorJsonData = { @@ -1123,6 +1221,9 @@ export function buildDoctorJsonData(opts: { ...(result.supersededRoots ? { supersededRoots: result.supersededRoots } : {}), + ...(result.hackLocalRoute + ? { hackLocalRoute: result.hackLocalRoute } + : {}), })), summary: buildDoctorSummaryLines({ results: opts.results }), counts, diff --git a/src/commands/global.ts b/src/commands/global.ts index 6089122a6..4efed8f7a 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -112,6 +112,14 @@ import { inspectNativeDnsHost, readStableNativeDnsFile, } from "../lib/native-domain-dns-snapshot.ts"; +import { + describeHackLocalRoute, + inspectHackLocalRoute, + reportHackLocalRoute, + rollbackHackLocalRoute, + selectNativeDnsCommandDomain, +} from "../lib/native-hack-local-route.ts"; +import { createNativeHackLocalRouteDependencies } from "../lib/native-hack-local-route-host.ts"; import { isLinux, isMac } from "../lib/os.ts"; import { reconcileRemoteCaddyRoutesStack, @@ -222,7 +230,7 @@ const globalInstallSpec = defineCommand({ const globalDnsPreviewSpec = defineCommand({ name: "preview", summary: - "Preview a scoped native custom-domain DNS claim without changing the host", + "Preview a scoped native custom-domain DNS claim or the hack.local relay repair without changing the host", group: "Global", options: [ optJson, @@ -231,7 +239,8 @@ const globalDnsPreviewSpec = defineCommand({ type: "string", long: "--domain", valueHint: "v5.hack.gy", - description: "Custom suffix to inspect (default: global default_domain)", + description: + "Suffix to inspect: a custom suffix, or hack.local for the native relay repair (default: a custom global default_domain)", } as const), ] as const, positionals: [], @@ -248,7 +257,8 @@ const globalDnsActivateSpec = defineCommand({ type: "string", long: "--domain", valueHint: "v5.hack.gy", - description: "Custom suffix to activate (default: global default_domain)", + description: + "Custom suffix to activate (default: a custom global default_domain)", } as const), ] as const, positionals: [], @@ -257,7 +267,8 @@ const globalDnsActivateSpec = defineCommand({ const globalDnsDeactivateSpec = defineCommand({ name: "deactivate", - summary: "Remove only an owned native custom-domain DNS suffix", + summary: + "Remove an owned native custom-domain DNS suffix, or undo the hack.local relay repair", group: "Global", options: [ defineOption({ @@ -266,7 +277,7 @@ const globalDnsDeactivateSpec = defineCommand({ long: "--domain", valueHint: "v5.hack.gy", description: - "Custom suffix to deactivate (default: global default_domain)", + "Custom suffix to deactivate, or hack.local to undo the native relay repair (default: a custom global default_domain)", } as const), ] as const, positionals: [], @@ -391,11 +402,28 @@ export const globalCommand = defineCommand({ ], } as const); +async function selectGlobalDnsDomain(opts: { + readonly verb: "preview" | "activate" | "deactivate"; + readonly option: string | undefined; +}) { + const explicit = + opts.option === undefined ? undefined : parseDefaultDomain(opts.option); + return selectNativeDnsCommandDomain({ + verb: opts.verb, + explicit, + configuredDefault: explicit ?? (await resolveDefaultDomain()), + }); +} + async function handleGlobalDnsPreview({ args, }: Parameters< CommandHandlerFor >[0]): Promise { + const selected = await selectGlobalDnsDomain({ + verb: "preview", + option: args.options.domain, + }); const runtime = resolveNativeRuntimeSelection(); if (!(runtime && isMac())) { logger.error({ @@ -404,9 +432,20 @@ async function handleGlobalDnsPreview({ }); return 1; } - const domain = parseDefaultDomain( - args.options.domain ?? (await resolveDefaultDomain()) - ); + if (selected.kind === "hack-local") { + const report = reportHackLocalRoute( + await inspectHackLocalRoute( + await createNativeHackLocalRouteDependencies({ runtime }) + ) + ); + if (args.options.json) { + console.log(JSON.stringify({ ok: true, data: report })); + } else { + logger.info({ message: describeHackLocalRoute(report) }); + } + return 0; + } + const { domain } = selected; const { inspection, receiptPath } = await inspectNativeDnsPlan({ runtime, domain, @@ -442,6 +481,10 @@ async function handleGlobalDnsActivate({ }: Parameters< CommandHandlerFor >[0]): Promise { + const selected = await selectGlobalDnsDomain({ + verb: "activate", + option: args.options.domain, + }); const runtime = resolveNativeRuntimeSelection(); if (!(runtime && isMac())) { logger.error({ @@ -450,9 +493,13 @@ async function handleGlobalDnsActivate({ }); return 1; } - const domain = parseDefaultDomain( - args.options.domain ?? (await resolveDefaultDomain()) - ); + if (selected.kind !== "custom") { + logger.error({ + message: "hack.local is repaired with hack doctor --fix, not activated.", + }); + return 1; + } + const { domain } = selected; const initial = await inspectNativeDnsPlan({ runtime, domain }); if ( initial.inspection.plan.status === "available" && @@ -537,6 +584,10 @@ async function handleGlobalDnsDeactivate({ }: Parameters< CommandHandlerFor >[0]): Promise { + const selected = await selectGlobalDnsDomain({ + verb: "deactivate", + option: args.options.domain, + }); const runtime = resolveNativeRuntimeSelection(); if (!(runtime && isMac())) { logger.error({ @@ -545,9 +596,26 @@ async function handleGlobalDnsDeactivate({ }); return 1; } - const domain = parseDefaultDomain( - args.options.domain ?? (await resolveDefaultDomain()) - ); + if (selected.kind === "hack-local") { + const result = await rollbackHackLocalRoute( + await createNativeHackLocalRouteDependencies({ runtime }) + ); + if (result.outcome === "refused") { + logger.error({ + message: `hack.local rollback refused (${result.reason}): ${result.detail}`, + }); + return 1; + } + logger.success({ + message: `Restored "${result.receipt.oldLine}"; the dnsmasq file hash matches the receipt.${ + result.retainedResolver + ? ` ${result.retainedResolver} was retained because its ownership is unproven.` + : "" + }`, + }); + return 0; + } + const { domain } = selected; const initial = await inspectNativeDnsPlan({ runtime, domain }); if (initial.inspection.plan.status === "available") { if (initial.receipt?.state === "inactive") { diff --git a/src/lib/native-domain-dns-host.ts b/src/lib/native-domain-dns-host.ts index 378ee367c..b442a81a0 100644 --- a/src/lib/native-domain-dns-host.ts +++ b/src/lib/native-domain-dns-host.ts @@ -441,6 +441,36 @@ function assertReceiptState(opts: { } } +/** + * Run one bounded resolver create/remove through the caller's existing sudo + * ticket. The script accepts only direct children of /etc/resolver, creates + * without clobbering and removes only an exact identity and content match. + * Create returns the new file's "dev:ino" identity; remove returns "1" or "0". + */ +export async function runPrivilegedResolverOperation(opts: { + readonly action: "create" | "remove"; + readonly file: + | NativeDnsOwnedFile + | { readonly path: string; readonly content: string }; + readonly runPrivileged?: NativeDnsCommandRunner; +}): Promise { + const runPrivileged = opts.runPrivileged ?? defaultPrivilegedRunner; + const result = await runPrivileged([ + "sudo", + "-n", + "/usr/bin/python3", + "-I", + "-S", + "-c", + PRIVILEGED_RESOLVER_SCRIPT, + opts.action, + opts.file.path, + opts.file.content, + "identity" in opts.file ? opts.file.identity : "", + ]); + return assertSuccess(result, `Resolver ${opts.action}`); +} + /** * Build the effect boundary for a previously validated native DNS plan. The * caller owns authorization, fresh inspection, service restart and cache flush. @@ -458,20 +488,11 @@ export function createNativeDnsHostDependencies( | NativeDnsOwnedFile | { readonly path: string; readonly content: string } ): Promise { - const result = await runPrivileged([ - "sudo", - "-n", - "/usr/bin/python3", - "-I", - "-S", - "-c", - PRIVILEGED_RESOLVER_SCRIPT, + return await runPrivilegedResolverOperation({ action, - file.path, - file.content, - "identity" in file ? file.identity : "", - ]); - return assertSuccess(result, `Resolver ${action}`); + file, + runPrivileged, + }); } return { diff --git a/src/lib/native-hack-local-route-host.ts b/src/lib/native-hack-local-route-host.ts new file mode 100644 index 000000000..bd21b3b38 --- /dev/null +++ b/src/lib/native-hack-local-route-host.ts @@ -0,0 +1,404 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, readdir, rename, unlink } from "node:fs/promises"; +import { createConnection } from "node:net"; +import { basename, dirname, join, resolve } from "node:path"; +import { inspectActiveNativeHttpsOwner } from "../backends/native-project-https.ts"; +import type { NativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +import { DEFAULT_HOST_DNS_IP } from "../constants.ts"; +import { canPrompt, confirmSafe } from "./interactivity.ts"; +import { + type NativeDnsCommandRunner, + runNativeDnsAttachedCommand, + runPrivilegedResolverOperation, +} from "./native-domain-dns-host.ts"; +import { prepareNativeDnsLaunchdRestart } from "./native-domain-dns-launchd.ts"; +import { + inspectNativeDnsHost, + readStableNativeDnsFile, +} from "./native-domain-dns-snapshot.ts"; +import { + HACK_LOCAL_DOMAIN, + type HackLocalRouteDependencies, + type HackLocalRouteReceipt, +} from "./native-hack-local-route.ts"; +import { exec, run } from "./shell.ts"; + +const IDENTITY = /^\d+:\d+$/; +const IP_ADDRESS_LINE = /^ip_address:\s*(\S+)\s*$/gm; +const PROBE_TIMEOUT_MS = 1500; +const RECEIPT_FILENAME = "hack-local-route.json"; + +/** Private receipt location under the native runtime home. */ +export function hackLocalRouteReceiptPath(runtimeHome: string): string { + return join(runtimeHome, "native-dns", RECEIPT_FILENAME); +} + +async function syncDirectory(path: string): Promise { + const directory = await open( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW + ); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} + +function isErrno(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} + +async function unlinkIfPresent(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (!isErrno(error, "ENOENT")) { + throw error; + } + } +} + +/** + * Write bytes to a fresh sibling and rename it over target only if `guard` + * still passes immediately before publication. The rename is atomic, so an + * interrupted write leaves either the old or the new complete file. + */ +async function replaceAtomically(opts: { + readonly path: string; + readonly content: string; + readonly mode: number; + readonly guard: () => Promise; +}): Promise { + const temporary = join( + dirname(opts.path), + `.${basename(opts.path)}.hack-${randomUUID()}.tmp` + ); + const handle = await open( + temporary, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + opts.mode + ); + try { + try { + await handle.writeFile(opts.content, "utf8"); + await handle.chmod(opts.mode); + await handle.sync(); + } finally { + await handle.close(); + } + await opts.guard(); + await rename(temporary, opts.path); + await syncDirectory(dirname(opts.path)); + } finally { + await unlinkIfPresent(temporary); + } +} + +async function ensurePrivateDirectory(path: string): Promise { + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if (!isErrno(error, "EEXIST")) { + throw error; + } + } + const details = await lstat(path); + if ( + !details.isDirectory() || + (details.mode & 0o077) !== 0 || + details.uid !== process.getuid?.() + ) { + throw new Error("hack.local repair receipt directory is not private"); + } +} + +export interface HackLocalRouteFileOptions { + readonly dnsmasqPath: string; + readonly includeDir: string; + readonly resolverPath: string; + readonly receiptPath: string; + /** Testing seam; production runs the bounded resolver script with sudo -n. */ + readonly runPrivileged?: NativeDnsCommandRunner; +} + +type FileDependencies = Pick< + HackLocalRouteDependencies, + | "paths" + | "readReceipt" + | "writeReceipt" + | "readDnsmasq" + | "readIncludeFiles" + | "readResolver" + | "replaceDnsmasq" + | "createResolver" + | "removeResolverIfOwned" +>; + +/** + * File effects rooted at explicit paths. The dnsmasq file must be a regular + * single-link file owned by the caller (as Homebrew installs it); its mode is + * preserved. Resolver changes use the existing privileged resolver script. + */ +export function createHackLocalRouteFileDependencies( + opts: HackLocalRouteFileOptions +): FileDependencies { + return { + paths: { + dnsmasqPath: opts.dnsmasqPath, + resolverPath: opts.resolverPath, + receiptPath: opts.receiptPath, + }, + readReceipt: async () => await readStableNativeDnsFile(opts.receiptPath), + async writeReceipt(receipt: HackLocalRouteReceipt) { + await ensurePrivateDirectory(dirname(opts.receiptPath)); + await replaceAtomically({ + path: opts.receiptPath, + content: `${JSON.stringify(receipt, null, 2)}\n`, + mode: 0o600, + guard: async () => undefined, + }); + }, + readDnsmasq: async () => await readStableNativeDnsFile(opts.dnsmasqPath), + async readIncludeFiles() { + let names: string[]; + try { + names = await readdir(opts.includeDir); + } catch (error) { + if (isErrno(error, "ENOENT")) { + return []; + } + throw error; + } + const files: { path: string; content: string }[] = []; + for (const name of names + .filter((entry) => entry.endsWith(".conf")) + .sort()) { + const path = join(opts.includeDir, name); + const content = await readStableNativeDnsFile(path); + if (content !== null) { + files.push({ path, content }); + } + } + return files; + }, + readResolver: async () => await readStableNativeDnsFile(opts.resolverPath), + async replaceDnsmasq({ expected, next }) { + const details = await lstat(opts.dnsmasqPath); + if ( + !details.isFile() || + details.nlink !== 1 || + details.uid !== process.getuid?.() + ) { + throw new Error( + `${opts.dnsmasqPath} is not a regular file owned by the current user; refusing to rewrite it` + ); + } + await replaceAtomically({ + path: opts.dnsmasqPath, + content: next, + mode: details.mode & 0o7777, + guard: async () => { + const current = await readStableNativeDnsFile(opts.dnsmasqPath); + if (current !== expected) { + throw new Error( + "dnsmasq configuration changed before replacement; nothing was written" + ); + } + }, + }); + if ((await readStableNativeDnsFile(opts.dnsmasqPath)) !== next) { + throw new Error( + "dnsmasq configuration did not contain the written bytes" + ); + } + }, + async createResolver(content) { + const identity = await runPrivilegedResolverOperation({ + action: "create", + file: { path: opts.resolverPath, content }, + runPrivileged: opts.runPrivileged, + }); + if (!IDENTITY.test(identity)) { + throw new Error("Resolver create returned no valid ownership identity"); + } + return identity; + }, + async removeResolverIfOwned({ identity, content }) { + const result = await runPrivilegedResolverOperation({ + action: "remove", + file: { path: opts.resolverPath, content, identity }, + runPrivileged: opts.runPrivileged, + }); + if (result !== "0" && result !== "1") { + throw new Error("Resolver remove returned an invalid result"); + } + return result === "1"; + }, + }; +} + +/** Bounded TCP connect; any error or timeout means the target did not answer. */ +export async function probeTcp(opts: { + readonly host: string; + readonly port: number; + readonly timeoutMs?: number; +}): Promise { + return await new Promise((resolvePromise) => { + const socket = createConnection({ + host: opts.host, + port: opts.port, + timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS, + }); + const finish = (answered: boolean) => { + socket.destroy(); + resolvePromise(answered); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.once("timeout", () => finish(false)); + }); +} + +async function runCommand(command: readonly string[]) { + return await exec(command, { stdin: "ignore", timeoutMs: 5000 }); +} + +async function resolveBrewPrefix(): Promise { + const result = await exec(["brew", "--prefix"], { stdin: "ignore" }); + const prefix = result.exitCode === 0 ? result.stdout.trim() : ""; + return prefix.length > 0 ? prefix : "/opt/homebrew"; +} + +async function privileged(command: readonly string[], action: string) { + const result = await runNativeDnsAttachedCommand({ command }); + if (result.exitCode !== 0) { + throw new Error(`${action} failed (exit ${result.exitCode})`); + } +} + +async function verifyLiveHackLocalDns(): Promise { + const name = `hack-probe-${randomUUID().replaceAll("-", "")}.${HACK_LOCAL_DOMAIN}`; + const direct = await runCommand([ + "/usr/bin/dig", + "+short", + "+time=2", + "+tries=1", + `@${DEFAULT_HOST_DNS_IP}`, + name, + "A", + ]); + if (direct.exitCode !== 0 || direct.stdout.trim() !== DEFAULT_HOST_DNS_IP) { + throw new Error( + `dnsmasq did not answer ${name} with ${DEFAULT_HOST_DNS_IP}` + ); + } + const system = await runCommand([ + "/usr/bin/dscacheutil", + "-q", + "host", + "-a", + "name", + name, + ]); + const addresses = [...system.stdout.matchAll(IP_ADDRESS_LINE)].map( + (match) => match[1] + ); + if ( + system.exitCode !== 0 || + addresses.length === 0 || + addresses.some((address) => address !== DEFAULT_HOST_DNS_IP) + ) { + throw new Error(`macOS did not resolve ${name} to ${DEFAULT_HOST_DNS_IP}`); + } +} + +/** + * Production wiring for the native macOS host: Homebrew dnsmasq, the + * /etc/resolver directory, the verified native relay owner, the system + * launchd dnsmasq job and the native administrator prompt. + */ +export async function createNativeHackLocalRouteDependencies(opts: { + readonly runtime: NativeRuntimeSelection; +}): Promise { + const prefix = await resolveBrewPrefix(); + const dnsmasqPath = resolve(prefix, "etc", "dnsmasq.conf"); + const includeDir = resolve(prefix, "etc", "dnsmasq.d"); + const files = createHackLocalRouteFileDependencies({ + dnsmasqPath, + includeDir, + resolverPath: join("/etc/resolver", HACK_LOCAL_DOMAIN), + receiptPath: hackLocalRouteReceiptPath(opts.runtime.home), + }); + return { + ...files, + async verifyRelay() { + try { + const owner = await inspectActiveNativeHttpsOwner({ + runtime: opts.runtime, + }); + const listening = await probeTcp({ + host: DEFAULT_HOST_DNS_IP, + port: owner.httpsPort, + }); + return listening ? { port: owner.httpsPort } : null; + } catch { + return null; + } + }, + probeTarget: async (target) => await probeTcp({ host: target, port: 443 }), + canPrompt: () => canPrompt(), + async authorize(message) { + const accepted = await confirmSafe({ + message, + initialValue: false, + nonInteractive: "decline", + }); + if (!accepted) { + return false; + } + return ( + (await run(["sudo", "-v"], { + stdin: "inherit", + timeoutMs: 120_000, + forwardSignals: true, + })) === 0 + ); + }, + async prepareRestart() { + const host = await inspectNativeDnsHost({ brewPrefix: prefix }); + if (host.mainConfig.path !== dnsmasqPath) { + throw new Error( + "Running dnsmasq does not load the inspected configuration" + ); + } + return await prepareNativeDnsLaunchdRestart({ + dnsmasqBinary: host.dnsmasqArgs[0] ?? "", + mainConfigPath: host.mainConfig.path, + includeDir: host.includeDir, + inspectedArgs: host.dnsmasqArgs, + }); + }, + async flushDnsCache() { + await privileged( + ["sudo", "-n", "/usr/bin/dscacheutil", "-flushcache"], + "dscacheutil -flushcache" + ); + await privileged( + ["sudo", "-n", "/usr/bin/killall", "-HUP", "mDNSResponder"], + "killall -HUP mDNSResponder" + ); + }, + verifyLiveDns: verifyLiveHackLocalDns, + now: () => new Date(), + }; +} diff --git a/src/lib/native-hack-local-route.ts b/src/lib/native-hack-local-route.ts new file mode 100644 index 000000000..448a5e2ed --- /dev/null +++ b/src/lib/native-hack-local-route.ts @@ -0,0 +1,970 @@ +import { createHash } from "node:crypto"; +import { CliUsageError } from "../cli/command.ts"; +import { + DEFAULT_CADDY_IP, + DEFAULT_HOST_DNS_IP, + DEFAULT_NEW_PROJECT_TLD, + DEFAULT_OAUTH_ALIAS_ROOT, + DEFAULT_PROJECT_TLD, +} from "../constants.ts"; +import { isRecord } from "./guards.ts"; + +/** + * Native repair of the built-in `*.hack.local` route. + * + * Under the native backend the HTTPS relay listens on 127.0.0.1, while an older + * Docker-era install may still route `.hack.local` to Docker Caddy + * (172.30.0.2). This module decides whether the single Hack-managed + * `address=/.hack.local/` dnsmasq line may be rewritten in place to + * 127.0.0.1, applies that rewrite through injected effects, and restores the + * exact earlier line from a private receipt. + * + * Invariants: + * - Only the one managed line changes; every other byte of the file is kept. + * No second line for the suffix is ever added. + * - A rewrite needs a verified native relay, a dead old target and a fresh + * inspection after authorization. Every unmet precondition is a closed + * refusal reason, never a best-effort change. + * - A pending receipt is published before any host effect. An interrupted run + * leaves it for explicit recovery through the rollback command. + */ + +export const HACK_LOCAL_DOMAIN = DEFAULT_NEW_PROJECT_TLD; +export const HACK_LOCAL_RESOLVER_CONTENT = `nameserver ${DEFAULT_HOST_DNS_IP}\n`; +export const HACK_LOCAL_RELAY_LINE = `address=/.${HACK_LOCAL_DOMAIN}/${DEFAULT_HOST_DNS_IP}`; +export const HACK_LOCAL_REPAIR_COMMAND = "hack doctor --fix"; +export const HACK_LOCAL_ROLLBACK_COMMAND = `hack global dns deactivate --domain ${HACK_LOCAL_DOMAIN}`; + +const LINE_PREFIX = `address=/.${HACK_LOCAL_DOMAIN}/`; +/** Former Hack-written targets that may be replaced once proven dead. */ +const REPLACEABLE_TARGETS: ReadonlySet = new Set([ + DEFAULT_CADDY_IP, + "::1", +]); +const TARGET_FORMAT = /^[0-9a-f:.]+$/i; +const DOMAIN_TOKEN = /[a-z0-9-]+(?:\.[a-z0-9-]+)*/gi; +const SHA256 = /^[0-9a-f]{64}$/; +const BUILT_IN_ROOTS: ReadonlySet = new Set([ + DEFAULT_PROJECT_TLD, + DEFAULT_NEW_PROJECT_TLD, + DEFAULT_OAUTH_ALIAS_ROOT, +]); + +/** Closed set of reasons a repair or rollback does not change the host. */ +export type HackLocalRouteRefusal = + | "invalid-receipt" + | "pending-receipt" + | "relay-not-verified" + | "dnsmasq-config-missing" + | "no-managed-line" + | "unmanaged-line" + | "foreign-target" + | "foreign-resolver" + | "old-target-live" + | "non-interactive" + | "declined" + | "inputs-changed" + | "no-receipt" + | "rollback-drift"; + +export interface HackLocalRouteReceipt { + readonly version: 1; + readonly kind: "hack-local-route-repair"; + readonly state: "pending" | "applied" | "rolled-back"; + readonly domain: typeof HACK_LOCAL_DOMAIN; + readonly dnsmasqPath: string; + readonly resolverPath: string; + readonly oldLine: string; + readonly newLine: string; + /** SHA-256 of the whole dnsmasq file before and after the line rewrite. */ + readonly beforeSha256: string; + readonly afterSha256: string; + readonly resolverAction: "keep" | "create"; + readonly resolverSha256: string; + /** Identity of a resolver this repair created; null until creation is confirmed. */ + readonly resolverIdentity: string | null; + readonly relayPort: number; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface HackLocalRoutePaths { + readonly dnsmasqPath: string; + readonly resolverPath: string; + readonly receiptPath: string; +} + +/** Every host read and effect is supplied by the caller. */ +export interface HackLocalRouteDependencies { + readonly paths: HackLocalRoutePaths; + readonly readReceipt: () => Promise; + /** Atomically and durably publish the private receipt. */ + readonly writeReceipt: (receipt: HackLocalRouteReceipt) => Promise; + readonly readDnsmasq: () => Promise; + readonly readIncludeFiles: () => Promise< + readonly { readonly path: string; readonly content: string }[] + >; + readonly readResolver: () => Promise; + /** Atomic replacement that refuses unless the current bytes equal expected. */ + readonly replaceDnsmasq: (opts: { + readonly expected: string; + readonly next: string; + }) => Promise; + /** No-clobber privileged create; returns the created file identity. */ + readonly createResolver: (content: string) => Promise; + /** Privileged removal of an exact identity and content match only. */ + readonly removeResolverIfOwned: (opts: { + readonly identity: string; + readonly content: string; + }) => Promise; + /** The active native relay owner and its listening 127.0.0.1 port, or null. */ + readonly verifyRelay: () => Promise<{ readonly port: number } | null>; + /** Bounded probe: true when the old target still answers on 443. */ + readonly probeTarget: (target: string) => Promise; + readonly canPrompt: () => boolean; + /** Human confirmation plus the native administrator prompt. */ + readonly authorize: (message: string) => Promise; + /** Validate the dnsmasq service before effects; return its restart. */ + readonly prepareRestart: () => Promise<() => Promise>; + readonly flushDnsCache: () => Promise; + readonly verifyLiveDns: () => Promise; + readonly now: () => Date; +} + +export interface HackLocalRoutePlan { + readonly paths: HackLocalRoutePaths; + readonly oldLine: string; + readonly newLine: string; + readonly oldTarget: string; + readonly beforeContent: string; + readonly afterContent: string; + readonly beforeSha256: string; + readonly afterSha256: string; + readonly resolverAction: "keep" | "create"; + readonly relayPort: number; + readonly fingerprint: string; +} + +export type HackLocalRouteInspection = + | { + readonly status: "current"; + readonly paths: HackLocalRoutePaths; + readonly relayPort: number; + readonly receipt: HackLocalRouteReceipt | null; + } + | { + readonly status: "repairable"; + readonly plan: HackLocalRoutePlan; + readonly receipt: HackLocalRouteReceipt | null; + } + | { + readonly status: "refused"; + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + readonly paths: HackLocalRoutePaths; + readonly receipt: HackLocalRouteReceipt | null; + }; + +export type HackLocalRouteRepairResult = + | { + readonly outcome: "repaired"; + readonly plan: HackLocalRoutePlan; + readonly receipt: HackLocalRouteReceipt; + } + | { readonly outcome: "current"; readonly relayPort: number } + | { + readonly outcome: "refused"; + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + readonly command?: string; + }; + +export type HackLocalRouteRollbackResult = + | { + readonly outcome: "rolled-back"; + readonly receipt: HackLocalRouteReceipt; + /** A resolver this repair created but could not prove it still owns. */ + readonly retainedResolver: string | null; + } + | { + readonly outcome: "refused"; + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + readonly command?: string; + }; + +/** JSON-safe summary for doctor and preview; never includes file contents. */ +export interface HackLocalRouteReport { + readonly status: "current" | "repairable" | "refused"; + readonly reason?: HackLocalRouteRefusal; + readonly detail?: string; + readonly oldLine?: string; + readonly newLine?: string; + readonly resolverAction?: "keep" | "create"; + readonly relayPort?: number; + readonly dnsmasqPath: string; + readonly resolverPath: string; + readonly receiptPath: string; + readonly receiptState: HackLocalRouteReceipt["state"] | null; + readonly command?: string; +} + +export function sha256(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +interface ConfigLine { + /** Line text without its terminator (a trailing CR is excluded too). */ + readonly text: string; + readonly start: number; + readonly end: number; +} + +function configLines(content: string): readonly ConfigLine[] { + const lines: ConfigLine[] = []; + let start = 0; + while (start <= content.length) { + const newline = content.indexOf("\n", start); + const lineEnd = newline < 0 ? content.length : newline; + const end = + lineEnd > start && content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd; + lines.push({ text: content.slice(start, end), start, end }); + if (newline < 0) { + break; + } + start = newline + 1; + } + return lines; +} + +/** True when a non-comment dnsmasq directive names hack.local or a subdomain. */ +function claimsHackLocal(text: string): boolean { + const line = text.trim(); + if (!line || line.startsWith("#")) { + return false; + } + const equal = line.indexOf("="); + if (equal < 0) { + return false; + } + const value = line.slice(equal + 1).split("#", 1)[0] ?? ""; + return (value.match(DOMAIN_TOKEN) ?? []).some((token) => { + const domain = token.toLowerCase(); + return ( + domain === HACK_LOCAL_DOMAIN || domain.endsWith(`.${HACK_LOCAL_DOMAIN}`) + ); + }); +} + +/** Replace exactly one whole line; null when the line is absent or repeated. */ +export function replaceManagedLine(opts: { + readonly content: string; + readonly from: string; + readonly to: string; +}): string | null { + const matches = configLines(opts.content).filter( + (line) => line.text === opts.from + ); + if (matches.length !== 1) { + return null; + } + const [line] = matches as [ConfigLine]; + return `${opts.content.slice(0, line.start)}${opts.to}${opts.content.slice(line.end)}`; +} + +type DnsmasqAnalysis = + | { + readonly kind: "refused"; + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + } + | { readonly kind: "line"; readonly line: string; readonly target: string }; + +/** Pure classification of the managed line; exported for focused tests. */ +export function analyzeHackLocalDnsmasq(opts: { + readonly content: string | null; + readonly includeFiles: readonly { + readonly path: string; + readonly content: string; + }[]; +}): DnsmasqAnalysis { + if (opts.content === null) { + return { + kind: "refused", + reason: "dnsmasq-config-missing", + detail: "The dnsmasq configuration does not exist.", + }; + } + const included = opts.includeFiles.find((file) => + configLines(file.content).some((line) => claimsHackLocal(line.text)) + ); + if (included) { + return { + kind: "refused", + reason: "unmanaged-line", + detail: `${included.path} also claims .${HACK_LOCAL_DOMAIN}; Hack manages only the main configuration line.`, + }; + } + const claims = configLines(opts.content).filter((line) => + claimsHackLocal(line.text) + ); + if (claims.length === 0) { + return { + kind: "refused", + reason: "no-managed-line", + detail: `No ${LINE_PREFIX} line exists; the native repair never adds one.`, + }; + } + if (claims.length > 1) { + return { + kind: "refused", + reason: "unmanaged-line", + detail: `${claims.length} lines claim .${HACK_LOCAL_DOMAIN}; only a single Hack-managed line can be rewritten.`, + }; + } + const line = (claims[0] as ConfigLine).text; + const target = line.startsWith(LINE_PREFIX) + ? line.slice(LINE_PREFIX.length) + : ""; + if (!TARGET_FORMAT.test(target)) { + return { + kind: "refused", + reason: "unmanaged-line", + detail: `"${line.slice(0, 200)}" is not in the Hack-managed ${LINE_PREFIX}
form.`, + }; + } + if (target !== DEFAULT_HOST_DNS_IP && !REPLACEABLE_TARGETS.has(target)) { + return { + kind: "refused", + reason: "foreign-target", + detail: `.${HACK_LOCAL_DOMAIN} routes to ${target}, which Hack never wrote; refusing to replace it.`, + }; + } + return { kind: "line", line, target }; +} + +function analyzeResolver( + content: string | null +): "keep" | "create" | "foreign" { + if (content === null) { + return "create"; + } + return content.trim() === HACK_LOCAL_RESOLVER_CONTENT.trim() + ? "keep" + : "foreign"; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length < 4096; +} + +/** Strict receipt parsing; any unknown shape refuses rather than guessing ownership. */ +export function parseHackLocalRouteReceipt(raw: string): HackLocalRouteReceipt { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("hack.local repair receipt is not valid JSON"); + } + const ok = + isRecord(value) && + value.version === 1 && + value.kind === "hack-local-route-repair" && + (value.state === "pending" || + value.state === "applied" || + value.state === "rolled-back") && + value.domain === HACK_LOCAL_DOMAIN && + nonEmptyString(value.dnsmasqPath) && + nonEmptyString(value.resolverPath) && + nonEmptyString(value.oldLine) && + value.oldLine.startsWith(LINE_PREFIX) && + value.newLine === HACK_LOCAL_RELAY_LINE && + typeof value.beforeSha256 === "string" && + SHA256.test(value.beforeSha256) && + typeof value.afterSha256 === "string" && + SHA256.test(value.afterSha256) && + (value.resolverAction === "keep" || value.resolverAction === "create") && + typeof value.resolverSha256 === "string" && + SHA256.test(value.resolverSha256) && + (value.resolverIdentity === null || + nonEmptyString(value.resolverIdentity)) && + typeof value.relayPort === "number" && + Number.isInteger(value.relayPort) && + value.relayPort > 0 && + value.relayPort < 65_536 && + nonEmptyString(value.createdAt) && + nonEmptyString(value.updatedAt); + if (!ok) { + throw new Error("hack.local repair receipt has an unexpected shape"); + } + return value as unknown as HackLocalRouteReceipt; +} + +type StoredReceipt = + | { + readonly raw: string | null; + readonly receipt: HackLocalRouteReceipt | null; + } + | { readonly invalid: string }; + +async function readReceipt( + deps: HackLocalRouteDependencies +): Promise { + const raw = await deps.readReceipt(); + if (raw === null) { + return { raw, receipt: null }; + } + try { + return { raw, receipt: parseHackLocalRouteReceipt(raw) }; + } catch (error) { + return { invalid: error instanceof Error ? error.message : String(error) }; + } +} + +function refused(opts: { + readonly reason: HackLocalRouteRefusal; + readonly detail: string; + readonly paths: HackLocalRoutePaths; + readonly receipt: HackLocalRouteReceipt | null; +}): HackLocalRouteInspection { + return { status: "refused", ...opts }; +} + +/** + * Read-only inspection. Preconditions are checked in a fixed order so that a + * missing relay never reads host DNS files and a live old target is never + * replaced: receipt, relay, managed line, resolver, then the old-target probe. + */ +export async function inspectHackLocalRoute( + deps: HackLocalRouteDependencies +): Promise { + const { paths } = deps; + const stored = await readReceipt(deps); + if ("invalid" in stored) { + return refused({ + reason: "invalid-receipt", + detail: `${stored.invalid}; refusing to infer ownership of ${paths.receiptPath}.`, + paths, + receipt: null, + }); + } + const { receipt } = stored; + if (receipt?.state === "pending") { + return refused({ + reason: "pending-receipt", + detail: `An interrupted hack.local repair left a pending receipt. Restore the recorded earlier line with: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + paths, + receipt, + }); + } + const relay = await deps.verifyRelay(); + if (!relay) { + return refused({ + reason: "relay-not-verified", + detail: + "No verified native HTTPS relay is listening on 127.0.0.1; start a native project before repairing *.hack.local.", + paths, + receipt, + }); + } + const [content, includeFiles, resolver] = await Promise.all([ + deps.readDnsmasq(), + deps.readIncludeFiles(), + deps.readResolver(), + ]); + const analysis = analyzeHackLocalDnsmasq({ content, includeFiles }); + if (analysis.kind === "refused") { + return refused({ ...analysis, paths, receipt }); + } + const resolverAction = analyzeResolver(resolver); + if (resolverAction === "foreign") { + return refused({ + reason: "foreign-resolver", + detail: `${paths.resolverPath} exists with content Hack did not write; refusing to change it.`, + paths, + receipt, + }); + } + const beforeContent = content as string; + if (analysis.target === DEFAULT_HOST_DNS_IP && resolverAction === "keep") { + return { + status: "current", + paths, + relayPort: relay.port, + receipt, + }; + } + if ( + analysis.target !== DEFAULT_HOST_DNS_IP && + (await deps.probeTarget(analysis.target)) + ) { + return refused({ + reason: "old-target-live", + detail: `${analysis.target}:443 still answers (Docker Caddy may be running); refusing to break the live route.`, + paths, + receipt, + }); + } + const afterContent = + analysis.target === DEFAULT_HOST_DNS_IP + ? beforeContent + : replaceManagedLine({ + content: beforeContent, + from: analysis.line, + to: HACK_LOCAL_RELAY_LINE, + }); + if (afterContent === null) { + return refused({ + reason: "unmanaged-line", + detail: "The managed line could not be located exactly once.", + paths, + receipt, + }); + } + const fingerprint = sha256( + JSON.stringify({ + beforeContent, + includeFiles, + resolver, + receipt: stored.raw, + relayPort: relay.port, + }) + ); + return { + status: "repairable", + receipt, + plan: { + paths, + oldLine: analysis.line, + newLine: HACK_LOCAL_RELAY_LINE, + oldTarget: analysis.target, + beforeContent, + afterContent, + beforeSha256: sha256(beforeContent), + afterSha256: sha256(afterContent), + resolverAction, + relayPort: relay.port, + fingerprint, + }, + }; +} + +/** Summarize an inspection for doctor and preview output. */ +export function reportHackLocalRoute( + inspection: HackLocalRouteInspection +): HackLocalRouteReport { + const paths = + inspection.status === "repairable" + ? inspection.plan.paths + : inspection.paths; + const base = { + dnsmasqPath: paths.dnsmasqPath, + resolverPath: paths.resolverPath, + receiptPath: paths.receiptPath, + receiptState: inspection.receipt?.state ?? null, + }; + if (inspection.status === "current") { + return { + ...base, + status: "current", + relayPort: inspection.relayPort, + ...(inspection.receipt?.state === "applied" + ? { command: HACK_LOCAL_ROLLBACK_COMMAND } + : {}), + }; + } + if (inspection.status === "repairable") { + return { + ...base, + status: "repairable", + oldLine: inspection.plan.oldLine, + newLine: inspection.plan.newLine, + resolverAction: inspection.plan.resolverAction, + relayPort: inspection.plan.relayPort, + command: HACK_LOCAL_REPAIR_COMMAND, + }; + } + return { + ...base, + status: "refused", + reason: inspection.reason, + detail: inspection.detail, + ...(inspection.reason === "pending-receipt" + ? { command: HACK_LOCAL_ROLLBACK_COMMAND } + : {}), + }; +} + +/** One-line human message for a report. */ +export function describeHackLocalRoute(report: HackLocalRouteReport): string { + if (report.status === "current") { + return `*.${HACK_LOCAL_DOMAIN} routes to the native relay on 127.0.0.1:${report.relayPort}.${ + report.receiptState === "applied" + ? ` Undo the native repair with: ${HACK_LOCAL_ROLLBACK_COMMAND}` + : "" + }`; + } + if (report.status === "repairable") { + const resolver = + report.resolverAction === "create" + ? `create ${report.resolverPath} with "nameserver 127.0.0.1"` + : ""; + const rewrite = + report.oldLine === report.newLine + ? "" + : `rewrite "${report.oldLine}" to "${report.newLine}" in ${report.dnsmasqPath}`; + const change = [rewrite, resolver].filter(Boolean).join(" and "); + return `Proposed: ${change}. Apply with: ${HACK_LOCAL_REPAIR_COMMAND}`; + } + return `Not repairable (${report.reason}): ${report.detail}`; +} + +function samePlan( + left: HackLocalRoutePlan, + right: HackLocalRoutePlan +): boolean { + return ( + left.fingerprint === right.fingerprint && + left.beforeSha256 === right.beforeSha256 && + left.afterSha256 === right.afterSha256 && + left.oldLine === right.oldLine && + left.resolverAction === right.resolverAction && + left.relayPort === right.relayPort && + left.paths.dnsmasqPath === right.paths.dnsmasqPath && + left.paths.resolverPath === right.paths.resolverPath && + left.paths.receiptPath === right.paths.receiptPath + ); +} + +function errorMessage(error: unknown): string { + return (error instanceof Error ? error.message : String(error)) + .replaceAll("\n", " ") + .slice(0, 512); +} + +/** + * Restore the recorded earlier state. Shared by explicit rollback and by + * automatic cleanup when a repair fails after its first effect. + */ +async function restoreFromReceipt(opts: { + readonly deps: HackLocalRouteDependencies; + readonly receipt: HackLocalRouteReceipt; + readonly restart: () => Promise; +}): Promise<{ readonly retainedResolver: string | null }> { + const { deps, receipt } = opts; + const current = await deps.readDnsmasq(); + if (current === null) { + throw new Error("dnsmasq configuration disappeared during rollback"); + } + const currentSha = sha256(current); + if ( + currentSha === receipt.afterSha256 && + currentSha !== receipt.beforeSha256 + ) { + const restored = replaceManagedLine({ + content: current, + from: receipt.newLine, + to: receipt.oldLine, + }); + if (restored === null || sha256(restored) !== receipt.beforeSha256) { + throw new Error("Restored dnsmasq bytes would not match the receipt"); + } + await deps.replaceDnsmasq({ expected: current, next: restored }); + } else if (currentSha !== receipt.beforeSha256) { + throw new Error("dnsmasq configuration changed since the repair"); + } + const verified = await deps.readDnsmasq(); + if (verified === null || sha256(verified) !== receipt.beforeSha256) { + throw new Error("Restored dnsmasq configuration hash does not match"); + } + let retainedResolver: string | null = null; + if (receipt.resolverAction === "create") { + const resolver = await deps.readResolver(); + if (resolver !== null) { + const removed = + receipt.resolverIdentity !== null && + (await deps.removeResolverIfOwned({ + identity: receipt.resolverIdentity, + content: HACK_LOCAL_RESOLVER_CONTENT, + })); + if (!removed) { + retainedResolver = receipt.resolverPath; + } + } + } + await opts.restart(); + await deps.flushDnsCache(); + return { retainedResolver }; +} + +function stamp( + receipt: HackLocalRouteReceipt, + patch: Partial>, + now: Date +): HackLocalRouteReceipt { + return Object.freeze({ ...receipt, ...patch, updatedAt: now.toISOString() }); +} + +/** + * Authorized, reversible repair. Rechecks the plan after the administrator + * prompt, publishes a pending receipt before effects, and on failure restores + * the recorded earlier line. If that cleanup cannot be proven the pending + * receipt stays for the explicit rollback command. + */ +export async function repairHackLocalRoute( + deps: HackLocalRouteDependencies +): Promise { + const before = await inspectHackLocalRoute(deps); + if (before.status === "current") { + return { outcome: "current", relayPort: before.relayPort }; + } + if (before.status === "refused") { + return { + outcome: "refused", + reason: before.reason, + detail: before.detail, + ...(before.reason === "pending-receipt" + ? { command: HACK_LOCAL_ROLLBACK_COMMAND } + : {}), + }; + } + const { plan } = before; + if (!deps.canPrompt()) { + return { + outcome: "refused", + reason: "non-interactive", + detail: `The repair needs interactive confirmation and a macOS administrator prompt. Run it in a terminal: ${HACK_LOCAL_REPAIR_COMMAND}`, + command: HACK_LOCAL_REPAIR_COMMAND, + }; + } + const changes = [ + plan.oldLine === plan.newLine + ? "" + : `rewrite only "${plan.oldLine}" to "${plan.newLine}" in ${plan.paths.dnsmasqPath}`, + plan.resolverAction === "create" + ? `create ${plan.paths.resolverPath} with "nameserver 127.0.0.1"` + : "", + ].filter(Boolean); + const probe = + plan.oldTarget === DEFAULT_HOST_DNS_IP + ? "" + : `${plan.oldTarget}:443 did not answer; `; + const approved = await deps.authorize( + `Native hack.local repair: ${changes.join(" and ")}? ${probe}the native relay listens on 127.0.0.1:${plan.relayPort}. This restarts dnsmasq. Undo: ${HACK_LOCAL_ROLLBACK_COMMAND}` + ); + if (!approved) { + return { + outcome: "refused", + reason: "declined", + detail: "The hack.local repair was not authorized; nothing changed.", + }; + } + const restart = await deps.prepareRestart(); + const after = await inspectHackLocalRoute(deps); + if (after.status !== "repairable" || !samePlan(plan, after.plan)) { + return { + outcome: "refused", + reason: "inputs-changed", + detail: + "DNS files, the relay or the old target changed during authorization; nothing changed.", + }; + } + + const createdAt = deps.now().toISOString(); + let receipt: HackLocalRouteReceipt = Object.freeze({ + version: 1, + kind: "hack-local-route-repair", + state: "pending", + domain: HACK_LOCAL_DOMAIN, + dnsmasqPath: plan.paths.dnsmasqPath, + resolverPath: plan.paths.resolverPath, + oldLine: plan.oldLine, + newLine: plan.newLine, + beforeSha256: plan.beforeSha256, + afterSha256: plan.afterSha256, + resolverAction: plan.resolverAction, + resolverSha256: sha256(HACK_LOCAL_RESOLVER_CONTENT), + resolverIdentity: null, + relayPort: plan.relayPort, + createdAt, + updatedAt: createdAt, + }); + await deps.writeReceipt(receipt); + + let phase = "dnsmasq rewrite"; + try { + if (plan.afterContent !== plan.beforeContent) { + await deps.replaceDnsmasq({ + expected: plan.beforeContent, + next: plan.afterContent, + }); + } + if (plan.resolverAction === "create") { + phase = "resolver create"; + const identity = await deps.createResolver(HACK_LOCAL_RESOLVER_CONTENT); + receipt = stamp(receipt, { resolverIdentity: identity }, deps.now()); + await deps.writeReceipt(receipt); + } + phase = "dnsmasq restart"; + await restart(); + phase = "DNS cache flush"; + await deps.flushDnsCache(); + phase = "live DNS verification"; + await deps.verifyLiveDns(); + phase = "receipt"; + receipt = stamp(receipt, { state: "applied" }, deps.now()); + await deps.writeReceipt(receipt); + return { outcome: "repaired", plan, receipt }; + } catch (cause) { + let retained: string | null = null; + try { + ({ retainedResolver: retained } = await restoreFromReceipt({ + deps, + receipt, + restart, + })); + await deps.writeReceipt( + stamp(receipt, { state: "rolled-back" }, deps.now()) + ); + } catch (cleanup) { + throw new Error( + `hack.local repair failed during ${phase}: ${errorMessage(cause)}; automatic restore failed (${errorMessage(cleanup)}). Recover with: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + { cause } + ); + } + throw new Error( + `hack.local repair failed during ${phase}: ${errorMessage(cause)}; the earlier dnsmasq line was restored${ + retained + ? `; ${retained} was retained because its ownership is unproven` + : "" + }.`, + { cause } + ); + } +} + +/** + * Restore the exact earlier line recorded by an applied or interrupted repair + * and verify the whole-file hash. A file changed since the repair refuses. + */ +export async function rollbackHackLocalRoute( + deps: HackLocalRouteDependencies +): Promise { + const stored = await readReceipt(deps); + if ("invalid" in stored) { + return { + outcome: "refused", + reason: "invalid-receipt", + detail: `${stored.invalid}; refusing to infer ownership of ${deps.paths.receiptPath}.`, + }; + } + const { receipt } = stored; + if (!receipt || receipt.state === "rolled-back") { + return { + outcome: "refused", + reason: "no-receipt", + detail: `No applied or interrupted hack.local repair is recorded at ${deps.paths.receiptPath}; nothing to roll back.`, + }; + } + if ( + receipt.dnsmasqPath !== deps.paths.dnsmasqPath || + receipt.resolverPath !== deps.paths.resolverPath + ) { + return { + outcome: "refused", + reason: "rollback-drift", + detail: `The receipt names ${receipt.dnsmasqPath}, not the current ${deps.paths.dnsmasqPath}.`, + }; + } + const current = await deps.readDnsmasq(); + const currentSha = current === null ? null : sha256(current); + if ( + currentSha === null || + (currentSha !== receipt.afterSha256 && currentSha !== receipt.beforeSha256) + ) { + return { + outcome: "refused", + reason: "rollback-drift", + detail: `${receipt.dnsmasqPath} changed since the repair (hash differs from the receipt); restore "${receipt.oldLine}" manually.`, + }; + } + if (!deps.canPrompt()) { + return { + outcome: "refused", + reason: "non-interactive", + detail: `Rollback needs interactive confirmation and a macOS administrator prompt. Run it in a terminal: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + command: HACK_LOCAL_ROLLBACK_COMMAND, + }; + } + const approved = await deps.authorize( + `Restore "${receipt.oldLine}" in ${receipt.dnsmasqPath}${ + receipt.resolverAction === "create" + ? ` and remove the ${receipt.resolverPath} this repair created` + : "" + }? This restarts dnsmasq.` + ); + if (!approved) { + return { + outcome: "refused", + reason: "declined", + detail: "The hack.local rollback was not authorized; nothing changed.", + }; + } + const restart = await deps.prepareRestart(); + const again = await deps.readReceipt(); + const recheck = await deps.readDnsmasq(); + if (again !== stored.raw || recheck !== current) { + return { + outcome: "refused", + reason: "inputs-changed", + detail: + "The receipt or dnsmasq configuration changed during authorization; nothing changed.", + }; + } + const { retainedResolver } = await restoreFromReceipt({ + deps, + receipt, + restart, + }); + const rolledBack = stamp(receipt, { state: "rolled-back" }, deps.now()); + await deps.writeReceipt(rolledBack); + return { outcome: "rolled-back", receipt: rolledBack, retainedResolver }; +} + +export type NativeDnsCommandDomain = + | { readonly kind: "custom"; readonly domain: string } + | { readonly kind: "hack-local" }; + +/** + * Select the suffix for `hack global dns preview|activate|deactivate`. + * Without --domain only a configured custom default_domain is used; a + * built-in default is a closed usage error that names the right command. + */ +export function selectNativeDnsCommandDomain(opts: { + readonly verb: "preview" | "activate" | "deactivate"; + /** Normalized --domain value, if given. */ + readonly explicit: string | undefined; + /** Normalized global default_domain (hack.local when unset). */ + readonly configuredDefault: string; +}): NativeDnsCommandDomain { + const domain = opts.explicit ?? opts.configuredDefault; + if (opts.explicit === undefined && BUILT_IN_ROOTS.has(domain)) { + throw new CliUsageError( + [ + `hack global dns ${opts.verb} needs --domain: the default domain ${domain} is a built-in root this command never claims.`, + "For a dedicated custom suffix: hack global dns preview --domain v5.hack.gy", + `To point *.${HACK_LOCAL_DOMAIN} at the native relay: ${HACK_LOCAL_REPAIR_COMMAND} (inspect first with hack global dns preview --domain ${HACK_LOCAL_DOMAIN})`, + `To undo that repair: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + ].join("\n") + ); + } + if (domain === HACK_LOCAL_DOMAIN) { + if (opts.verb === "activate") { + throw new CliUsageError( + `*.${HACK_LOCAL_DOMAIN} is a built-in root, not a scoped claim. Repair its native route with: ${HACK_LOCAL_REPAIR_COMMAND}` + ); + } + return { kind: "hack-local" }; + } + return { kind: "custom", domain }; +} diff --git a/tests/native-ca-doctor-cli.test.ts b/tests/native-ca-doctor-cli.test.ts index da2e38ce2..8d43196fd 100644 --- a/tests/native-ca-doctor-cli.test.ts +++ b/tests/native-ca-doctor-cli.test.ts @@ -25,15 +25,19 @@ test("native Doctor reports unknown owner without consulting Docker-era CA state const result = await invoke(["doctor", "--json"]); expect(result.code).toBe(0); const envelope: unknown = JSON.parse(result.stdout); - expect(envelope).toMatchObject({ - ok: true, - data: { - checks: [ - { id: "native https owner", status: "warn" }, - { id: "native root trust", status: "warn" }, - ], - }, - }); + const checks: Record[] = [ + { id: "native https owner", status: "warn" }, + { id: "native root trust", status: "warn" }, + ]; + if (process.platform === "darwin") { + // The hack.local route check refuses before reading any host DNS file. + checks.push({ + id: "native hack.local route", + status: "warn", + hackLocalRoute: { status: "refused", reason: "relay-not-verified" }, + }); + } + expect(envelope).toMatchObject({ ok: true, data: { checks } }); expect(result.stdout).not.toContain("caddy local ca"); expect(result.stdout).not.toContain("docker daemon"); }); diff --git a/tests/native-hack-local-route-cli.test.ts b/tests/native-hack-local-route-cli.test.ts new file mode 100644 index 000000000..7d3f8e22c --- /dev/null +++ b/tests/native-hack-local-route-cli.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +/** Selection fails before any runtime, host DNS or privileged step. */ +async function invoke(args: string[]) { + const home = await mkdtemp(join(tmpdir(), "hack-local-route-cli-")); + roots.push(home); + const child = Bun.spawn([process.execPath, "index.ts", ...args], { + env: { + ...process.env, + HOME: home, + HACK_HOME: join(home, ".hack"), + HACK_GLOBAL_CONFIG_PATH: join(home, ".hack", "hack.config.json"), + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: "/nonexistent/hack-native", + HACK_NATIVE_HOME: "/nonexistent/native-home", + HACK_NO_INTERACTIVE: "1", + }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { output: stdout + stderr, code }; +} + +for (const verb of ["preview", "activate", "deactivate"]) { + test(`global dns ${verb} without --domain names the right command`, async () => { + const result = await invoke(["global", "dns", verb]); + expect(result.code).toBe(1); + expect(result.output).toContain( + `hack global dns ${verb} needs --domain: the default domain hack.local is a built-in root` + ); + expect(result.output).toContain("hack doctor --fix"); + expect(result.output).toContain( + "hack global dns deactivate --domain hack.local" + ); + expect(result.output).not.toContain("Native domain DNS plan refused"); + }); +} + +test("global dns activate --domain hack.local points to the doctor repair", async () => { + const result = await invoke([ + "global", + "dns", + "activate", + "--domain", + "hack.local", + ]); + expect(result.code).toBe(1); + expect(result.output).toContain( + "Repair its native route with: hack doctor --fix" + ); +}); diff --git a/tests/native-hack-local-route.test.ts b/tests/native-hack-local-route.test.ts new file mode 100644 index 000000000..4b8097bf5 --- /dev/null +++ b/tests/native-hack-local-route.test.ts @@ -0,0 +1,661 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + open, + readdir, + readFile, + rm, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { CliUsageError } from "../src/cli/command.ts"; +import type { NativeDnsCommandRunner } from "../src/lib/native-domain-dns-host.ts"; +import { + analyzeHackLocalDnsmasq, + HACK_LOCAL_REPAIR_COMMAND, + HACK_LOCAL_RESOLVER_CONTENT, + HACK_LOCAL_ROLLBACK_COMMAND, + type HackLocalRouteDependencies, + type HackLocalRouteReceipt, + inspectHackLocalRoute, + parseHackLocalRouteReceipt, + repairHackLocalRoute, + reportHackLocalRoute, + rollbackHackLocalRoute, + selectNativeDnsCommandDomain, + sha256, +} from "../src/lib/native-hack-local-route.ts"; +import { createHackLocalRouteFileDependencies } from "../src/lib/native-hack-local-route-host.ts"; + +const ORIGINAL = [ + "# Managed by hack", + "port=53", + "listen-address=127.0.0.1", + "address=/.hack/172.30.0.2", + "address=/.hack.local/172.30.0.2", + "address=/.hack.gy/172.30.0.2", + "", + "# trailing user comment", + "", +].join("\n"); + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +interface Calls { + readonly authorize: string[]; + restart: number; + flush: number; + verify: number; + readDnsmasq: number; + probes: string[]; +} + +/** Emulates the privileged resolver script inside the temp root only. */ +function fakePrivileged(resolverDir: string): NativeDnsCommandRunner { + return async (command) => { + expect(command.slice(0, 3)).toEqual(["sudo", "-n", "/usr/bin/python3"]); + const [action, path, content, identity] = command.slice(7); + if (!(path?.startsWith(`${resolverDir}/`) && content !== undefined)) { + return { exitCode: 1, stdout: "", stderr: "outside root" }; + } + if (action === "create") { + const handle = await open(path, "wx", 0o644); + await handle.writeFile(content); + const details = await handle.stat(); + await handle.close(); + return { + exitCode: 0, + stdout: `${details.dev}:${details.ino}\n`, + stderr: "", + }; + } + try { + const details = await stat(path); + const actual = await readFile(path, "utf8"); + if (`${details.dev}:${details.ino}` !== identity || actual !== content) { + return { exitCode: 0, stdout: "0\n", stderr: "" }; + } + await unlink(path); + return { exitCode: 0, stdout: "1\n", stderr: "" }; + } catch { + return { exitCode: 0, stdout: "0\n", stderr: "" }; + } + }; +} + +async function fixture( + opts: { + readonly dnsmasq?: string | null; + readonly resolver?: string | null; + readonly includeFiles?: Readonly>; + readonly relay?: { readonly port: number } | null; + readonly oldTargetLive?: boolean; + readonly prompt?: boolean; + readonly approve?: boolean; + readonly overrides?: Partial; + } = {} +) { + const root = await mkdtemp(join(tmpdir(), "hack-local-route-")); + roots.push(root); + const etc = join(root, "etc"); + const includeDir = join(etc, "dnsmasq.d"); + const resolverDir = join(root, "resolver"); + const home = join(root, "native-home"); + await Promise.all([ + mkdir(includeDir, { recursive: true }), + mkdir(resolverDir), + mkdir(home, { mode: 0o700 }), + ]); + const dnsmasqPath = join(etc, "dnsmasq.conf"); + const content = opts.dnsmasq === undefined ? ORIGINAL : opts.dnsmasq; + if (content !== null) { + await writeFile(dnsmasqPath, content, { mode: 0o644 }); + } + for (const [name, text] of Object.entries(opts.includeFiles ?? {})) { + await writeFile(join(includeDir, name), text); + } + // Sibling roots that must never change. + await writeFile(join(resolverDir, "hack"), "nameserver 127.0.0.1\n"); + await writeFile(join(resolverDir, "hack.gy"), "nameserver 127.0.0.1\n"); + const resolverPath = join(resolverDir, "hack.local"); + if (opts.resolver !== undefined && opts.resolver !== null) { + await writeFile(resolverPath, opts.resolver); + } + const calls: Calls = { + authorize: [], + restart: 0, + flush: 0, + verify: 0, + readDnsmasq: 0, + probes: [], + }; + const files = createHackLocalRouteFileDependencies({ + dnsmasqPath, + includeDir, + resolverPath, + receiptPath: join(home, "native-dns", "hack-local-route.json"), + runPrivileged: fakePrivileged(resolverDir), + }); + let clock = Date.parse("2026-10-12T12:00:00.000Z"); + const deps: HackLocalRouteDependencies = { + ...files, + readDnsmasq: async () => { + calls.readDnsmasq += 1; + return await files.readDnsmasq(); + }, + verifyRelay: async () => + opts.relay === undefined ? { port: 8443 } : opts.relay, + probeTarget: async (target) => { + calls.probes.push(target); + return opts.oldTargetLive === true; + }, + canPrompt: () => opts.prompt ?? true, + authorize: async (message) => { + calls.authorize.push(message); + return opts.approve ?? true; + }, + prepareRestart: async () => async () => { + calls.restart += 1; + }, + flushDnsCache: async () => { + calls.flush += 1; + }, + verifyLiveDns: async () => { + calls.verify += 1; + }, + now: () => { + clock += 1000; + return new Date(clock); + }, + ...opts.overrides, + }; + return { root, deps, calls, dnsmasqPath, resolverPath, resolverDir, home }; +} + +/** Every file under root except the given paths, as exact bytes. */ +async function snapshotTree( + root: string, + except: readonly string[] +): Promise> { + const out: Record = {}; + const walk = async (directory: string) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await walk(path); + } else if (!except.includes(path)) { + out[relative(root, path)] = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + } + } + }; + await walk(root); + return out; +} + +async function readReceipt( + deps: HackLocalRouteDependencies +): Promise { + return parseHackLocalRouteReceipt( + await readFile(deps.paths.receiptPath, "utf8") + ); +} + +const REWRITTEN = ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/127.0.0.1" +); + +test("happy path rewrites only the managed line in place and records a receipt", async () => { + const f = await fixture(); + const others = await snapshotTree(f.root, [ + f.dnsmasqPath, + f.resolverPath, + f.deps.paths.receiptPath, + ]); + + const before = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(before).toMatchObject({ + status: "repairable", + oldLine: "address=/.hack.local/172.30.0.2", + newLine: "address=/.hack.local/127.0.0.1", + resolverAction: "create", + relayPort: 8443, + command: HACK_LOCAL_REPAIR_COMMAND, + }); + + const result = await repairHackLocalRoute(f.deps); + expect(result.outcome).toBe("repaired"); + + const after = await readFile(f.dnsmasqPath, "utf8"); + expect(after).toBe(REWRITTEN); + expect(after.match(/hack\.local/g)).toHaveLength(1); + expect(after).toContain("address=/.hack/172.30.0.2\n"); + expect(after).toContain("address=/.hack.gy/172.30.0.2\n"); + expect((await stat(f.dnsmasqPath)).mode & 0o777).toBe(0o644); + expect(await readFile(f.resolverPath, "utf8")).toBe( + HACK_LOCAL_RESOLVER_CONTENT + ); + expect( + await snapshotTree(f.root, [ + f.dnsmasqPath, + f.resolverPath, + f.deps.paths.receiptPath, + ]) + ).toEqual(others); + expect((await readdir(join(f.root, "etc"))).sort()).toEqual([ + "dnsmasq.conf", + "dnsmasq.d", + ]); + + const receipt = await readReceipt(f.deps); + expect(receipt).toMatchObject({ + state: "applied", + oldLine: "address=/.hack.local/172.30.0.2", + newLine: "address=/.hack.local/127.0.0.1", + beforeSha256: sha256(ORIGINAL), + afterSha256: sha256(REWRITTEN), + resolverAction: "create", + relayPort: 8443, + }); + expect(receipt.resolverIdentity).toMatch(/^\d+:\d+$/); + expect(Date.parse(receipt.createdAt)).toBeGreaterThan(0); + expect((await stat(f.deps.paths.receiptPath)).mode & 0o777).toBe(0o600); + expect(f.calls.authorize).toHaveLength(1); + expect(f.calls.authorize[0]).toContain(HACK_LOCAL_ROLLBACK_COMMAND); + expect(f.calls.probes).toContain("172.30.0.2"); + expect([f.calls.restart, f.calls.flush, f.calls.verify]).toEqual([1, 1, 1]); + + const now = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(now).toMatchObject({ + status: "current", + receiptState: "applied", + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("current"); +}); + +test("a live old target refuses so Docker Caddy is never broken", async () => { + const f = await fixture({ oldTargetLive: true }); + const result = await repairHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "refused", + reason: "old-target-live", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect(f.calls.authorize).toHaveLength(0); + await expect(stat(f.deps.paths.receiptPath)).rejects.toThrow(); +}); + +test("unmanaged hack.local lines refuse without changes", async () => { + const variants = [ + ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/172.30.0.2 # mine" + ), + ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + " address=/.hack.local/172.30.0.2" + ), + ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/172.30.0.2\naddress=/.hack.local/127.0.0.1" + ), + ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "server=/hack.local/10.0.0.53" + ), + ]; + for (const dnsmasq of variants) { + const f = await fixture({ dnsmasq }); + const result = await repairHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "refused", + reason: "unmanaged-line", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(dnsmasq); + } + const included = await fixture({ + includeFiles: { "other.conf": "address=/.api.hack.local/10.0.0.9\n" }, + }); + expect(await repairHackLocalRoute(included.deps)).toMatchObject({ + outcome: "refused", + reason: "unmanaged-line", + }); + const missing = await fixture({ + dnsmasq: ORIGINAL.replace("address=/.hack.local/172.30.0.2\n", ""), + }); + expect(await repairHackLocalRoute(missing.deps)).toMatchObject({ + outcome: "refused", + reason: "no-managed-line", + }); +}); + +test("a foreign target refuses", async () => { + const dnsmasq = ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/10.1.2.3" + ); + const f = await fixture({ dnsmasq }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "foreign-target", + }); + expect(f.calls.probes).toEqual([]); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(dnsmasq); + expect( + analyzeHackLocalDnsmasq({ content: dnsmasq, includeFiles: [] }) + ).toMatchObject({ kind: "refused", reason: "foreign-target" }); +}); + +test("a foreign resolver refuses", async () => { + const f = await fixture({ resolver: "nameserver 10.0.0.53\n" }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "foreign-resolver", + }); + expect(await readFile(f.resolverPath, "utf8")).toBe("nameserver 10.0.0.53\n"); +}); + +test("an unverified relay refuses before reading host DNS files", async () => { + const f = await fixture({ relay: null }); + const report = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(report).toMatchObject({ + status: "refused", + reason: "relay-not-verified", + }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "relay-not-verified", + }); + expect(f.calls.readDnsmasq).toBe(0); + expect(f.calls.authorize).toHaveLength(0); +}); + +test("non-interactive and declined runs refuse with an exact command", async () => { + const quiet = await fixture({ prompt: false }); + expect(await repairHackLocalRoute(quiet.deps)).toEqual({ + outcome: "refused", + reason: "non-interactive", + detail: expect.stringContaining(HACK_LOCAL_REPAIR_COMMAND), + command: HACK_LOCAL_REPAIR_COMMAND, + }); + const declined = await fixture({ approve: false }); + expect(await repairHackLocalRoute(declined.deps)).toMatchObject({ + outcome: "refused", + reason: "declined", + }); + for (const f of [quiet, declined]) { + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await expect(stat(f.deps.paths.receiptPath)).rejects.toThrow(); + } +}); + +test("a change during authorization refuses before any effect", async () => { + const f = await fixture(); + const deps: HackLocalRouteDependencies = { + ...f.deps, + authorize: async () => { + await writeFile(f.dnsmasqPath, `${ORIGINAL}# edited\n`); + return true; + }, + }; + expect(await repairHackLocalRoute(deps)).toMatchObject({ + outcome: "refused", + reason: "inputs-changed", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(`${ORIGINAL}# edited\n`); + await expect(stat(f.deps.paths.receiptPath)).rejects.toThrow(); +}); + +test("rollback restores the exact earlier bytes and removes the created resolver", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "rolled-back", + retainedResolver: null, + }); + const restored = await readFile(f.dnsmasqPath); + expect(restored.toString("utf8")).toBe(ORIGINAL); + expect(sha256(restored.toString("utf8"))).toBe(sha256(ORIGINAL)); + await expect(stat(f.resolverPath)).rejects.toThrow(); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); + expect(f.calls.restart).toBe(2); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "no-receipt", + }); +}); + +test("rollback refuses when the file changed after the repair", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const edited = `${REWRITTEN}address=/.example.test/127.0.0.1\n`; + await writeFile(f.dnsmasqPath, edited); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "rollback-drift", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(edited); + expect(await readFile(f.resolverPath, "utf8")).toBe( + HACK_LOCAL_RESOLVER_CONTENT + ); +}); + +test("an interrupted repair leaves a pending receipt that rollback recovers", async () => { + // Simulate a crash after the atomic rewrite: pending receipt plus new bytes. + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const plan = await inspectHackLocalRoute(f.deps); + if (plan.status !== "repairable") { + throw new Error("expected a repairable plan"); + } + const pending: HackLocalRouteReceipt = { + version: 1, + kind: "hack-local-route-repair", + state: "pending", + domain: "hack.local", + dnsmasqPath: f.dnsmasqPath, + resolverPath: f.resolverPath, + oldLine: plan.plan.oldLine, + newLine: plan.plan.newLine, + beforeSha256: plan.plan.beforeSha256, + afterSha256: plan.plan.afterSha256, + resolverAction: "keep", + resolverSha256: sha256(HACK_LOCAL_RESOLVER_CONTENT), + resolverIdentity: null, + relayPort: 8443, + createdAt: "2026-10-12T12:00:00.000Z", + updatedAt: "2026-10-12T12:00:00.000Z", + }; + await f.deps.writeReceipt(pending); + await f.deps.replaceDnsmasq({ expected: ORIGINAL, next: REWRITTEN }); + + expect( + reportHackLocalRoute(await inspectHackLocalRoute(f.deps)) + ).toMatchObject({ + status: "refused", + reason: "pending-receipt", + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "pending-receipt", + }); + + expect((await rollbackHackLocalRoute(f.deps)).outcome).toBe("rolled-back"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect(await readFile(f.resolverPath, "utf8")).toBe( + HACK_LOCAL_RESOLVER_CONTENT + ); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); +}); + +test("a pending receipt written before the rewrite recovers without a write", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const failing: HackLocalRouteDependencies = { + ...f.deps, + replaceDnsmasq: async () => { + throw new Error("simulated interruption"); + }, + // The automatic restore cannot restart, so the pending receipt remains. + prepareRestart: async () => async () => { + throw new Error("restart unavailable"); + }, + }; + await expect(repairHackLocalRoute(failing)).rejects.toThrow( + HACK_LOCAL_ROLLBACK_COMMAND + ); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect((await readReceipt(f.deps)).state).toBe("pending"); + expect((await rollbackHackLocalRoute(f.deps)).outcome).toBe("rolled-back"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); +}); + +test("a failed live verification restores the earlier line automatically", async () => { + const f = await fixture({ + overrides: { + verifyLiveDns: async () => { + throw new Error("probe did not resolve"); + }, + }, + }); + await expect(repairHackLocalRoute(f.deps)).rejects.toThrow( + "the earlier dnsmasq line was restored" + ); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await expect(stat(f.resolverPath)).rejects.toThrow(); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); +}); + +test("replacement refuses changed bytes and leaves no temporary file", async () => { + const f = await fixture(); + await expect( + f.deps.replaceDnsmasq({ expected: "something else", next: REWRITTEN }) + ).rejects.toThrow("changed before replacement"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect((await readdir(join(f.root, "etc"))).sort()).toEqual([ + "dnsmasq.conf", + "dnsmasq.d", + ]); +}); + +test("an invalid receipt refuses rather than inferring ownership", async () => { + const f = await fixture(); + await mkdir(join(f.home, "native-dns"), { mode: 0o700 }); + await writeFile(f.deps.paths.receiptPath, '{"version":2}'); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "invalid-receipt", + }); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "invalid-receipt", + }); +}); + +test("dns commands without --domain never target a built-in default", () => { + for (const verb of ["preview", "activate", "deactivate"] as const) { + for (const configuredDefault of ["hack.local", "hack", "hack.gy"]) { + expect(() => + selectNativeDnsCommandDomain({ + verb, + explicit: undefined, + configuredDefault, + }) + ).toThrow(CliUsageError); + } + } + expect(() => + selectNativeDnsCommandDomain({ + verb: "preview", + explicit: undefined, + configuredDefault: "hack.local", + }) + ).toThrow(HACK_LOCAL_REPAIR_COMMAND); + expect( + selectNativeDnsCommandDomain({ + verb: "activate", + explicit: undefined, + configuredDefault: "v5.hack.gy", + }) + ).toEqual({ kind: "custom", domain: "v5.hack.gy" }); + expect( + selectNativeDnsCommandDomain({ + verb: "deactivate", + explicit: "hack.local", + configuredDefault: "hack.local", + }) + ).toEqual({ kind: "hack-local" }); + expect(() => + selectNativeDnsCommandDomain({ + verb: "activate", + explicit: "hack.local", + configuredDefault: "hack.local", + }) + ).toThrow(HACK_LOCAL_REPAIR_COMMAND); +}); + +test("doctor --fix applies the repair and JSON reports the route state", async () => { + const { checkNativeHackLocalRoute, buildDoctorJsonData } = await import( + "../src/commands/doctor.ts" + ); + const progress = { start: () => undefined, stop: () => undefined }; + const reportOnly = await fixture(); + const reported = await checkNativeHackLocalRoute({ + progress, + fix: false, + dependencies: async () => reportOnly.deps, + }); + expect(buildDoctorJsonData({ results: reported }).checks).toEqual([ + expect.objectContaining({ + id: "native hack.local route", + status: "warn", + hackLocalRoute: expect.objectContaining({ + status: "repairable", + oldLine: "address=/.hack.local/172.30.0.2", + command: HACK_LOCAL_REPAIR_COMMAND, + }), + }), + ]); + expect(await readFile(reportOnly.dnsmasqPath, "utf8")).toBe(ORIGINAL); + + const fixed = await fixture(); + const results = await checkNativeHackLocalRoute({ + progress, + fix: true, + dependencies: async () => fixed.deps, + }); + expect(results.map((result) => [result.name, result.status])).toEqual([ + ["native hack.local repair", "ok"], + ["native hack.local route", "ok"], + ]); + expect(results[0]?.message).toContain(HACK_LOCAL_ROLLBACK_COMMAND); + expect(await readFile(fixed.dnsmasqPath, "utf8")).toBe(REWRITTEN); + + const quiet = await fixture({ prompt: false }); + const refusedResults = await checkNativeHackLocalRoute({ + progress, + fix: true, + dependencies: async () => quiet.deps, + }); + expect(refusedResults[0]).toMatchObject({ + name: "native hack.local repair", + status: "warn", + }); + expect(refusedResults[0]?.message).toContain("non-interactive"); + expect(refusedResults[0]?.message).toContain(HACK_LOCAL_REPAIR_COMMAND); +}); From aca221555deabb1c8231ade5bd99708b2740cf05 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 15:25:16 -0400 Subject: [PATCH 06/11] fix(native): deliver runtime metadata env to native containers Native foreground up and restart now add the same runtime host metadata the Compose backend generates (HACK_BRANCH, HACK_COMPOSE_PROJECT, HACK_DEV_HOST/URL, HACK_ALIAS_HOST/URL, HACK_SERVICE_* and HACK_RUNTIME_METADATA) to the normalized Compose input native review checks. Values are public container environment derived from the route labels review turns into its route plan, the effective branch and the native HTTPS port (included in URLs when it is not 443). Startup refuses before serving if the advertised hostnames differ from the reviewed routes. Keys a service already declares, including Hack-managed project values, are left untouched, so explicit project values win. Both backends share one builder; the Compose override bytes are unchanged. The run mapping records runtimeMetadata so restores of graphs admitted with metadata reproduce it, while legacy retained graphs are restored exactly as admitted. Restart preflight reviews the same input. The runtime-core hostname-change fingerprint ignores the reserved metadata keys, whose values follow the hostnames, so hostname-only retaining restarts (domain migration) keep working. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 30 +- docs/guides/native-candidate.md | 5 + .../src/project/hostname_change.rs | 84 +++++ .../native-project-restart-preflight.ts | 32 +- src/backends/native-project-run.ts | 8 + src/backends/native-project-start.ts | 48 +++ src/backends/native-runtime-metadata.ts | 175 +++++++++ src/commands/project.ts | 8 + src/lib/runtime-host-metadata.ts | 128 +++++-- tests/native-project-start.test.ts | 266 ++++++++++--- tests/native-runtime-metadata.test.ts | 348 ++++++++++++++++++ tests/runtime-host-metadata.test.ts | 89 +++++ 12 files changed, 1141 insertions(+), 80 deletions(-) create mode 100644 src/backends/native-runtime-metadata.ts create mode 100644 tests/native-runtime-metadata.test.ts diff --git a/docs/cli.md b/docs/cli.md index 67846089b..e12326214 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -388,9 +388,11 @@ deliberately marks that exact Compose volume disposable. Hack deliberately does ### Runtime host metadata -Containers started by `hack up`, `hack restart`, and `hack run` receive the effective instance and -public-route metadata. This keeps server-generated links, OAuth callbacks, webhooks, and other -browser-facing URLs isolated when the same project runs in multiple worktrees. +Containers started by `hack up`, `hack restart`, and `hack run` for a `.hack/docker-compose.yml` +project receive the effective instance and public-route metadata. This applies to the default +Compose backend and to the native backend (`HACK_RUNTIME_BACKEND=native`). It keeps +server-generated links, OAuth callbacks, webhooks, and other browser-facing URLs isolated when the +same project runs in multiple worktrees. Both backends emit the same keys and document shape. | Variable | Value | | --- | --- | @@ -432,11 +434,29 @@ over generated metadata for backward compatibility. Existing containers receive their next `hack up` or `hack restart`; `hack exec` observes the values already stored in the running container. -Hack materializes this contract in generated, machine-local Compose overrides. Do not edit or -commit `.hack/.internal/compose.runtime.override.yml` or +With the Compose backend, Hack materializes this contract in generated, machine-local Compose +overrides. Do not edit or commit `.hack/.internal/compose.runtime.override.yml` or `.hack/.branch/compose..runtime.override.yml`; runtime commands refresh them and Hack's managed `.hack/.gitignore` covers them. +With the native backend, foreground `hack up` and `hack restart` add the same values to the +normalized Compose input that native review checks. They are public container environment, not +private managed values, so `hack exec` and native `hack run` see them too. The service URLs come +from the reviewed route labels, and startup refuses before serving if they differ from the reviewed +route plan. When a native HTTPS frontend is selected with a port other than 443 +(`HACK_NATIVE_HTTPS_PORT`), `HACK_DEV_URL`, `HACK_ALIAS_URL` and every service URL include that +port; host variables never do. Native-specific limits: + +- A graph whose data was retained by an earlier release is restored exactly as it was admitted and + does not gain these variables. It receives them after it is next started without retained data. +- These values are part of the retained graph's reviewed input. A retaining restart that changes + only route hostnames (for example a domain migration) remains supported, because the metadata + keys are excluded from the hostname-change fingerprint. Changing the native HTTPS port alone + changes the metadata without a hostname change, so the retained graph refuses to start with the + new port; start it with its original port. +- Authored `.hack/hack.project.json` configuration does not deliver these variables yet, on either + backend. + Linked worktrees also inherit the project secret key automatically from the primary checkout through the shared git common dir, so you don't need to copy `.hack.secret.key` by hand. Set `HACK_ENV_SECRET_KEY` for CI or fully detached environments. `hack doctor` flags divergent secret diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index fce308140..cc5cb9bd9 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -470,6 +470,11 @@ New normalized shared-source graphs also capture a separate hostname-change contract. A retaining restart can change only the literal hostnames of already routed services: the service set, upstream ports, TLS settings, probes, commands, environment declarations, images, mounts, networks and volumes must stay the same. +The one exception is Hack's reserved +[runtime host metadata](../cli.md#runtime-host-metadata) keys (`HACK_BRANCH`, +`HACK_COMPOSE_PROJECT`, `HACK_DEV_*`, `HACK_ALIAS_*`, `HACK_SERVICE_*` and +`HACK_RUNTIME_METADATA`): their public values are derived from the hostnames, so +the fingerprint ignores them. A metadata change without a hostname change still refuses. Both original and normalized Compose declarations are checked; only fingerprints are retained. Changed dependency-cache inputs still refuse. The old ownership plan and named data identities remain fixed, while normalized input provenance and diff --git a/packages/runtime-core/src/project/hostname_change.rs b/packages/runtime-core/src/project/hostname_change.rs index d5d5e8675..4d81b78e4 100644 --- a/packages/runtime-core/src/project/hostname_change.rs +++ b/packages/runtime-core/src/project/hostname_change.rs @@ -4,11 +4,42 @@ use super::{compose, yaml}; use serde_json::Value; use sha2::{Digest, Sha256}; +/// Hack-owned public runtime host metadata keys (the CLI's runtime host metadata +/// contract). Their values are derived from the effective route hostnames, branch and +/// HTTPS ingress port, so a hostname change necessarily changes them. Like the route +/// hostnames they are excluded from the fingerprint; every other literal still counts. +const RUNTIME_METADATA_KEYS: [&str; 10] = [ + "HACK_ALIAS_HOST", + "HACK_ALIAS_URL", + "HACK_BRANCH", + "HACK_COMPOSE_PROJECT", + "HACK_DEV_HOST", + "HACK_DEV_URL", + "HACK_RUNTIME_METADATA", + "HACK_SERVICE_NAME", + "HACK_SERVICE_URL", + "HACK_SERVICE_URLS", +]; + +fn without_runtime_metadata(service: &mut Value) { + let reserved = |key: &str| RUNTIME_METADATA_KEYS.contains(&key); + match service.get_mut("environment") { + Some(Value::Object(environment)) => environment.retain(|key, _| !reserved(key)), + Some(Value::Array(environment)) => environment.retain(|entry| { + !entry + .as_str() + .is_some_and(|text| reserved(text.split_once('=').map_or(text, |(key, _)| key))) + }), + _ => {} + } +} + fn masked(bytes: &[u8]) -> Option { let mut value = yaml::parse(bytes).ok()?; let services = value.get_mut("services")?.as_object_mut()?; let mut routed = false; for service in services.values_mut() { + without_runtime_metadata(service); let Some(labels) = service.get_mut("labels") else { continue; }; @@ -91,4 +122,57 @@ mod tests { assert_eq!(fingerprint(before, before), fingerprint(after, after)); assert!(fingerprint(b"services: {web: {image: alpine}}", after).is_none()); } + + #[test] + fn runtime_metadata_follows_hostnames_but_other_environment_still_counts() { + let expected = fingerprint(BASE.as_bytes(), BASE.as_bytes()).unwrap(); + let metadata = |host: &str, port: &str| { + BASE.replace("caddy: web.hack", &format!("caddy: {host}")) + .replace( + "environment: {TOKEN: literal}", + &format!( + "environment: {{TOKEN: literal, HACK_DEV_URL: 'https://{host}{port}', HACK_SERVICE_URLS: '[\"https://{host}{port}\"]', HACK_RUNTIME_METADATA: '{{\"version\":1}}', HACK_BRANCH: ''}}" + ), + ) + }; + for normalized in [ + metadata("web.hack", ""), + metadata("web.v5.hack.gy", ""), + metadata("web.hack", ":8443"), + ] { + assert_eq!( + fingerprint(BASE.as_bytes(), normalized.as_bytes()), + Some(expected.clone()) + ); + } + let list = |url: &str| { + format!( + "services: {{web: {{environment: ['TOKEN=literal', 'HACK_DEV_URL={url}', HACK_SERVICE_NAME], labels: ['caddy=a.hack', 'caddy.reverse_proxy={{{{upstreams 80}}}}', 'caddy.tls=internal']}}}}" + ) + }; + let plain = list("https://a.hack"); + assert_eq!( + fingerprint(plain.as_bytes(), plain.as_bytes()), + fingerprint( + plain.as_bytes(), + list("https://b.hack:8443") + .replace("caddy=a.hack", "caddy=b.hack") + .as_bytes() + ) + ); + for changed in [ + metadata("web.hack", "").replace("TOKEN: literal", "TOKEN: different"), + metadata("web.hack", "").replace("HACK_BRANCH", "HACK_OTHER"), + plain.replace("TOKEN=literal", "TOKEN=different"), + ] { + assert_ne!( + fingerprint(BASE.as_bytes(), changed.as_bytes()), + Some(expected.clone()) + ); + assert_ne!( + fingerprint(plain.as_bytes(), changed.as_bytes()), + fingerprint(plain.as_bytes(), plain.as_bytes()) + ); + } + } } diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index 7867dc4a3..cbb5a74d5 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -36,11 +36,19 @@ import { normalizeNativeProfiles, } from "./native-project-run.ts"; import { readNativeSelection } from "./native-project-selection.ts"; -import { prepareNativeProjectServices } from "./native-project-start.ts"; +import { + nativeRuntimeMetadataSelected, + prepareNativeProjectServices, +} from "./native-project-start.ts"; import { invokeNativeRuntime, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; +import { + applyNativeRuntimeMetadata, + type NativeRuntimeMetadataSelection, + verifyNativeRuntimeMetadataRoutes, +} from "./native-runtime-metadata.ts"; const DEFAULTS = { prepare: prepareNativeProjectInput, @@ -312,10 +320,17 @@ export async function preflightNativeRestart(opts: { readonly adaptationFile?: string; readonly dependencyFile?: string; readonly allowedHosts?: readonly string[]; + /** Same public metadata selection the replacement startup will review. */ + readonly runtimeMetadata?: NativeRuntimeMetadataSelection; + readonly httpsPort?: number | null; readonly dependencies?: Partial; }) { const deps = { ...DEFAULTS, ...opts.dependencies }; const selected = nativeRestartSelection({ run: opts.run }); + const runtimeMetadata = nativeRuntimeMetadataSelected({ + selection: opts.runtimeMetadata, + restore: opts.run, + }); const stoppedRetry = await stoppedRestartMode({ ...opts, invoke: deps.invoke, @@ -363,6 +378,15 @@ export async function preflightNativeRestart(opts: { if (selected.aws) { input = (await deps.aws({ input, ...selected.aws })).input; } + const metadata = runtimeMetadata + ? applyNativeRuntimeMetadata({ + input, + selection: runtimeMetadata, + branch: reviewIdentity ? reviewIdentity.branch : opts.scope.branch, + httpsPort: opts.httpsPort ?? null, + }) + : undefined; + input = metadata?.input ?? input; const specs = prepareNativeProjectServices( input, opts.dependencyFile !== undefined @@ -429,6 +453,12 @@ export async function preflightNativeRestart(opts: { "Native restart configuration changed; the current graph was not stopped." ); } + if (metadata) { + verifyNativeRuntimeMetadataRoutes({ + plan: review.report.plan, + routes: metadata.routes, + }); + } if (nativeReviewNeedsCompatibility({ saved: opts.run, review })) { await verifyNativeSourceCompatibility({ runtime: opts.runtime, diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index c2825cbe7..d79af2b4d 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -42,6 +42,11 @@ export type NativeProjectRun = { readonly profiles?: readonly string[]; /** Public startup selector only; null means no AWS adaptation. Never credentials. */ readonly aws?: { readonly profile: string; readonly region?: string } | null; + /** + * Present when startup reviewed public runtime host metadata into this graph. + * Retained restores must add it again; absent legacy graphs never gain it. + */ + readonly runtimeMetadata?: true; }; export type NativeProjectRunScope = { readonly projectRoot: string; @@ -109,7 +114,10 @@ function valid(value: unknown): value is NativeProjectRun { "effectiveEnvName,namespace,owner,planId,run", "aws,effectiveEnvName,namespace,owner,planId,run", "aws,effectiveEnvName,namespace,owner,planId,profiles,run", + "aws,effectiveEnvName,namespace,owner,planId,profiles,run,runtimeMetadata", ].includes(Object.keys(value).sort().join()) && + (!Object.hasOwn(value, "runtimeMetadata") || + value.runtimeMetadata === true) && (!Object.hasOwn(value, "aws") || validAws(value.aws)) && (!Object.hasOwn(value, "profiles") || validProfiles(value.profiles)) && (!Object.hasOwn(value, "effectiveEnvName") || diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 1d85a18fe..80efe6a81 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -74,6 +74,11 @@ import { invokeNativeRuntime, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; +import { + applyNativeRuntimeMetadata, + type NativeRuntimeMetadataSelection, + verifyNativeRuntimeMetadataRoutes, +} from "./native-runtime-metadata.ts"; const SHA = /^[a-f0-9]{64}$/; const OWNER = /^[a-f0-9]{32}$/; @@ -716,6 +721,7 @@ function selectedMapping(opts: { readonly effectiveEnvName: string | null; readonly profiles: readonly string[]; readonly aws?: { readonly profile: string; readonly region?: string }; + readonly runtimeMetadata: boolean; }): NativeProjectRun { return { ...opts.ready, @@ -727,8 +733,28 @@ function selectedMapping(opts: { ...(opts.aws.region ? { region: opts.aws.region } : {}), } : null, + ...(opts.runtimeMetadata ? { runtimeMetadata: true as const } : {}), }; } +/** + * Fresh graphs receive runtime host metadata when the caller selects it. A + * retained graph is restored exactly as admitted: only graphs that recorded + * metadata get it again, so legacy retained data stays restorable. + */ +export function nativeRuntimeMetadataSelected(opts: { + readonly selection?: NativeRuntimeMetadataSelection; + readonly restore?: NativeProjectRun; +}): NativeRuntimeMetadataSelection | undefined { + if (opts.restore && opts.restore.runtimeMetadata !== true) { + return undefined; + } + if (opts.restore && !opts.selection) { + throw new Error( + "Native retained graph requires its runtime metadata selection; no hooks, runtime start or HTTPS owner were requested." + ); + } + return opts.selection; +} async function retireRemovedMapping(opts: { readonly final: unknown; readonly mapping: NativeProjectRun | undefined; @@ -809,6 +835,8 @@ export async function startNativeProject(opts: { readonly preparedBase?: NativePreparedBase; readonly https?: NativeHttpsSelection; readonly aws?: { readonly profile: string; readonly region?: string }; + /** Public instance identity delivered as runtime host metadata; omitted delivers none. */ + readonly runtimeMetadata?: NativeRuntimeMetadataSelection; readonly before: (input: NativeProjectInput) => Promise; readonly signal?: AbortSignal; readonly dependencies?: Partial; @@ -838,6 +866,10 @@ export async function startNativeProject(opts: { signal: opts.signal, }); requireActiveStartup(opts.signal); + const runtimeMetadata = nativeRuntimeMetadataSelected({ + selection: opts.runtimeMetadata, + restore, + }); if (retained) { await requireAcknowledgedRetainedFinalization({ scope: opts.scope, @@ -1016,6 +1048,15 @@ export async function startNativeProject(opts: { }); input = reviewedBranch.input; const reviewIdentity = reviewedBranch.identity; + const metadata = runtimeMetadata + ? applyNativeRuntimeMetadata({ + input, + selection: runtimeMetadata, + branch: reviewIdentity ? reviewIdentity.branch : opts.scope.branch, + httpsPort: opts.https?.httpsPort ?? null, + }) + : undefined; + input = metadata?.input ?? input; requireActiveStartup(controller.signal); specs = prepareNativeProjectServices( input, @@ -1055,6 +1096,12 @@ export async function startNativeProject(opts: { input: pinned, run: async (review) => { requireEnrollmentCompatible(review.report.plan); + if (metadata) { + verifyNativeRuntimeMetadataRoutes({ + plan: review.report.plan, + routes: metadata.routes, + }); + } const routes = reviewedNativeRoutes({ plan: review.report.plan, specs, @@ -1172,6 +1219,7 @@ export async function startNativeProject(opts: { effectiveEnvName: input.effectiveEnvName, profiles, aws: selection.aws, + runtimeMetadata: metadata !== undefined, }); // Enrollment must exist before taking a shared lease: release // requires independent proof of this exact graph's cleanup. diff --git a/src/backends/native-runtime-metadata.ts b/src/backends/native-runtime-metadata.ts new file mode 100644 index 000000000..e110da36b --- /dev/null +++ b/src/backends/native-runtime-metadata.ts @@ -0,0 +1,175 @@ +import { isRecord } from "../lib/guards.ts"; +import { + buildRuntimeHostMetadataEnvironments, + type RuntimeHostMetadataService, + readCaddyLabel, + readDeclaredEnvironmentKeys, + runtimeHostMetadataComposeProject, +} from "../lib/runtime-host-metadata.ts"; +import type { NativeProjectInput } from "./native-project-input.ts"; + +const LIMIT = 256 * 1024; + +/** Public instance identity for native runtime host metadata. Never secret values. */ +export type NativeRuntimeMetadataSelection = { + /** Configured base development host, before branch rewriting. */ + readonly devHost: string; + /** Configured base OAuth alias host, or null when the alias is disabled. */ + readonly aliasHost: string | null; + /** Base Compose project name; branch instances report `--`. */ + readonly projectName: string; +}; + +export type NativeRuntimeMetadata = { + /** Input whose normalized Compose carries the metadata as public environment. */ + readonly input: NativeProjectInput; + /** Normalized route hostnames per routed service; review must report the same set. */ + readonly routes: ReadonlyMap; +}; + +function refused(): Error { + return new Error( + "Native runtime metadata requires reviewable services whose route labels match the reviewed plan; no graph was started." + ); +} + +/** Mirror native review's hostname normalization: trailing dot removed, lowercase. */ +function routeHosts(labels: unknown): string[] { + const caddy = readCaddyLabel({ labels }); + if (caddy === null) { + return []; + } + const hosts: string[] = []; + for (const part of caddy.split(",")) { + const trimmed = part.trim(); + const host = ( + trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed + ).toLowerCase(); + if (host && !hosts.includes(host)) { + hosts.push(host); + } + } + return hosts; +} + +/** Compose interpolation treats `$` specially; `$$` is its literal escape. */ +function literal(value: string): string { + return value.replaceAll("$", () => "$$"); +} + +/** + * Add the same runtime host metadata the Compose backend generates to the exact + * normalized Compose native review will see, as public (non-secret) environment. + * + * The values are derived from the route labels native review turns into its + * route plan, the effective branch and the native HTTPS port; callers must check + * the returned routes against the reviewed plan with + * {@link verifyNativeRuntimeMetadataRoutes} before starting anything. Keys a + * service already declares, including Hack-managed project values (bare keys), + * are left untouched, so explicit project values win. Call this after branch + * rewriting and every other normalization so the reviewed bytes are final. + */ +export function applyNativeRuntimeMetadata(opts: { + readonly input: NativeProjectInput; + readonly selection: NativeRuntimeMetadataSelection; + readonly branch: string | null; + /** Native HTTPS frontend port; null when no frontend is selected. */ + readonly httpsPort: number | null; +}): NativeRuntimeMetadata { + const compose: unknown = JSON.parse(opts.input.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw refused(); + } + const services = new Map(); + const routes = new Map(); + const records = new Map>(); + for (const [name, service] of Object.entries(compose.services)) { + if (!isRecord(service)) { + throw refused(); + } + const hosts = routeHosts(service.labels); + if (hosts.length > 0) { + routes.set(name, hosts); + } + records.set(name, service); + services.set(name, { + hosts, + declaredEnvironmentKeys: readDeclaredEnvironmentKeys({ + environment: service.environment, + }), + }); + } + const environments = buildRuntimeHostMetadataEnvironments({ + services, + branch: opts.branch, + devHost: opts.selection.devHost, + aliasHost: opts.selection.aliasHost, + composeProject: runtimeHostMetadataComposeProject({ + projectName: opts.selection.projectName, + branch: opts.branch, + }), + httpsPort: opts.httpsPort, + }); + for (const [name, values] of environments) { + const service = records.get(name); + if (!service) { + throw refused(); + } + const entries = Object.entries(values).map( + ([key, value]) => [key, literal(value)] as const + ); + if (service.environment === undefined || service.environment === null) { + service.environment = Object.fromEntries(entries); + } else if (isRecord(service.environment)) { + Object.assign(service.environment, Object.fromEntries(entries)); + } else if (Array.isArray(service.environment)) { + service.environment.push( + ...entries.map(([key, value]) => `${key}=${value}`) + ); + } else { + throw refused(); + } + } + const normalizedComposeJson = JSON.stringify(compose); + if (Buffer.byteLength(normalizedComposeJson) > LIMIT) { + throw refused(); + } + return { input: { ...opts.input, normalizedComposeJson }, routes }; +} + +/** + * Refuse unless every active service's reviewed route hostnames are exactly the + * hostnames its runtime metadata advertises. This keeps the delivered URLs bound + * to the reviewed route plan rather than to a client-side label reading. + */ +export function verifyNativeRuntimeMetadataRoutes(opts: { + readonly plan: unknown; + readonly routes: ReadonlyMap; +}): void { + if (!(isRecord(opts.plan) && isRecord(opts.plan.services))) { + throw refused(); + } + for (const [name, service] of Object.entries(opts.plan.services)) { + if (!isRecord(service)) { + throw refused(); + } + if (service.active !== true) { + continue; + } + const routing = service.routing; + const reviewed: unknown[] = + isRecord(routing) && Array.isArray(routing.hostnames) + ? routing.hostnames + : []; + if (!reviewed.every((host) => typeof host === "string")) { + throw refused(); + } + const expected = [...(opts.routes.get(name) ?? [])].sort(); + if ( + JSON.stringify([...(reviewed as string[])].sort()) !== + JSON.stringify(expected) + ) { + throw refused(); + } + } +} diff --git a/src/commands/project.ts b/src/commands/project.ts index d8d967637..186a3befa 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -4908,6 +4908,7 @@ async function handleNativeUp({ projectName, branch, }); + const devHost = await resolveRuntimeDevHost({ project, branch }); const startup: Parameters[0] = { runtime: native, scope: { @@ -4931,6 +4932,11 @@ async function handleNativeUp({ region: process.env.HACK_NATIVE_AWS_REGION, } : undefined, + runtimeMetadata: { + devHost, + aliasHost: resolveBranchAliasHost({ devHost, cfg }), + projectName, + }, before: async (input) => { const lifecycle = await runLifecycleUpBeforeAndProcesses({ title: "Lifecycle (native up before)", @@ -5024,6 +5030,8 @@ async function handleNativeUp({ adaptationFile: startup.adaptationFile, dependencyFile: startup.dependencyFile, allowedHosts: startup.allowedHosts, + runtimeMetadata: startup.runtimeMetadata, + httpsPort: startup.https?.httpsPort ?? null, run, cleanedRetry, recoverStopped: recovery !== undefined, diff --git a/src/lib/runtime-host-metadata.ts b/src/lib/runtime-host-metadata.ts index 7badd1adc..140447c8a 100644 --- a/src/lib/runtime-host-metadata.ts +++ b/src/lib/runtime-host-metadata.ts @@ -29,6 +29,43 @@ export type RuntimeHostMetadata = { >; }; +/** One service's public route hosts and the environment keys its author already owns. */ +export type RuntimeHostMetadataService = { + /** Effective public hostnames in declaration order; empty when unrouted. */ + readonly hosts: readonly string[]; + readonly declaredEnvironmentKeys: ReadonlySet; +}; + +/** Every environment key Hack reserves for runtime host metadata. */ +export const RUNTIME_HOST_METADATA_KEY_NAMES: readonly string[] = Object.values( + RUNTIME_HOST_METADATA_KEYS +); + +/** Compose project name reported for an instance; branches use `--`. */ +export function runtimeHostMetadataComposeProject(opts: { + readonly projectName: string; + readonly branch: string | null; +}): string { + return opts.branch ? `${opts.projectName}--${opts.branch}` : opts.projectName; +} + +/** + * Browser-facing HTTPS URL for a public host. The default HTTPS port stays + * implicit; any other ingress port (such as a native HTTPS frontend) is explicit. + */ +export function runtimeHostUrl(opts: { + readonly host: string; + readonly httpsPort?: number | null; +}): string { + const port = + opts.httpsPort === undefined || + opts.httpsPort === null || + opts.httpsPort === 443 + ? "" + : `:${opts.httpsPort}`; + return `https://${opts.host}${port}`; +} + /** * Builds the generated Compose fragment that exposes non-secret runtime * identity to every service. Keys already declared by a service are omitted; @@ -48,6 +85,51 @@ export function buildRuntimeHostMetadataOverride(opts: { return null; } + const environments = buildRuntimeHostMetadataEnvironments({ + services: new Map( + [...effectiveServices].map(([serviceName, service]) => [ + serviceName, + { + hosts: service.caddy + ? extractCaddyHosts({ value: service.caddy }) + : [], + declaredEnvironmentKeys: service.declaredEnvironmentKeys, + }, + ]) + ), + branch: opts.branch, + devHost: opts.devHost, + aliasHost: opts.aliasHost, + composeProject: opts.composeProject, + }); + if (environments.size === 0) { + return null; + } + const services: Record }> = {}; + for (const [serviceName, environment] of environments) { + services[serviceName] = { environment }; + } + return ensureTrailingNewline( + cleanupYaml(YAML.stringify({ services }, null, 2)) + ); +} + +/** + * Computes the non-secret runtime identity each service receives. The Compose + * override and the native backend both use it, so both emit the same keys and + * document shape. Keys a service already declares are omitted (explicit project + * values win) and services left without keys are absent from the result. The + * iteration order of `services` defines the metadata document's service order. + */ +export function buildRuntimeHostMetadataEnvironments(opts: { + readonly services: ReadonlyMap; + readonly branch: string | null; + readonly devHost: string; + readonly aliasHost: string | null; + readonly composeProject: string; + /** Public HTTPS ingress port; omitted, null or 443 keeps URLs portless. */ + readonly httpsPort?: number | null; +}): Map> { const baseHosts = [opts.devHost, opts.aliasHost].filter( (host): host is string => typeof host === "string" && host.length > 0 ); @@ -63,12 +145,12 @@ export function buildRuntimeHostMetadataOverride(opts: { baseHosts, }) : null; + const url = (host: string) => + runtimeHostUrl({ host, httpsPort: opts.httpsPort }); const publicServices: Record = {}; - for (const [serviceName, service] of effectiveServices) { - const urls = resolveServiceUrls({ - caddy: service.caddy, - }); + for (const [serviceName, service] of opts.services) { + const urls = resolveServiceUrls({ hosts: service.hosts, url }); if (urls.length > 0) { publicServices[serviceName] = { urls }; } @@ -86,20 +168,19 @@ export function buildRuntimeHostMetadataOverride(opts: { const sharedMetadata: Record = { [RUNTIME_HOST_METADATA_KEYS.branch]: opts.branch ?? "", [RUNTIME_HOST_METADATA_KEYS.devHost]: effectiveDevHost, - [RUNTIME_HOST_METADATA_KEYS.devUrl]: `https://${effectiveDevHost}`, + [RUNTIME_HOST_METADATA_KEYS.devUrl]: url(effectiveDevHost), [RUNTIME_HOST_METADATA_KEYS.composeProject]: opts.composeProject, [RUNTIME_HOST_METADATA_KEYS.runtimeMetadata]: JSON.stringify(runtimeMetadata), ...(effectiveAliasHost ? { [RUNTIME_HOST_METADATA_KEYS.aliasHost]: effectiveAliasHost, - [RUNTIME_HOST_METADATA_KEYS.aliasUrl]: `https://${effectiveAliasHost}`, + [RUNTIME_HOST_METADATA_KEYS.aliasUrl]: url(effectiveAliasHost), } : {}), }; - const services: Record }> = {}; - - for (const [serviceName, service] of effectiveServices) { + const environments = new Map>(); + for (const [serviceName, service] of opts.services) { const urls = publicServices[serviceName]?.urls ?? []; const metadata: Record = { ...sharedMetadata, @@ -113,16 +194,10 @@ export function buildRuntimeHostMetadataOverride(opts: { ) ); if (Object.keys(environment).length > 0) { - services[serviceName] = { environment }; + environments.set(serviceName, environment); } } - - if (Object.keys(services).length === 0) { - return null; - } - return ensureTrailingNewline( - cleanupYaml(YAML.stringify({ services }, null, 2)) - ); + return environments; } function parseComposeYaml(opts: { @@ -188,16 +263,13 @@ function applyBranchIfNeeded(opts: { } function resolveServiceUrls(opts: { - readonly caddy: string | null; + readonly hosts: readonly string[]; + readonly url: (host: string) => string; }): readonly string[] { - if (!opts.caddy) { - return []; - } - const urls: string[] = []; const seen = new Set(); - for (const host of extractCaddyHosts({ value: opts.caddy })) { - const url = `https://${host}`; + for (const host of opts.hosts) { + const url = opts.url(host); if (!seen.has(url)) { seen.add(url); urls.push(url); @@ -206,7 +278,10 @@ function resolveServiceUrls(opts: { return urls; } -function readCaddyLabel(opts: { readonly labels: unknown }): string | null { +/** The public `caddy` route label value from map or list Compose labels. */ +export function readCaddyLabel(opts: { + readonly labels: unknown; +}): string | null { if (isRecord(opts.labels)) { const value = opts.labels.caddy; return typeof value === "string" && value.trim().length > 0 ? value : null; @@ -256,7 +331,8 @@ function extractCaddyHosts(opts: { return hosts; } -function readDeclaredEnvironmentKeys(opts: { +/** Environment keys a Compose service declares, from map or list form. */ +export function readDeclaredEnvironmentKeys(opts: { readonly environment: unknown; }): Set { if (isRecord(opts.environment)) { diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 710df0980..8f8d9e8c7 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -2192,6 +2192,60 @@ test("startup prepares mapping storage before input and plan review", async () = expect(await startNativeProject(opts)).toBe(0); }); +/** Read-only native answers for a branch restart preflight of the run 1…1. */ +async function restartPreflightInvoke({ + args, +}: { + readonly args: readonly string[]; +}): Promise { + if (args[0] === "project") { + expect(args).toContain("--branch"); + return { + plan: { namespace: "b".repeat(64), compose_sha256: "d".repeat(64) }, + }; + } + if (args[1] === "status") { + return { network: "internet" }; + } + if (args[1] === "probe") { + return { admitted: true }; + } + if (args[1] === "inspect") { + return { + journal_incomplete: false, + receipt: { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + plan_id: "a".repeat(64), + phase: "ready-observed", + resources: { + "container:web": { + kind: "container", + key: "web", + id: "f".repeat(64), + }, + }, + }, + }; + } + if (args[1] === "run-selection") { + expect(args[args.indexOf("--service") + 1]).toBe("web"); + return { + ok: true, + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + plan: "a".repeat(64), + service: "web", + container: "f".repeat(64), + boot: "owned-boot", + generation: "e".repeat(64), + }; + } + throw new Error("unexpected preflight effect"); +} + test("startup and restart review use identical branch routes after adaptation", async () => { const { opts } = await fixture(false); const prepare = opts.dependencies.prepare!; @@ -2253,54 +2307,7 @@ test("startup and restart review use identical branch routes after adaptation", prepare: opts.dependencies.prepare, review: opts.dependencies.review, dependencies: async () => [], - invoke: async ({ args }) => { - if (args[0] === "project") { - expect(args).toContain("--branch"); - return { - plan: { namespace: "b".repeat(64), compose_sha256: "d".repeat(64) }, - }; - } - if (args[1] === "status") { - return { network: "internet" }; - } - if (args[1] === "probe") { - return { admitted: true }; - } - if (args[1] === "inspect") { - return { - journal_incomplete: false, - receipt: { - run: "1".repeat(32), - owner: "c".repeat(32), - namespace: "b".repeat(64), - plan_id: "a".repeat(64), - phase: "ready-observed", - resources: { - "container:web": { - kind: "container", - key: "web", - id: "f".repeat(64), - }, - }, - }, - }; - } - if (args[1] === "run-selection") { - expect(args[args.indexOf("--service") + 1]).toBe("web"); - return { - ok: true, - run: "1".repeat(32), - owner: "c".repeat(32), - namespace: "b".repeat(64), - plan: "a".repeat(64), - service: "web", - container: "f".repeat(64), - boot: "owned-boot", - generation: "e".repeat(64), - }; - } - throw new Error("unexpected preflight effect"); - }, + invoke: restartPreflightInvoke, }, }); expect(observed).toHaveLength(2); @@ -2313,6 +2320,72 @@ test("startup and restart review use identical branch routes after adaptation", ).toBe("feature-a.app.hack.local, feature-a.app.hack.gy"); }); +test("startup and restart review identical branch runtime metadata; legacy runs get none", async () => { + const { opts } = await fixture(false); + const review = opts.dependencies.review!; + const scope = { ...opts.scope, branch: "feature-a" }; + await writeFile(opts.composeFile, "services: {}\n"); + await writeFile( + join(scope.projectDir, "hack.config.json"), + JSON.stringify({ dev_host: "app.hack.local" }) + ); + const selection = { + devHost: "app.hack.local", + aliasHost: null, + projectName: "app", + }; + const observed: string[] = []; + opts.dependencies.review = async (request) => { + observed.push(request.input.normalizedComposeJson); + return await review(request); + }; + expect( + await startNativeProject({ + ...opts, + scope, + https: httpsSelection, + runtimeMetadata: selection, + }) + ).toBe(0); + const run: NativeProjectRun = { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + }; + for (const recorded of [{ ...run, runtimeMetadata: true as const }, run]) { + await preflightNativeRestart({ + runtime: opts.runtime, + scope, + composeFile: opts.composeFile, + runtimeMetadata: selection, + httpsPort: httpsSelection.httpsPort, + run: recorded, + dependencies: { + prepare: opts.dependencies.prepare, + review: opts.dependencies.review, + dependencies: async () => [], + invoke: restartPreflightInvoke, + }, + }); + } + expect(observed).toHaveLength(3); + expect(observed[1]).toBe(observed[0]); + const web = JSON.parse(observed[0] ?? "{}").services.web.environment; + expect(web).toMatchObject({ + HACK_BRANCH: "feature-a", + HACK_COMPOSE_PROJECT: "app--feature-a", + HACK_DEV_HOST: "feature-a.app.hack.local", + HACK_DEV_URL: "https://feature-a.app.hack.local:8443", + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URLS: "[]", + }); + expect(observed[2]).not.toContain("HACK_"); +}); + test("retained startup selects legacy labels only after runtime admission and before review", async () => { for (const changed of [false, true]) { const { opts } = await fixture(false); @@ -2590,3 +2663,100 @@ test("later graph, temporary, hook and acknowledgement failures cannot mask HTTP expect(events).not.toContain("ready"); } }); + +const runtimeMetadata = { + devHost: "example.com", + aliasHost: null, + projectName: "demo", +}; +function stubHttpsLease( + opts: Awaited>["opts"] +) { + opts.dependencies.https = async (selection) => ({ + identity: leaseIdentity(selection), + caPath: "/synthetic/root.crt", + httpsPort: 8443, + exited: new Promise(() => {}), + verifyHostname: async () => ({ statusCode: 200 }), + releaseAfterCleanup: async () => {}, + }); +} +test("native up reviews runtime host metadata with the HTTPS port and records it", async () => { + const { opts } = await httpsFixture(); + stubHttpsLease(opts); + const review = opts.dependencies.review!; + let reviewed: Record | undefined; + opts.dependencies.review = async (request) => { + reviewed = JSON.parse(request.input.normalizedComposeJson).services.web + .environment; + return await review(request); + }; + let saved: NativeProjectRun | undefined; + opts.dependencies.save = async ({ run }) => { + saved = run; + }; + expect( + await startNativeProject({ + ...opts, + https: httpsSelection, + runtimeMetadata, + }) + ).toBe(0); + expect(reviewed).toMatchObject({ + HACK_BRANCH: "", + HACK_COMPOSE_PROJECT: "demo", + HACK_DEV_HOST: "example.com", + HACK_DEV_URL: "https://example.com:8443", + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URL: "https://web.example.com:8443", + HACK_SERVICE_URLS: JSON.stringify(["https://web.example.com:8443"]), + }); + expect(JSON.parse(String(reviewed?.HACK_RUNTIME_METADATA))).toEqual({ + version: 1, + branch: null, + composeProject: "demo", + hosts: { dev: "example.com", alias: null }, + services: { web: { urls: ["https://web.example.com:8443"] } }, + }); + expect(saved?.runtimeMetadata).toBe(true); +}); +test("runtime host metadata that differs from reviewed routes refuses before serving", async () => { + const { opts, events } = await httpsFixture(); + stubHttpsLease(opts); + const prepare = opts.dependencies.prepare!; + opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + return { + ...input, + normalizedComposeJson: input.normalizedComposeJson.replace( + "web.example.com", + "other.example.com" + ), + }; + }; + opts.dependencies.serve = async () => { + events.push("serve"); + return 0; + }; + await expect( + startNativeProject({ ...opts, https: httpsSelection, runtimeMetadata }) + ).rejects.toThrow("Native runtime metadata"); + expect(events).not.toContain("serve"); + expect(events).toContain("cleanup"); +}); +test("startup without a runtime metadata selection leaves the reviewed graph unchanged", async () => { + const { opts } = await fixture(false); + const review = opts.dependencies.review!; + let reviewed = ""; + opts.dependencies.review = async (request) => { + reviewed = request.input.normalizedComposeJson; + return await review(request); + }; + let saved: NativeProjectRun | undefined; + opts.dependencies.save = async ({ run }) => { + saved = run; + }; + expect(await startNativeProject(opts)).toBe(0); + expect(reviewed).not.toContain("HACK_"); + expect(saved && Object.hasOwn(saved, "runtimeMetadata")).toBe(false); +}); diff --git a/tests/native-runtime-metadata.test.ts b/tests/native-runtime-metadata.test.ts new file mode 100644 index 000000000..3022b4508 --- /dev/null +++ b/tests/native-runtime-metadata.test.ts @@ -0,0 +1,348 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { YAML } from "bun"; +import { prepareNativeProjectBranch } from "../src/backends/native-project-branch.ts"; +import { + type NativeProjectInput, + prepareNativeProjectInput, +} from "../src/backends/native-project-input.ts"; +import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; +import { nativeRuntimeMetadataSelected } from "../src/backends/native-project-start.ts"; +import { + applyNativeRuntimeMetadata, + verifyNativeRuntimeMetadataRoutes, +} from "../src/backends/native-runtime-metadata.ts"; +import { isRecord } from "../src/lib/guards.ts"; +import { + buildRuntimeHostMetadataOverride, + type RuntimeHostMetadata, +} from "../src/lib/runtime-host-metadata.ts"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +const SELECTION = { + devHost: "app.hack.local", + aliasHost: "app.hack.gy", + projectName: "app", +} as const; + +function input(services: Record): NativeProjectInput { + return { + originalSha256: "d".repeat(64), + normalizedComposeJson: JSON.stringify({ services }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: null, + environmentFiles: [], + serviceNames: Object.keys(services), + }; +} + +function environment( + result: NativeProjectInput, + service: string +): Record { + const compose: unknown = JSON.parse(result.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw new Error("missing services"); + } + const selected = compose.services[service]; + if (!(isRecord(selected) && isRecord(selected.environment))) { + throw new Error(`missing ${service} environment`); + } + return selected.environment; +} + +function metadata(env: Record): RuntimeHostMetadata { + return JSON.parse(String(env.HACK_RUNTIME_METADATA)) as RuntimeHostMetadata; +} + +async function project() { + const root = await mkdtemp(join(tmpdir(), "native-runtime-metadata-")); + roots.push(root); + const projectDir = join(root, ".hack"); + await mkdir(projectDir); + const composeFile = join(projectDir, "docker-compose.yml"); + await writeFile( + composeFile, + JSON.stringify({ + services: { + web: { + image: "example", + environment: ["KEEP=public"], + labels: { + caddy: "app.hack.local, app.hack.gy", + "caddy.tls": "internal", + "caddy.reverse_proxy": "{{upstreams 3000}}", + }, + }, + api: { + image: "example", + environment: { HACK_SERVICE_URL: "author-owned" }, + labels: { + caddy: "api.app.hack.local", + "caddy.tls": "internal", + "caddy.reverse_proxy": "{{upstreams 4000}}", + }, + }, + worker: { image: "example" }, + }, + }) + ); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ dev_host: "app.hack.local", oauth: { enabled: true } }) + ); + await writeFile( + join(projectDir, "hack.env.default.yaml"), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { global: { HACK_DEV_URL: "https://project-owned.example" } }, + }) + ); + return { root, projectDir, composeFile }; +} + +test("native up input for a branch instance carries branch metadata with the HTTPS port", async () => { + const f = await project(); + const scope = { + projectRoot: f.root, + projectDir: f.projectDir, + nativeHome: join(f.root, "unused"), + branch: "feature-a", + }; + const branched = await prepareNativeProjectBranch({ + input: await prepareNativeProjectInput({ + projectRoot: f.root, + projectDir: f.projectDir, + composeFile: f.composeFile, + }), + composeFile: f.composeFile, + scope, + }); + const result = applyNativeRuntimeMetadata({ + input: branched, + selection: SELECTION, + branch: "feature-a", + httpsPort: 8443, + }); + + const web = environment(result.input, "web"); + expect(web).toMatchObject({ + KEEP: "public", + HACK_BRANCH: "feature-a", + HACK_COMPOSE_PROJECT: "app--feature-a", + HACK_DEV_HOST: "feature-a.app.hack.local", + HACK_ALIAS_HOST: "feature-a.app.hack.gy", + HACK_ALIAS_URL: "https://feature-a.app.hack.gy:8443", + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URL: "https://feature-a.app.hack.local:8443", + HACK_SERVICE_URLS: JSON.stringify([ + "https://feature-a.app.hack.local:8443", + "https://feature-a.app.hack.gy:8443", + ]), + }); + // Explicit project values win: the managed key stays a private declaration. + expect(web.HACK_DEV_URL).toBeNull(); + expect(result.input.managedEnvironment.web?.HACK_DEV_URL).toBe( + "https://project-owned.example" + ); + expect(result.input.managedEnvironment).toBe(branched.managedEnvironment); + expect(result.input.normalizedComposeJson).not.toContain( + "project-owned.example" + ); + expect(result.input.originalSha256).toBe(branched.originalSha256); + + const api = environment(result.input, "api"); + expect(api.HACK_SERVICE_URL).toBe("author-owned"); + expect(api.HACK_SERVICE_URLS).toBe( + JSON.stringify(["https://api.feature-a.app.hack.local:8443"]) + ); + const worker = environment(result.input, "worker"); + expect(worker.HACK_SERVICE_URL).toBeUndefined(); + expect(worker.HACK_SERVICE_URLS).toBe("[]"); + expect(metadata(worker)).toEqual({ + version: 1, + branch: "feature-a", + composeProject: "app--feature-a", + hosts: { dev: "feature-a.app.hack.local", alias: "feature-a.app.hack.gy" }, + services: { + web: { + urls: [ + "https://feature-a.app.hack.local:8443", + "https://feature-a.app.hack.gy:8443", + ], + }, + api: { urls: ["https://api.feature-a.app.hack.local:8443"] }, + }, + }); + expect(result.routes).toEqual( + new Map([ + ["web", ["feature-a.app.hack.local", "feature-a.app.hack.gy"]], + ["api", ["api.feature-a.app.hack.local"]], + ]) + ); +}); + +test("base instance metadata has an empty branch and portless default HTTPS URLs", () => { + for (const httpsPort of [null, 443]) { + const result = applyNativeRuntimeMetadata({ + input: input({ + web: { + image: "example", + labels: ["caddy=App.Hack.Local.", "caddy.tls=internal"], + }, + }), + selection: { ...SELECTION, aliasHost: null }, + branch: null, + httpsPort, + }); + const web = environment(result.input, "web"); + expect(web).toEqual({ + HACK_BRANCH: "", + HACK_DEV_HOST: "app.hack.local", + HACK_DEV_URL: "https://app.hack.local", + HACK_COMPOSE_PROJECT: "app", + HACK_RUNTIME_METADATA: JSON.stringify({ + version: 1, + branch: null, + composeProject: "app", + hosts: { dev: "app.hack.local", alias: null }, + services: { web: { urls: ["https://app.hack.local"] } }, + }), + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URLS: JSON.stringify(["https://app.hack.local"]), + HACK_SERVICE_URL: "https://app.hack.local", + }); + expect(web.HACK_ALIAS_HOST).toBeUndefined(); + } +}); + +test("list environments keep their shape and literal values are escaped for interpolation", () => { + const result = applyNativeRuntimeMetadata({ + input: input({ + web: { image: "example", environment: ["KEEP=public", "HACK_BRANCH"] }, + }), + selection: { + devHost: "app.hack.local", + aliasHost: null, + projectName: "a$b", + }, + branch: null, + httpsPort: null, + }); + const compose = JSON.parse(result.input.normalizedComposeJson); + const entries: string[] = compose.services.web.environment; + expect(entries.slice(0, 2)).toEqual(["KEEP=public", "HACK_BRANCH"]); + expect(entries).toContain("HACK_COMPOSE_PROJECT=a$$b"); + expect(entries.filter((entry) => entry.startsWith("HACK_BRANCH"))).toEqual([ + "HACK_BRANCH", + ]); +}); + +test("native metadata matches the Compose backend's keys and values", () => { + const services = { + web: { + image: "example", + environment: { HACK_SERVICE_URL: "author-owned" }, + labels: { caddy: "app.hack.local, app.hack.gy" }, + }, + worker: { image: "example", environment: ["KEEP=public"] }, + }; + const compose = buildRuntimeHostMetadataOverride({ + composeYamls: [JSON.stringify({ services })], + branch: null, + devHost: SELECTION.devHost, + aliasHost: SELECTION.aliasHost, + composeProject: SELECTION.projectName, + }); + const parsed = YAML.parse(compose ?? "") as { + services: Record }>; + }; + const native = applyNativeRuntimeMetadata({ + input: input(services), + selection: SELECTION, + branch: null, + httpsPort: null, + }); + expect(environment(native.input, "web")).toEqual({ + HACK_SERVICE_URL: "author-owned", + ...parsed.services.web?.environment, + }); + const worker = JSON.parse(native.input.normalizedComposeJson).services.worker + .environment as string[]; + expect(worker).toEqual([ + "KEEP=public", + ...Object.entries(parsed.services.worker?.environment ?? {}).map( + ([key, value]) => `${key}=${value}` + ), + ]); +}); + +test("reviewed routes must match the advertised hostnames for active services", () => { + const routes = new Map([["web", ["b.hack", "a.hack"]]]); + const plan = (hostnames: unknown, active = true) => ({ + services: { + web: { active, routing: { hostnames, port: 3000 } }, + worker: { active: true, routing: null }, + }, + }); + verifyNativeRuntimeMetadataRoutes({ + plan: plan(["a.hack", "b.hack"]), + routes, + }); + verifyNativeRuntimeMetadataRoutes({ + plan: plan(["other.hack"], false), + routes, + }); + for (const changed of [["a.hack"], ["a.hack", "c.hack"], [1], undefined]) { + expect(() => + verifyNativeRuntimeMetadataRoutes({ plan: plan(changed), routes }) + ).toThrow("Native runtime metadata"); + } + expect(() => + verifyNativeRuntimeMetadataRoutes({ + plan: { + services: { + worker: { active: true, routing: { hostnames: ["w.hack"] } }, + }, + }, + routes, + }) + ).toThrow("Native runtime metadata"); +}); + +test("retained graphs reproduce exactly the metadata selection they were admitted with", () => { + const legacy: NativeProjectRun = { + run: "1".repeat(32), + owner: "2".repeat(32), + namespace: "3".repeat(64), + planId: "4".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + }; + const current: NativeProjectRun = { ...legacy, runtimeMetadata: true }; + expect(nativeRuntimeMetadataSelected({ selection: SELECTION })).toBe( + SELECTION + ); + expect(nativeRuntimeMetadataSelected({})).toBeUndefined(); + expect( + nativeRuntimeMetadataSelected({ selection: SELECTION, restore: legacy }) + ).toBeUndefined(); + expect( + nativeRuntimeMetadataSelected({ selection: SELECTION, restore: current }) + ).toBe(SELECTION); + expect(() => nativeRuntimeMetadataSelected({ restore: current })).toThrow( + "runtime metadata selection" + ); +}); diff --git a/tests/runtime-host-metadata.test.ts b/tests/runtime-host-metadata.test.ts index b125a498a..30cdba45a 100644 --- a/tests/runtime-host-metadata.test.ts +++ b/tests/runtime-host-metadata.test.ts @@ -3,6 +3,7 @@ import { YAML } from "bun"; import { isRecord } from "../src/lib/guards.ts"; import { + buildRuntimeHostMetadataEnvironments, buildRuntimeHostMetadataOverride, type RuntimeHostMetadata, } from "../src/lib/runtime-host-metadata.ts"; @@ -110,6 +111,94 @@ test("runtime metadata uses every effective public URL for a branch instance", ( ]); }); +test("Compose runtime metadata override bytes stay portless and unchanged", () => { + const override = buildRuntimeHostMetadataOverride({ + composeYamls: [ + [ + "services:", + " web:", + " image: app", + " labels:", + ' caddy: "demo.hack"', + " worker:", + " image: app", + " environment: [HACK_BRANCH]", + "", + ].join("\n"), + ], + branch: null, + devHost: "demo.hack", + aliasHost: null, + composeProject: "demo", + }); + const document = + '"{\\"version\\":1,\\"branch\\":null,\\"composeProject\\":\\"demo\\",\\"hosts\\":{\\"dev\\":\\"demo.hack\\",\\"alias\\":null},\\"services\\":{\\"web\\":{\\"urls\\":[\\"https://demo.hack\\"]}}}"'; + expect(override).toBe( + [ + "services:", + " web:", + " environment:", + ' HACK_BRANCH: ""', + " HACK_DEV_HOST: demo.hack", + " HACK_DEV_URL: https://demo.hack", + " HACK_COMPOSE_PROJECT: demo", + ` HACK_RUNTIME_METADATA: ${document}`, + " HACK_SERVICE_NAME: web", + ' HACK_SERVICE_URLS: "[\\"https://demo.hack\\"]"', + " HACK_SERVICE_URL: https://demo.hack", + " worker:", + " environment:", + " HACK_DEV_HOST: demo.hack", + " HACK_DEV_URL: https://demo.hack", + " HACK_COMPOSE_PROJECT: demo", + ` HACK_RUNTIME_METADATA: ${document}`, + " HACK_SERVICE_NAME: worker", + ' HACK_SERVICE_URLS: "[]"', + "", + ].join("\n") + ); +}); + +test("shared metadata URLs carry a non-default HTTPS port", () => { + const services = new Map([ + [ + "web", + { + hosts: ["feature-x.demo.hack", "feature-x.demo.hack"], + declaredEnvironmentKeys: new Set(), + }, + ], + ]); + for (const [httpsPort, suffix] of [ + [undefined, ""], + [null, ""], + [443, ""], + [8443, ":8443"], + ] as const) { + const web = buildRuntimeHostMetadataEnvironments({ + services, + branch: "feature-x", + devHost: "demo.hack", + aliasHost: "demo.hack.gy", + composeProject: "demo--feature-x", + httpsPort, + }).get("web"); + expect(web).toMatchObject({ + HACK_DEV_HOST: "feature-x.demo.hack", + HACK_DEV_URL: `https://feature-x.demo.hack${suffix}`, + HACK_ALIAS_HOST: "feature-x.demo.hack.gy", + HACK_ALIAS_URL: `https://feature-x.demo.hack.gy${suffix}`, + HACK_SERVICE_URL: `https://feature-x.demo.hack${suffix}`, + HACK_SERVICE_URLS: JSON.stringify([ + `https://feature-x.demo.hack${suffix}`, + ]), + }); + expect( + readRuntimeMetadata({ environment: web ?? {} }).services.web?.urls + ).toEqual([`https://feature-x.demo.hack${suffix}`]); + } +}); + function readServiceEnvironment(opts: { readonly override: string | null; readonly service: string; From 0b5ba5929d4a4e79d06f674908cbacfd8c4db7d7 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 15:28:38 -0400 Subject: [PATCH 07/11] fix(native): make the hack.local repair recoverable and byte-exact - Rollback of a receipt whose dnsmasq.conf changed (or names another path) no longer refuses forever: after a plain confirmation it marks the receipt released and writes nothing else, so a pending receipt can always be cleared. - Keep a leading BOM when reading DNS files, so rewrites keep every byte and receipt hashes equal the raw file hashes. - Hold an exclusive lock for repair and rollback; refuse with busy. - Repair refuses with restart-unavailable before any effect; rollback restores the file even when dnsmasq cannot be restarted and says so. Automatic cleanup restarts only when something changed and reports "nothing was changed" when the failure came first. - Accept only the managed single-line form for a receipt's old line, and control-free absolute paths; diagnostics name lines by number and replace control characters. - Cover the rollback non-interactive, recheck, restore, path, lock, file owner/link and private-directory guards with focused tests. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 58 ++- src/commands/doctor.ts | 21 +- src/commands/global.ts | 14 +- src/lib/native-domain-dns-snapshot.ts | 11 +- src/lib/native-hack-local-route-host.ts | 116 +++++- src/lib/native-hack-local-route.ts | 477 +++++++++++++++++----- tests/native-hack-local-route.test.ts | 504 ++++++++++++++++++++++-- 7 files changed, 1028 insertions(+), 173 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index f911b3806..8a1c2ad0b 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1341,26 +1341,48 @@ in place. It never adds a second line for the suffix, and the `.hack` and Each unmet condition is a closed refusal reason: `relay-not-verified`, `dnsmasq-config-missing`, `no-managed-line`, `unmanaged-line`, `foreign-target`, `foreign-resolver`, `old-target-live`, `pending-receipt`, `invalid-receipt`, -`non-interactive`, `declined` or `inputs-changed`. The repair needs interactive -confirmation and the native macOS administrator prompt; without a terminal it -refuses and names `hack doctor --fix` to run in one. After the prompt it inspects -everything again and refuses if anything changed. Before changing files it -writes a private receipt to `/native-dns/hack-local-route.json` -with the old line, new line, whole-file SHA-256 hashes and timestamps. It -replaces the file atomically, restarts the verified dnsmasq launchd job, flushes -the DNS cache and verifies live resolution. If a later step fails, it restores -the earlier line automatically. `hack global dns preview --domain hack.local` -shows the same proposal without changing anything. +`non-interactive`, `declined`, `busy`, `restart-unavailable` or +`inputs-changed`. Refusal details name lines by number and never repeat file +contents. The repair needs interactive confirmation and the native macOS +administrator prompt; without a terminal it refuses and names `hack doctor --fix` +to run in one. Only one repair or rollback runs at a time; a second one refuses +with `busy`. After the prompt the repair checks that the dnsmasq launchd job can +be restarted (`restart-unavailable` otherwise), then inspects everything again +and refuses if anything changed. Before changing files it writes a private +receipt to `/native-dns/hack-local-route.json` with the old line, +new line, SHA-256 hashes of the exact file bytes and timestamps. It replaces the +file atomically and keeps every other byte, including a leading byte-order mark. +It then restarts the verified dnsmasq launchd job, flushes the DNS cache and +verifies live resolution. If a later step fails, it restores the earlier line +automatically; when the failure came before any change, it reports that nothing +changed. `hack global dns preview --domain hack.local` shows the same proposal +without changing anything. + +The repair routes names to 127.0.0.1 but does not change ports. With a +`HACK_NATIVE_HTTPS_PORT` other than 443, such as 18443, URLs still need that +port, for example `https://app.hack.local:18443`. The confirmation prompt names +the relay port. To undo the repair, run `hack global dns deactivate --domain hack.local`. It -restores the exact earlier line and verifies the whole-file hash against the -receipt. It removes the resolver file only if the repair created it and its -identity still matches, then restarts dnsmasq. If the file changed after the -repair, rollback refuses with `rollback-drift`; restore the line yourself. An -interrupted repair leaves a `pending` receipt. Doctor then refuses another repair -until the same rollback command recovers it. A Docker-era `hack doctor --fix` or -`hack global install` still rewrites all three built-in roots to whichever target -answers, which can undo the native repair. +needs a terminal and refuses with `non-interactive` and the same command +otherwise. It restores the exact earlier line and verifies the whole-file hash +against the receipt. It removes the resolver file only if the repair created it +and its identity still matches, then restarts dnsmasq. If dnsmasq is not +running, or the job cannot be restarted safely, the rollback still restores the +file and says that dnsmasq was not restarted; the line loads on its next start. + +An interrupted repair leaves a `pending` receipt, and Doctor refuses another +repair until the same rollback command handles it. Sometimes `dnsmasq.conf` +changed after the repair, for example when a Docker-era `hack doctor --fix` or +`hack global install` rewrote the built-in roots. In that case, or when the +receipt names another `dnsmasq.conf` path, restoring would overwrite those +changes. The rollback command then never writes the file. Instead, after a +plain confirmation, it marks the receipt `released` and leaves `dnsmasq.conf` +and any resolver file as they are. Doctor then inspects the current file again. +Restore the recorded line yourself only if you still want the earlier route. A +Docker-era `hack doctor --fix` or `hack global install` still rewrites all +three built-in roots to whichever target answers, which can undo the native +repair. The runtime's read-only `runtime inspect-host-listener --pid PID --port PORT --executable /absolute/caddy --json` reports the selected same-user loopback diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 7a516b448..5992b4dbe 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -114,6 +114,7 @@ import { type HackLocalRouteDependencies, type HackLocalRouteReport, inspectHackLocalRoute, + printable, repairHackLocalRoute, reportHackLocalRoute, } from "../lib/native-hack-local-route.ts"; @@ -1131,7 +1132,9 @@ export async function checkNativeHackLocalRoute(opts: { { name: HACK_LOCAL_ROUTE_CHECK, status: "warn", - message: `Unable to inspect the hack.local route: ${error instanceof Error ? error.message : String(error)}`, + message: printable( + `Unable to inspect the hack.local route: ${error instanceof Error ? error.message : String(error)}` + ), durationMs: 0, }, ]; @@ -1144,11 +1147,13 @@ export async function checkNativeHackLocalRoute(opts: { return { name: HACK_LOCAL_REPAIR_CHECK, status: "ok", - message: `Rewrote "${repaired.receipt.oldLine}" to "${repaired.receipt.newLine}"${ - repaired.receipt.resolverAction === "create" - ? ` and created ${repaired.receipt.resolverPath}` - : "" - }; live DNS verified. Receipt: ${deps.paths.receiptPath}. Undo with: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + message: printable( + `Rewrote "${repaired.receipt.oldLine}" to "${repaired.receipt.newLine}"${ + repaired.receipt.resolverAction === "create" + ? ` and created ${repaired.receipt.resolverPath}` + : "" + }; live DNS verified. Receipt: ${deps.paths.receiptPath}. Undo with: ${HACK_LOCAL_ROLLBACK_COMMAND}` + ), }; } if (repaired.outcome === "current") { @@ -1162,7 +1167,9 @@ export async function checkNativeHackLocalRoute(opts: { return { name: HACK_LOCAL_REPAIR_CHECK, status: "warn", - message: `Repair refused (${repaired.reason}): ${repaired.detail}`, + message: printable( + `Repair refused (${repaired.reason}): ${repaired.detail}` + ), }; }) ); diff --git a/src/commands/global.ts b/src/commands/global.ts index 4efed8f7a..d7e249d8e 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -113,6 +113,7 @@ import { readStableNativeDnsFile, } from "../lib/native-domain-dns-snapshot.ts"; import { + describeHackLocalRollback, describeHackLocalRoute, inspectHackLocalRoute, reportHackLocalRoute, @@ -600,19 +601,12 @@ async function handleGlobalDnsDeactivate({ const result = await rollbackHackLocalRoute( await createNativeHackLocalRouteDependencies({ runtime }) ); + const message = describeHackLocalRollback(result); if (result.outcome === "refused") { - logger.error({ - message: `hack.local rollback refused (${result.reason}): ${result.detail}`, - }); + logger.error({ message }); return 1; } - logger.success({ - message: `Restored "${result.receipt.oldLine}"; the dnsmasq file hash matches the receipt.${ - result.retainedResolver - ? ` ${result.retainedResolver} was retained because its ownership is unproven.` - : "" - }`, - }); + logger.success({ message }); return 0; } const { domain } = selected; diff --git a/src/lib/native-domain-dns-snapshot.ts b/src/lib/native-domain-dns-snapshot.ts index 2679378bf..bddaadc36 100644 --- a/src/lib/native-domain-dns-snapshot.ts +++ b/src/lib/native-domain-dns-snapshot.ts @@ -31,7 +31,10 @@ function missing(error: unknown): boolean { ); } -/** Read a bounded regular file through a non-following descriptor. */ +/** + * Read a bounded regular file through a non-following descriptor. The result + * is a lossless decoding of strictly valid UTF-8, including any BOM. + */ export async function readStableNativeDnsFile( path: string ): Promise { @@ -68,7 +71,11 @@ export async function readStableNativeDnsFile( ) { throw new Error("DNS configuration changed during inspection"); } - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + // Keep a leading BOM: the string must re-encode to exactly these bytes, + // so a rewrite preserves them and hashes of it equal the raw file hash. + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes + ); } finally { await file.close(); } diff --git a/src/lib/native-hack-local-route-host.ts b/src/lib/native-hack-local-route-host.ts index bd21b3b38..f7a520744 100644 --- a/src/lib/native-hack-local-route-host.ts +++ b/src/lib/native-hack-local-route-host.ts @@ -1,6 +1,14 @@ import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { lstat, mkdir, open, readdir, rename, unlink } from "node:fs/promises"; +import { + link, + lstat, + mkdir, + open, + readdir, + rename, + unlink, +} from "node:fs/promises"; import { createConnection } from "node:net"; import { basename, dirname, join, resolve } from "node:path"; import { inspectActiveNativeHttpsOwner } from "../backends/native-project-https.ts"; @@ -28,6 +36,7 @@ const IDENTITY = /^\d+:\d+$/; const IP_ADDRESS_LINE = /^ip_address:\s*(\S+)\s*$/gm; const PROBE_TIMEOUT_MS = 1500; const RECEIPT_FILENAME = "hack-local-route.json"; +const LOCK_HOLDER = /^(\d+)\n$/; /** Private receipt location under the native runtime home. */ export function hackLocalRouteReceiptPath(runtimeHome: string): string { @@ -65,6 +74,26 @@ async function unlinkIfPresent(path: string): Promise { } } +/** Read a small lock file without following links; null when it is gone. */ +async function readLockFile(path: string): Promise { + let handle: Awaited>; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + if (isErrno(error, "ENOENT")) { + return null; + } + throw error; + } + try { + const buffer = Buffer.alloc(32); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + return buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + await handle.close(); + } +} + /** * Write bytes to a fresh sibling and rename it over target only if `guard` * still passes immediately before publication. The rename is atomic, so an @@ -104,7 +133,10 @@ async function replaceAtomically(opts: { } } -async function ensurePrivateDirectory(path: string): Promise { +async function ensurePrivateDirectory( + path: string, + ownerUid: number | undefined +): Promise { try { await mkdir(path, { mode: 0o700 }); } catch (error) { @@ -116,7 +148,7 @@ async function ensurePrivateDirectory(path: string): Promise { if ( !details.isDirectory() || (details.mode & 0o077) !== 0 || - details.uid !== process.getuid?.() + details.uid !== ownerUid ) { throw new Error("hack.local repair receipt directory is not private"); } @@ -129,6 +161,70 @@ export interface HackLocalRouteFileOptions { readonly receiptPath: string; /** Testing seam; production runs the bounded resolver script with sudo -n. */ readonly runPrivileged?: NativeDnsCommandRunner; + /** Testing seam for the required owner; defaults to the current user. */ + readonly ownerUid?: number; +} + +/** True when a process with this pid exists (possibly another user's). */ +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return isErrno(error, "EPERM"); + } +} + +/** + * Exclusive lock beside the receipt. The holder pid is written to a private + * sibling first and published with link(), which never replaces an existing + * lock, so a lock file is always complete. A lock whose holder no longer + * exists is stale and is taken over once. Two runs that both find the same + * stale lock could race; each run also needs an interactive prompt, so that + * window is not reachable in practice. + */ +async function acquireRouteLock(opts: { + readonly receiptPath: string; + readonly ownerUid: number | undefined; +}): Promise<(() => Promise) | null> { + const directory = dirname(opts.receiptPath); + await ensurePrivateDirectory(directory, opts.ownerUid); + const lockPath = `${opts.receiptPath}.lock`; + const staged = join(directory, `.${RECEIPT_FILENAME}.lock-${randomUUID()}`); + const handle = await open( + staged, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600 + ); + try { + await handle.writeFile(`${process.pid}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await link(staged, lockPath); + return async () => await unlinkIfPresent(lockPath); + } catch (error) { + if (!isErrno(error, "EEXIST")) { + throw error; + } + } + const holder = LOCK_HOLDER.exec((await readLockFile(lockPath)) ?? ""); + if (holder && processExists(Number(holder[1]))) { + return null; + } + await unlinkIfPresent(lockPath); + } + return null; + } finally { + await unlinkIfPresent(staged); + } } type FileDependencies = Pick< @@ -142,6 +238,7 @@ type FileDependencies = Pick< | "replaceDnsmasq" | "createResolver" | "removeResolverIfOwned" + | "acquireLock" >; /** @@ -152,6 +249,7 @@ type FileDependencies = Pick< export function createHackLocalRouteFileDependencies( opts: HackLocalRouteFileOptions ): FileDependencies { + const ownerUid = opts.ownerUid ?? process.getuid?.(); return { paths: { dnsmasqPath: opts.dnsmasqPath, @@ -160,7 +258,7 @@ export function createHackLocalRouteFileDependencies( }, readReceipt: async () => await readStableNativeDnsFile(opts.receiptPath), async writeReceipt(receipt: HackLocalRouteReceipt) { - await ensurePrivateDirectory(dirname(opts.receiptPath)); + await ensurePrivateDirectory(dirname(opts.receiptPath), ownerUid); await replaceAtomically({ path: opts.receiptPath, content: `${JSON.stringify(receipt, null, 2)}\n`, @@ -197,7 +295,7 @@ export function createHackLocalRouteFileDependencies( if ( !details.isFile() || details.nlink !== 1 || - details.uid !== process.getuid?.() + details.uid !== ownerUid ) { throw new Error( `${opts.dnsmasqPath} is not a regular file owned by the current user; refusing to rewrite it` @@ -233,6 +331,8 @@ export function createHackLocalRouteFileDependencies( } return identity; }, + acquireLock: async () => + await acquireRouteLock({ receiptPath: opts.receiptPath, ownerUid }), async removeResolverIfOwned({ identity, content }) { const result = await runPrivilegedResolverOperation({ action: "remove", @@ -374,6 +474,12 @@ export async function createNativeHackLocalRouteDependencies(opts: { })) === 0 ); }, + confirm: async (message) => + await confirmSafe({ + message, + initialValue: false, + nonInteractive: "decline", + }), async prepareRestart() { const host = await inspectNativeDnsHost({ brewPrefix: prefix }); if (host.mainConfig.path !== dnsmasqPath) { diff --git a/src/lib/native-hack-local-route.ts b/src/lib/native-hack-local-route.ts index 448a5e2ed..768ed120a 100644 --- a/src/lib/native-hack-local-route.ts +++ b/src/lib/native-hack-local-route.ts @@ -26,7 +26,15 @@ import { isRecord } from "./guards.ts"; * inspection after authorization. Every unmet precondition is a closed * refusal reason, never a best-effort change. * - A pending receipt is published before any host effect. An interrupted run - * leaves it for explicit recovery through the rollback command. + * leaves it for explicit recovery through the rollback command. When the + * recorded file has changed since, that command releases the receipt after + * confirmation instead of writing, so recovery can never get stuck. + * - Repair and rollback hold an exclusive lock for their whole mutation, so an + * automatic restore never mistakes another run's write for its own. + * - Strings are lossless decodings of the file bytes (BOM included), so every + * hash equals the SHA-256 of the raw file. + * - Diagnostics never echo free-form file contents. Lines appear only in the + * validated managed form, and every interpolated value is control-free. */ export const HACK_LOCAL_DOMAIN = DEFAULT_NEW_PROJECT_TLD; @@ -41,9 +49,17 @@ const REPLACEABLE_TARGETS: ReadonlySet = new Set([ DEFAULT_CADDY_IP, "::1", ]); +/** Every line a repair can record as the earlier line. */ +const RECORDABLE_OLD_LINES: ReadonlySet = new Set( + [DEFAULT_HOST_DNS_IP, ...REPLACEABLE_TARGETS].map( + (target) => `${LINE_PREFIX}${target}` + ) +); const TARGET_FORMAT = /^[0-9a-f:.]+$/i; const DOMAIN_TOKEN = /[a-z0-9-]+(?:\.[a-z0-9-]+)*/gi; const SHA256 = /^[0-9a-f]{64}$/; +const RESOLVER_IDENTITY = /^\d+:\d+$/; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g; const BUILT_IN_ROOTS: ReadonlySet = new Set([ DEFAULT_PROJECT_TLD, DEFAULT_NEW_PROJECT_TLD, @@ -63,14 +79,19 @@ export type HackLocalRouteRefusal = | "old-target-live" | "non-interactive" | "declined" + | "busy" + | "restart-unavailable" | "inputs-changed" - | "no-receipt" - | "rollback-drift"; + | "no-receipt"; export interface HackLocalRouteReceipt { readonly version: 1; readonly kind: "hack-local-route-repair"; - readonly state: "pending" | "applied" | "rolled-back"; + /** + * `released`: the recorded file changed after the repair, and the user + * chose to keep the current bytes instead of restoring the earlier line. + */ + readonly state: "pending" | "applied" | "rolled-back" | "released"; readonly domain: typeof HACK_LOCAL_DOMAIN; readonly dnsmasqPath: string; readonly resolverPath: string; @@ -124,6 +145,13 @@ export interface HackLocalRouteDependencies { readonly canPrompt: () => boolean; /** Human confirmation plus the native administrator prompt. */ readonly authorize: (message: string) => Promise; + /** Human confirmation only, for a change limited to the private receipt. */ + readonly confirm: (message: string) => Promise; + /** + * Take the exclusive repair/rollback lock. Returns its release, or null + * when another live run holds it. + */ + readonly acquireLock: () => Promise<(() => Promise) | null>; /** Validate the dnsmasq service before effects; return its restart. */ readonly prepareRestart: () => Promise<() => Promise>; readonly flushDnsCache: () => Promise; @@ -185,6 +213,17 @@ export type HackLocalRouteRollbackResult = readonly receipt: HackLocalRouteReceipt; /** A resolver this repair created but could not prove it still owns. */ readonly retainedResolver: string | null; + /** + * Null when dnsmasq was restarted. Otherwise why the restored file + * was not loaded (for example dnsmasq is not running). + */ + readonly restartSkipped: string | null; + } + | { + /** The file changed after the repair; nothing but the receipt changed. */ + readonly outcome: "released"; + readonly receipt: HackLocalRouteReceipt; + readonly detail: string; } | { readonly outcome: "refused"; @@ -193,7 +232,11 @@ export type HackLocalRouteRollbackResult = readonly command?: string; }; -/** JSON-safe summary for doctor and preview; never includes file contents. */ +/** + * JSON-safe summary for doctor and preview. It never includes free-form file + * contents: `oldLine` and `newLine` appear only in the validated managed form, + * and `detail` names lines by number. + */ export interface HackLocalRouteReport { readonly status: "current" | "repairable" | "refused"; readonly reason?: HackLocalRouteRefusal; @@ -209,10 +252,19 @@ export interface HackLocalRouteReport { readonly command?: string; } +/** + * SHA-256 of the UTF-8 encoding. File strings come from a fatal decoder that + * keeps a leading BOM, so this equals the hash of the raw file bytes. + */ export function sha256(content: string): string { return createHash("sha256").update(content, "utf8").digest("hex"); } +/** Replace control characters so a value cannot drive the terminal. */ +export function printable(value: string): string { + return value.replace(CONTROL_CHARACTERS, "?"); +} + interface ConfigLine { /** Line text without its terminator (a trailing CR is excluded too). */ readonly text: string; @@ -302,12 +354,12 @@ export function analyzeHackLocalDnsmasq(opts: { return { kind: "refused", reason: "unmanaged-line", - detail: `${included.path} also claims .${HACK_LOCAL_DOMAIN}; Hack manages only the main configuration line.`, + detail: `${printable(included.path)} also claims .${HACK_LOCAL_DOMAIN}; Hack manages only the main configuration line.`, }; } - const claims = configLines(opts.content).filter((line) => - claimsHackLocal(line.text) - ); + const claims = configLines(opts.content) + .map((line, index) => ({ ...line, number: index + 1 })) + .filter((line) => claimsHackLocal(line.text)); if (claims.length === 0) { return { kind: "refused", @@ -322,7 +374,8 @@ export function analyzeHackLocalDnsmasq(opts: { detail: `${claims.length} lines claim .${HACK_LOCAL_DOMAIN}; only a single Hack-managed line can be rewritten.`, }; } - const line = (claims[0] as ConfigLine).text; + const claim = claims[0] as ConfigLine & { readonly number: number }; + const line = claim.text; const target = line.startsWith(LINE_PREFIX) ? line.slice(LINE_PREFIX.length) : ""; @@ -330,14 +383,14 @@ export function analyzeHackLocalDnsmasq(opts: { return { kind: "refused", reason: "unmanaged-line", - detail: `"${line.slice(0, 200)}" is not in the Hack-managed ${LINE_PREFIX}
form.`, + detail: `Line ${claim.number} claims .${HACK_LOCAL_DOMAIN} but is not in the Hack-managed ${LINE_PREFIX}
form.`, }; } if (target !== DEFAULT_HOST_DNS_IP && !REPLACEABLE_TARGETS.has(target)) { return { kind: "refused", reason: "foreign-target", - detail: `.${HACK_LOCAL_DOMAIN} routes to ${target}, which Hack never wrote; refusing to replace it.`, + detail: `Line ${claim.number} routes .${HACK_LOCAL_DOMAIN} to an address Hack never wrote; refusing to replace it.`, }; } return { kind: "line", line, target }; @@ -358,6 +411,13 @@ function nonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length < 4096; } +/** A single-line absolute path; receipts never carry control characters. */ +function receiptPathValue(value: unknown): value is string { + return ( + nonEmptyString(value) && value.startsWith("/") && printable(value) === value + ); +} + /** Strict receipt parsing; any unknown shape refuses rather than guessing ownership. */ export function parseHackLocalRouteReceipt(raw: string): HackLocalRouteReceipt { let value: unknown; @@ -372,12 +432,13 @@ export function parseHackLocalRouteReceipt(raw: string): HackLocalRouteReceipt { value.kind === "hack-local-route-repair" && (value.state === "pending" || value.state === "applied" || - value.state === "rolled-back") && + value.state === "rolled-back" || + value.state === "released") && value.domain === HACK_LOCAL_DOMAIN && - nonEmptyString(value.dnsmasqPath) && - nonEmptyString(value.resolverPath) && - nonEmptyString(value.oldLine) && - value.oldLine.startsWith(LINE_PREFIX) && + receiptPathValue(value.dnsmasqPath) && + receiptPathValue(value.resolverPath) && + typeof value.oldLine === "string" && + RECORDABLE_OLD_LINES.has(value.oldLine) && value.newLine === HACK_LOCAL_RELAY_LINE && typeof value.beforeSha256 === "string" && SHA256.test(value.beforeSha256) && @@ -387,7 +448,8 @@ export function parseHackLocalRouteReceipt(raw: string): HackLocalRouteReceipt { typeof value.resolverSha256 === "string" && SHA256.test(value.resolverSha256) && (value.resolverIdentity === null || - nonEmptyString(value.resolverIdentity)) && + (typeof value.resolverIdentity === "string" && + RESOLVER_IDENTITY.test(value.resolverIdentity))) && typeof value.relayPort === "number" && Number.isInteger(value.relayPort) && value.relayPort > 0 && @@ -443,7 +505,9 @@ export async function inspectHackLocalRoute( if ("invalid" in stored) { return refused({ reason: "invalid-receipt", - detail: `${stored.invalid}; refusing to infer ownership of ${paths.receiptPath}.`, + detail: printable( + `${stored.invalid}; refusing to infer ownership of ${paths.receiptPath}.` + ), paths, receipt: null, }); @@ -452,7 +516,7 @@ export async function inspectHackLocalRoute( if (receipt?.state === "pending") { return refused({ reason: "pending-receipt", - detail: `An interrupted hack.local repair left a pending receipt. Restore the recorded earlier line with: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + detail: `An interrupted hack.local repair left a pending receipt. Run ${HACK_LOCAL_ROLLBACK_COMMAND} to restore the recorded earlier line, or, if dnsmasq.conf changed since, to release the receipt without writing.`, paths, receipt, }); @@ -480,7 +544,9 @@ export async function inspectHackLocalRoute( if (resolverAction === "foreign") { return refused({ reason: "foreign-resolver", - detail: `${paths.resolverPath} exists with content Hack did not write; refusing to change it.`, + detail: printable( + `${paths.resolverPath} exists with content Hack did not write; refusing to change it.` + ), paths, receipt, }); @@ -595,14 +661,16 @@ export function reportHackLocalRoute( }; } -/** One-line human message for a report. */ +/** One-line human message for a report; control characters are replaced. */ export function describeHackLocalRoute(report: HackLocalRouteReport): string { if (report.status === "current") { - return `*.${HACK_LOCAL_DOMAIN} routes to the native relay on 127.0.0.1:${report.relayPort}.${ - report.receiptState === "applied" - ? ` Undo the native repair with: ${HACK_LOCAL_ROLLBACK_COMMAND}` - : "" - }`; + return printable( + `*.${HACK_LOCAL_DOMAIN} routes to the native relay on 127.0.0.1:${report.relayPort}.${ + report.receiptState === "applied" + ? ` Undo the native repair with: ${HACK_LOCAL_ROLLBACK_COMMAND}` + : "" + }` + ); } if (report.status === "repairable") { const resolver = @@ -614,9 +682,11 @@ export function describeHackLocalRoute(report: HackLocalRouteReport): string { ? "" : `rewrite "${report.oldLine}" to "${report.newLine}" in ${report.dnsmasqPath}`; const change = [rewrite, resolver].filter(Boolean).join(" and "); - return `Proposed: ${change}. Apply with: ${HACK_LOCAL_REPAIR_COMMAND}`; + return printable( + `Proposed: ${change}. Apply with: ${HACK_LOCAL_REPAIR_COMMAND}` + ); } - return `Not repairable (${report.reason}): ${report.detail}`; + return printable(`Not repairable (${report.reason}): ${report.detail}`); } function samePlan( @@ -637,45 +707,85 @@ function samePlan( } function errorMessage(error: unknown): string { - return (error instanceof Error ? error.message : String(error)) - .replaceAll("\n", " ") - .slice(0, 512); + return printable( + error instanceof Error ? error.message : String(error) + ).slice(0, 512); +} + +/** How the recorded earlier bytes relate to the current file. */ +export type HackLocalRestoreStep = + /** The file holds the repaired bytes; `next` reproduces the earlier hash. */ + | { readonly kind: "write"; readonly next: string } + /** The file already holds the earlier bytes. */ + | { readonly kind: "unchanged" } + /** The file matches neither recorded hash, or is missing. */ + | { readonly kind: "drift" } + /** The receipt's lines do not reproduce its own recorded hashes. */ + | { readonly kind: "mismatch" }; + +/** Pure restore decision shared by the precheck and the effect. */ +export function planHackLocalRestore(opts: { + readonly current: string | null; + readonly receipt: HackLocalRouteReceipt; +}): HackLocalRestoreStep { + const { current, receipt } = opts; + if (current === null) { + return { kind: "drift" }; + } + const currentSha = sha256(current); + if (currentSha === receipt.beforeSha256) { + return { kind: "unchanged" }; + } + if (currentSha !== receipt.afterSha256) { + return { kind: "drift" }; + } + const next = replaceManagedLine({ + content: current, + from: receipt.newLine, + to: receipt.oldLine, + }); + if (next === null || sha256(next) !== receipt.beforeSha256) { + return { kind: "mismatch" }; + } + return { kind: "write", next }; +} + +interface RestoreOutcome { + readonly retainedResolver: string | null; + /** True when restore wrote dnsmasq.conf or removed the resolver. */ + readonly changed: boolean; + readonly restarted: boolean; } /** * Restore the recorded earlier state. Shared by explicit rollback and by - * automatic cleanup when a repair fails after its first effect. + * automatic cleanup when a repair fails. dnsmasq restarts only when this + * restore changed something or `forceRestart` says the running service may + * hold the repaired configuration; `restart` null skips it. */ async function restoreFromReceipt(opts: { readonly deps: HackLocalRouteDependencies; readonly receipt: HackLocalRouteReceipt; - readonly restart: () => Promise; -}): Promise<{ readonly retainedResolver: string | null }> { + readonly restart: (() => Promise) | null; + readonly forceRestart: boolean; +}): Promise { const { deps, receipt } = opts; const current = await deps.readDnsmasq(); - if (current === null) { - throw new Error("dnsmasq configuration disappeared during rollback"); - } - const currentSha = sha256(current); - if ( - currentSha === receipt.afterSha256 && - currentSha !== receipt.beforeSha256 - ) { - const restored = replaceManagedLine({ - content: current, - from: receipt.newLine, - to: receipt.oldLine, - }); - if (restored === null || sha256(restored) !== receipt.beforeSha256) { - throw new Error("Restored dnsmasq bytes would not match the receipt"); + const step = planHackLocalRestore({ current, receipt }); + let changed = false; + if (step.kind === "write") { + await deps.replaceDnsmasq({ expected: current as string, next: step.next }); + const verified = await deps.readDnsmasq(); + if (verified === null || sha256(verified) !== receipt.beforeSha256) { + throw new Error("Restored dnsmasq configuration hash does not match"); } - await deps.replaceDnsmasq({ expected: current, next: restored }); - } else if (currentSha !== receipt.beforeSha256) { - throw new Error("dnsmasq configuration changed since the repair"); - } - const verified = await deps.readDnsmasq(); - if (verified === null || sha256(verified) !== receipt.beforeSha256) { - throw new Error("Restored dnsmasq configuration hash does not match"); + changed = true; + } else if (step.kind !== "unchanged") { + throw new Error( + step.kind === "drift" + ? "dnsmasq configuration changed since the repair" + : "Restored dnsmasq bytes would not match the receipt" + ); } let retainedResolver: string | null = null; if (receipt.resolverAction === "create") { @@ -687,14 +797,20 @@ async function restoreFromReceipt(opts: { identity: receipt.resolverIdentity, content: HACK_LOCAL_RESOLVER_CONTENT, })); - if (!removed) { + if (removed) { + changed = true; + } else { retainedResolver = receipt.resolverPath; } } } - await opts.restart(); - await deps.flushDnsCache(); - return { retainedResolver }; + let restarted = false; + if ((changed || opts.forceRestart) && opts.restart) { + await opts.restart(); + await deps.flushDnsCache(); + restarted = true; + } + return { retainedResolver, changed, restarted }; } function stamp( @@ -705,6 +821,19 @@ function stamp( return Object.freeze({ ...receipt, ...patch, updatedAt: now.toISOString() }); } +function busy(): { + readonly outcome: "refused"; + readonly reason: "busy"; + readonly detail: string; +} { + return { + outcome: "refused", + reason: "busy", + detail: + "Another hack.local repair or rollback is running; nothing changed. Retry when it finishes.", + }; +} + /** * Authorized, reversible repair. Rechecks the plan after the administrator * prompt, publishes a pending receipt before effects, and on failure restores @@ -737,6 +866,21 @@ export async function repairHackLocalRoute( command: HACK_LOCAL_REPAIR_COMMAND, }; } + const release = await deps.acquireLock(); + if (!release) { + return busy(); + } + try { + return await repairUnderLock(deps, plan); + } finally { + await release(); + } +} + +async function repairUnderLock( + deps: HackLocalRouteDependencies, + plan: HackLocalRoutePlan +): Promise { const changes = [ plan.oldLine === plan.newLine ? "" @@ -750,7 +894,9 @@ export async function repairHackLocalRoute( ? "" : `${plan.oldTarget}:443 did not answer; `; const approved = await deps.authorize( - `Native hack.local repair: ${changes.join(" and ")}? ${probe}the native relay listens on 127.0.0.1:${plan.relayPort}. This restarts dnsmasq. Undo: ${HACK_LOCAL_ROLLBACK_COMMAND}` + printable( + `Native hack.local repair: ${changes.join(" and ")}? ${probe}the native relay listens on 127.0.0.1:${plan.relayPort}. This restarts dnsmasq. Undo: ${HACK_LOCAL_ROLLBACK_COMMAND}` + ) ); if (!approved) { return { @@ -759,7 +905,16 @@ export async function repairHackLocalRoute( detail: "The hack.local repair was not authorized; nothing changed.", }; } - const restart = await deps.prepareRestart(); + let restart: () => Promise; + try { + restart = await deps.prepareRestart(); + } catch (error) { + return { + outcome: "refused", + reason: "restart-unavailable", + detail: `The dnsmasq service cannot be restarted safely (${errorMessage(error)}); nothing changed.`, + }; + } const after = await inspectHackLocalRoute(deps); if (after.status !== "repairable" || !samePlan(plan, after.plan)) { return { @@ -792,6 +947,7 @@ export async function repairHackLocalRoute( await deps.writeReceipt(receipt); let phase = "dnsmasq rewrite"; + let restartAttempted = false; try { if (plan.afterContent !== plan.beforeContent) { await deps.replaceDnsmasq({ @@ -806,6 +962,7 @@ export async function repairHackLocalRoute( await deps.writeReceipt(receipt); } phase = "dnsmasq restart"; + restartAttempted = true; await restart(); phase = "DNS cache flush"; await deps.flushDnsCache(); @@ -816,13 +973,14 @@ export async function repairHackLocalRoute( await deps.writeReceipt(receipt); return { outcome: "repaired", plan, receipt }; } catch (cause) { - let retained: string | null = null; + let restored: RestoreOutcome; try { - ({ retainedResolver: retained } = await restoreFromReceipt({ + restored = await restoreFromReceipt({ deps, receipt, restart, - })); + forceRestart: restartAttempted, + }); await deps.writeReceipt( stamp(receipt, { state: "rolled-back" }, deps.now()) ); @@ -832,12 +990,18 @@ export async function repairHackLocalRoute( { cause } ); } + const state = + restored.changed || restored.restarted + ? "the earlier dnsmasq line was restored" + : "nothing was changed"; throw new Error( - `hack.local repair failed during ${phase}: ${errorMessage(cause)}; the earlier dnsmasq line was restored${ - retained - ? `; ${retained} was retained because its ownership is unproven` - : "" - }.`, + printable( + `hack.local repair failed during ${phase}: ${errorMessage(cause)}; ${state}${ + restored.retainedResolver + ? `; ${restored.retainedResolver} was retained because its ownership is unproven` + : "" + }.` + ), { cause } ); } @@ -845,7 +1009,10 @@ export async function repairHackLocalRoute( /** * Restore the exact earlier line recorded by an applied or interrupted repair - * and verify the whole-file hash. A file changed since the repair refuses. + * and verify the whole-file hash. When the file changed since the repair (or + * the receipt names another file), restoring would overwrite someone else's + * bytes, so the command instead offers to release the receipt: after a plain + * confirmation it marks the receipt `released` and changes nothing else. */ export async function rollbackHackLocalRoute( deps: HackLocalRouteDependencies @@ -855,53 +1022,76 @@ export async function rollbackHackLocalRoute( return { outcome: "refused", reason: "invalid-receipt", - detail: `${stored.invalid}; refusing to infer ownership of ${deps.paths.receiptPath}.`, - }; - } - const { receipt } = stored; - if (!receipt || receipt.state === "rolled-back") { - return { - outcome: "refused", - reason: "no-receipt", - detail: `No applied or interrupted hack.local repair is recorded at ${deps.paths.receiptPath}; nothing to roll back.`, + detail: printable( + `${stored.invalid}; refusing to infer ownership of ${deps.paths.receiptPath}.` + ), }; } + const { receipt, raw } = stored; if ( - receipt.dnsmasqPath !== deps.paths.dnsmasqPath || - receipt.resolverPath !== deps.paths.resolverPath + !receipt || + receipt.state === "rolled-back" || + receipt.state === "released" ) { return { outcome: "refused", - reason: "rollback-drift", - detail: `The receipt names ${receipt.dnsmasqPath}, not the current ${deps.paths.dnsmasqPath}.`, + reason: "no-receipt", + detail: printable( + `No applied or interrupted hack.local repair is recorded at ${deps.paths.receiptPath}; nothing to roll back.` + ), }; } + const release = await deps.acquireLock(); + if (!release) { + return busy(); + } + try { + return await rollbackUnderLock({ deps, receipt, raw }); + } finally { + await release(); + } +} + +async function rollbackUnderLock(opts: { + readonly deps: HackLocalRouteDependencies; + readonly receipt: HackLocalRouteReceipt; + readonly raw: string | null; +}): Promise { + const { deps, receipt } = opts; const current = await deps.readDnsmasq(); - const currentSha = current === null ? null : sha256(current); - if ( - currentSha === null || - (currentSha !== receipt.afterSha256 && currentSha !== receipt.beforeSha256) - ) { + const samePaths = + receipt.dnsmasqPath === deps.paths.dnsmasqPath && + receipt.resolverPath === deps.paths.resolverPath; + const step: HackLocalRestoreStep = samePaths + ? planHackLocalRestore({ current, receipt }) + : { kind: "drift" }; + if (step.kind === "mismatch") { return { outcome: "refused", - reason: "rollback-drift", - detail: `${receipt.dnsmasqPath} changed since the repair (hash differs from the receipt); restore "${receipt.oldLine}" manually.`, + reason: "invalid-receipt", + detail: + "The receipt's recorded line does not reproduce its recorded file hash; refusing to write it.", }; } if (!deps.canPrompt()) { return { outcome: "refused", reason: "non-interactive", - detail: `Rollback needs interactive confirmation and a macOS administrator prompt. Run it in a terminal: ${HACK_LOCAL_ROLLBACK_COMMAND}`, + detail: `Rollback needs interactive confirmation. Run it in a terminal: ${HACK_LOCAL_ROLLBACK_COMMAND}`, command: HACK_LOCAL_ROLLBACK_COMMAND, }; } + if (step.kind === "drift") { + return await releaseReceipt({ ...opts, samePaths }); + } const approved = await deps.authorize( - `Restore "${receipt.oldLine}" in ${receipt.dnsmasqPath}${ - receipt.resolverAction === "create" - ? ` and remove the ${receipt.resolverPath} this repair created` - : "" - }? This restarts dnsmasq.` + printable( + `Restore "${receipt.oldLine}" in ${receipt.dnsmasqPath}${ + receipt.resolverAction === "create" + ? ` and remove the ${receipt.resolverPath} this repair created` + : "" + }? This restarts dnsmasq.` + ) ); if (!approved) { return { @@ -910,10 +1100,18 @@ export async function rollbackHackLocalRoute( detail: "The hack.local rollback was not authorized; nothing changed.", }; } - const restart = await deps.prepareRestart(); + // Restoring the file needs no service; a missing or ambiguous dnsmasq only + // means the restored line loads on its next start. + let restart: (() => Promise) | null = null; + let restartSkipped: string | null = null; + try { + restart = await deps.prepareRestart(); + } catch (error) { + restartSkipped = errorMessage(error); + } const again = await deps.readReceipt(); const recheck = await deps.readDnsmasq(); - if (again !== stored.raw || recheck !== current) { + if (again !== opts.raw || recheck !== current) { return { outcome: "refused", reason: "inputs-changed", @@ -921,14 +1119,89 @@ export async function rollbackHackLocalRoute( "The receipt or dnsmasq configuration changed during authorization; nothing changed.", }; } - const { retainedResolver } = await restoreFromReceipt({ + const restored = await restoreFromReceipt({ deps, receipt, restart, + forceRestart: true, }); const rolledBack = stamp(receipt, { state: "rolled-back" }, deps.now()); await deps.writeReceipt(rolledBack); - return { outcome: "rolled-back", receipt: rolledBack, retainedResolver }; + return { + outcome: "rolled-back", + receipt: rolledBack, + retainedResolver: restored.retainedResolver, + restartSkipped, + }; +} + +async function releaseReceipt(opts: { + readonly deps: HackLocalRouteDependencies; + readonly receipt: HackLocalRouteReceipt; + readonly raw: string | null; + readonly samePaths: boolean; +}): Promise { + const { deps, receipt } = opts; + const why = opts.samePaths + ? `${receipt.dnsmasqPath} changed after the hack.local repair` + : `The receipt names ${receipt.dnsmasqPath}, not the current ${deps.paths.dnsmasqPath}`; + const kept = + receipt.resolverAction === "create" ? ` and ${receipt.resolverPath}` : ""; + const approved = await deps.confirm( + printable( + `${why}, so restoring "${receipt.oldLine}" would overwrite other changes. Release the ${receipt.state} receipt and leave the dnsmasq configuration${kept} exactly as they are?` + ) + ); + if (!approved) { + return { + outcome: "refused", + reason: "declined", + detail: "The hack.local receipt was not released; nothing changed.", + }; + } + if ((await deps.readReceipt()) !== opts.raw) { + return { + outcome: "refused", + reason: "inputs-changed", + detail: "The receipt changed during confirmation; nothing changed.", + }; + } + const released = stamp(receipt, { state: "released" }, deps.now()); + await deps.writeReceipt(released); + return { + outcome: "released", + receipt: released, + detail: printable( + `${why}. Hack released the receipt and left the dnsmasq configuration${kept} unchanged. Restore "${receipt.oldLine}" yourself if you still want the earlier route.` + ), + }; +} + +/** One-line human message for a rollback result; control-free. */ +export function describeHackLocalRollback( + result: HackLocalRouteRollbackResult +): string { + if (result.outcome === "refused") { + return printable( + `hack.local rollback refused (${result.reason}): ${result.detail}` + ); + } + if (result.outcome === "released") { + return printable( + `Released the hack.local repair receipt. ${result.detail}` + ); + } + return printable( + `Restored "${result.receipt.oldLine}"; the dnsmasq file hash matches the receipt.${ + result.retainedResolver + ? ` ${result.retainedResolver} was retained because its ownership is unproven.` + : "" + }${ + result.restartSkipped + ? ` dnsmasq was not restarted (${result.restartSkipped}); the restored line loads when dnsmasq next starts.` + : "" + }` + ); } export type NativeDnsCommandDomain = diff --git a/tests/native-hack-local-route.test.ts b/tests/native-hack-local-route.test.ts index 4b8097bf5..510ef6d19 100644 --- a/tests/native-hack-local-route.test.ts +++ b/tests/native-hack-local-route.test.ts @@ -1,6 +1,8 @@ import { afterEach, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { + chmod, + link, mkdir, mkdtemp, open, @@ -17,10 +19,13 @@ import { CliUsageError } from "../src/cli/command.ts"; import type { NativeDnsCommandRunner } from "../src/lib/native-domain-dns-host.ts"; import { analyzeHackLocalDnsmasq, + describeHackLocalRollback, + describeHackLocalRoute, HACK_LOCAL_REPAIR_COMMAND, HACK_LOCAL_RESOLVER_CONTENT, HACK_LOCAL_ROLLBACK_COMMAND, type HackLocalRouteDependencies, + type HackLocalRoutePlan, type HackLocalRouteReceipt, inspectHackLocalRoute, parseHackLocalRouteReceipt, @@ -54,6 +59,7 @@ afterEach(async () => { interface Calls { readonly authorize: string[]; + readonly confirm: string[]; restart: number; flush: number; verify: number; @@ -103,6 +109,7 @@ async function fixture( readonly oldTargetLive?: boolean; readonly prompt?: boolean; readonly approve?: boolean; + readonly ownerUid?: number; readonly overrides?: Partial; } = {} ) { @@ -121,6 +128,7 @@ async function fixture( const content = opts.dnsmasq === undefined ? ORIGINAL : opts.dnsmasq; if (content !== null) { await writeFile(dnsmasqPath, content, { mode: 0o644 }); + await chmod(dnsmasqPath, 0o644); } for (const [name, text] of Object.entries(opts.includeFiles ?? {})) { await writeFile(join(includeDir, name), text); @@ -134,6 +142,7 @@ async function fixture( } const calls: Calls = { authorize: [], + confirm: [], restart: 0, flush: 0, verify: 0, @@ -146,6 +155,7 @@ async function fixture( resolverPath, receiptPath: join(home, "native-dns", "hack-local-route.json"), runPrivileged: fakePrivileged(resolverDir), + ...(opts.ownerUid === undefined ? {} : { ownerUid: opts.ownerUid }), }); let clock = Date.parse("2026-10-12T12:00:00.000Z"); const deps: HackLocalRouteDependencies = { @@ -165,6 +175,10 @@ async function fixture( calls.authorize.push(message); return opts.approve ?? true; }, + confirm: async (message) => { + calls.confirm.push(message); + return opts.approve ?? true; + }, prepareRestart: async () => async () => { calls.restart += 1; }, @@ -213,6 +227,44 @@ async function readReceipt( ); } +/** The receipt a crash right after its publication leaves behind. */ +function pendingReceipt( + f: { readonly dnsmasqPath: string; readonly resolverPath: string }, + plan: HackLocalRoutePlan +): HackLocalRouteReceipt { + return { + version: 1, + kind: "hack-local-route-repair", + state: "pending", + domain: "hack.local", + dnsmasqPath: f.dnsmasqPath, + resolverPath: f.resolverPath, + oldLine: plan.oldLine, + newLine: plan.newLine, + beforeSha256: plan.beforeSha256, + afterSha256: plan.afterSha256, + resolverAction: "keep", + resolverSha256: sha256(HACK_LOCAL_RESOLVER_CONTENT), + resolverIdentity: null, + relayPort: 8443, + createdAt: "2026-10-12T12:00:00.000Z", + updatedAt: "2026-10-12T12:00:00.000Z", + }; +} + +async function editReceipt( + deps: HackLocalRouteDependencies, + patch: Record +): Promise { + const value: unknown = JSON.parse( + await readFile(deps.paths.receiptPath, "utf8") + ); + await writeFile( + deps.paths.receiptPath, + JSON.stringify({ ...(value as object), ...patch }) + ); +} + const REWRITTEN = ORIGINAL.replace( "address=/.hack.local/172.30.0.2", "address=/.hack.local/127.0.0.1" @@ -440,19 +492,38 @@ test("rollback restores the exact earlier bytes and removes the created resolver }); }); -test("rollback refuses when the file changed after the repair", async () => { - const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); +test("rollback of a changed file never writes it and releases the receipt on confirmation", async () => { + const f = await fixture(); expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); const edited = `${REWRITTEN}address=/.example.test/127.0.0.1\n`; await writeFile(f.dnsmasqPath, edited); - expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ - outcome: "refused", - reason: "rollback-drift", + const restarts = f.calls.restart; + + const declined = await rollbackHackLocalRoute({ + ...f.deps, + confirm: async () => false, }); + expect(declined).toMatchObject({ outcome: "refused", reason: "declined" }); + expect((await readReceipt(f.deps)).state).toBe("applied"); + + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ outcome: "released" }); + expect(describeHackLocalRollback(result)).toContain( + "address=/.hack.local/172.30.0.2" + ); expect(await readFile(f.dnsmasqPath, "utf8")).toBe(edited); + // The resolver the repair created is kept; another tool may rely on it. expect(await readFile(f.resolverPath, "utf8")).toBe( HACK_LOCAL_RESOLVER_CONTENT ); + expect((await readReceipt(f.deps)).state).toBe("released"); + expect(f.calls.authorize).toHaveLength(1); + expect(f.calls.confirm).toHaveLength(1); + expect(f.calls.restart).toBe(restarts); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "no-receipt", + }); }); test("an interrupted repair leaves a pending receipt that rollback recovers", async () => { @@ -462,25 +533,7 @@ test("an interrupted repair leaves a pending receipt that rollback recovers", as if (plan.status !== "repairable") { throw new Error("expected a repairable plan"); } - const pending: HackLocalRouteReceipt = { - version: 1, - kind: "hack-local-route-repair", - state: "pending", - domain: "hack.local", - dnsmasqPath: f.dnsmasqPath, - resolverPath: f.resolverPath, - oldLine: plan.plan.oldLine, - newLine: plan.plan.newLine, - beforeSha256: plan.plan.beforeSha256, - afterSha256: plan.plan.afterSha256, - resolverAction: "keep", - resolverSha256: sha256(HACK_LOCAL_RESOLVER_CONTENT), - resolverIdentity: null, - relayPort: 8443, - createdAt: "2026-10-12T12:00:00.000Z", - updatedAt: "2026-10-12T12:00:00.000Z", - }; - await f.deps.writeReceipt(pending); + await f.deps.writeReceipt(pendingReceipt(f, plan.plan)); await f.deps.replaceDnsmasq({ expected: ORIGINAL, next: REWRITTEN }); expect( @@ -504,25 +557,42 @@ test("an interrupted repair leaves a pending receipt that rollback recovers", as expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); }); -test("a pending receipt written before the rewrite recovers without a write", async () => { +test("a repair that fails before writing reports no change and skips the restart", async () => { const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + let restarts = 0; const failing: HackLocalRouteDependencies = { ...f.deps, replaceDnsmasq: async () => { throw new Error("simulated interruption"); }, - // The automatic restore cannot restart, so the pending receipt remains. prepareRestart: async () => async () => { + restarts += 1; throw new Error("restart unavailable"); }, }; await expect(repairHackLocalRoute(failing)).rejects.toThrow( - HACK_LOCAL_ROLLBACK_COMMAND + "nothing was changed" ); + expect(restarts).toBe(0); expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); - expect((await readReceipt(f.deps)).state).toBe("pending"); - expect((await rollbackHackLocalRoute(f.deps)).outcome).toBe("rolled-back"); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); +}); + +test("an interrupted repair before its write recovers through rollback", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const plan = await inspectHackLocalRoute(f.deps); + if (plan.status !== "repairable") { + throw new Error("expected a repairable plan"); + } + await f.deps.writeReceipt(pendingReceipt(f, plan.plan)); + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "rolled-back", + restartSkipped: null, + }); expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); }); test("a failed live verification restores the earlier line automatically", async () => { @@ -659,3 +729,379 @@ test("doctor --fix applies the repair and JSON reports the route state", async ( expect(refusedResults[0]?.message).toContain("non-interactive"); expect(refusedResults[0]?.message).toContain(HACK_LOCAL_REPAIR_COMMAND); }); + +test("a pending receipt whose file was rewritten since is released, never stuck", async () => { + // A crash after the rewrite, then a Docker-era doctor --fix rewrote the + // roots: the recorded hashes no longer describe the file. + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const plan = await inspectHackLocalRoute(f.deps); + if (plan.status !== "repairable") { + throw new Error("expected a repairable plan"); + } + await f.deps.writeReceipt(pendingReceipt(f, plan.plan)); + const rewritten = ORIGINAL.replaceAll("172.30.0.2", "127.0.0.1"); + await writeFile(f.dnsmasqPath, rewritten); + + const blocked = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(blocked).toMatchObject({ + status: "refused", + reason: "pending-receipt", + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect(blocked.detail).toContain("release the receipt"); + + const quiet = await rollbackHackLocalRoute({ + ...f.deps, + canPrompt: () => false, + }); + expect(quiet).toMatchObject({ + outcome: "refused", + reason: "non-interactive", + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect((await readReceipt(f.deps)).state).toBe("pending"); + + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "released", + }); + expect(f.calls.authorize).toHaveLength(0); + expect(f.calls.restart).toBe(0); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(rewritten); + expect( + reportHackLocalRoute(await inspectHackLocalRoute(f.deps)) + ).toMatchObject({ status: "current", receiptState: "released" }); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "current", + }); +}); + +test("a BOM-prefixed file keeps its exact bytes and receipt hashes match the raw file", async () => { + const bom = Buffer.from([0xef, 0xbb, 0xbf]); + const originalBytes = Buffer.concat([bom, Buffer.from(ORIGINAL, "utf8")]); + const f = await fixture(); + await writeFile(f.dnsmasqPath, originalBytes); + const rawSha = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); + + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const repairedBytes = await readFile(f.dnsmasqPath); + expect( + Buffer.compare( + repairedBytes, + Buffer.concat([bom, Buffer.from(REWRITTEN, "utf8")]) + ) + ).toBe(0); + const receipt = await readReceipt(f.deps); + expect(receipt.beforeSha256).toBe(rawSha(originalBytes)); + expect(receipt.afterSha256).toBe(rawSha(repairedBytes)); + + expect((await rollbackHackLocalRoute(f.deps)).outcome).toBe("rolled-back"); + expect(Buffer.compare(await readFile(f.dnsmasqPath), originalBytes)).toBe(0); +}); + +test("rollback without a terminal refuses with the exact command and changes nothing", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const receiptBefore = await readFile(f.deps.paths.receiptPath, "utf8"); + expect( + await rollbackHackLocalRoute({ ...f.deps, canPrompt: () => false }) + ).toEqual({ + outcome: "refused", + reason: "non-interactive", + detail: expect.stringContaining(HACK_LOCAL_ROLLBACK_COMMAND), + command: HACK_LOCAL_ROLLBACK_COMMAND, + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + expect(await readFile(f.deps.paths.receiptPath, "utf8")).toBe(receiptBefore); + expect(f.calls.authorize).toHaveLength(1); + + const declined = await rollbackHackLocalRoute({ + ...f.deps, + authorize: async () => false, + }); + expect(declined).toMatchObject({ outcome: "refused", reason: "declined" }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); +}); + +test("rollback rechecks the file and receipt after authorization", async () => { + const edited = `${REWRITTEN}# edited during the prompt\n`; + const fileChanged = await fixture(); + expect((await repairHackLocalRoute(fileChanged.deps)).outcome).toBe( + "repaired" + ); + expect( + await rollbackHackLocalRoute({ + ...fileChanged.deps, + authorize: async () => { + await writeFile(fileChanged.dnsmasqPath, edited); + return true; + }, + }) + ).toMatchObject({ outcome: "refused", reason: "inputs-changed" }); + expect(await readFile(fileChanged.dnsmasqPath, "utf8")).toBe(edited); + expect((await readReceipt(fileChanged.deps)).state).toBe("applied"); + + const receiptChanged = await fixture(); + expect((await repairHackLocalRoute(receiptChanged.deps)).outcome).toBe( + "repaired" + ); + expect( + await rollbackHackLocalRoute({ + ...receiptChanged.deps, + authorize: async () => { + await editReceipt(receiptChanged.deps, {}); + return true; + }, + }) + ).toMatchObject({ outcome: "refused", reason: "inputs-changed" }); + expect(await readFile(receiptChanged.dnsmasqPath, "utf8")).toBe(REWRITTEN); +}); + +test("a receipt whose line does not reproduce its hashes is never written", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + await editReceipt(f.deps, { oldLine: "address=/.hack.local/::1" }); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "invalid-receipt", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + expect(f.calls.authorize).toHaveLength(1); +}); + +test("a receipt line must be a single managed line", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const injected = "address=/.hack.local/172.30.0.2\nserver=8.8.8.8"; + for (const patch of [ + // Hashes edited to match, so only the line format check can refuse. + { + oldLine: injected, + beforeSha256: sha256( + REWRITTEN.replace("address=/.hack.local/127.0.0.1", injected) + ), + }, + { oldLine: "address=/.hack.local/10.0.0.9" }, + { dnsmasqPath: `${f.dnsmasqPath}\u001b[2J` }, + { dnsmasqPath: "relative/dnsmasq.conf" }, + { resolverIdentity: "not-an-identity" }, + ]) { + const raw = await readFile(f.deps.paths.receiptPath, "utf8"); + await editReceipt(f.deps, patch); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "invalid-receipt", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + await writeFile(f.deps.paths.receiptPath, raw); + } +}); + +test("a receipt for another dnsmasq path is released without writing the current file", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + await editReceipt(f.deps, { dnsmasqPath: "/elsewhere/etc/dnsmasq.conf" }); + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ outcome: "released" }); + expect(f.calls.confirm[0]).toContain("/elsewhere/etc/dnsmasq.conf"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); +}); + +test("a release rechecks the receipt after confirmation", async () => { + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + await writeFile(f.dnsmasqPath, `${REWRITTEN}# edited\n`); + expect( + await rollbackHackLocalRoute({ + ...f.deps, + confirm: async () => { + await editReceipt(f.deps, {}); + return true; + }, + }) + ).toMatchObject({ outcome: "refused", reason: "inputs-changed" }); + expect((await readReceipt(f.deps)).state).toBe("applied"); +}); + +test("a failed restart is retried during cleanup so dnsmasq is not left stopped", async () => { + // Resolver-only repair: the line already targets the relay. + const f = await fixture({ dnsmasq: REWRITTEN }); + let restarts = 0; + await expect( + repairHackLocalRoute({ + ...f.deps, + // Nothing for the restore itself to change: only the failed restart + // makes a second restart necessary. + removeResolverIfOwned: async () => false, + prepareRestart: async () => async () => { + restarts += 1; + if (restarts === 1) { + throw new Error("bootstrap failed"); + } + }, + }) + ).rejects.toThrow("was retained because its ownership is unproven"); + expect(restarts).toBe(2); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); +}); + +test("automatic cleanup never overwrites a concurrent edit and keeps the receipt pending", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + const edited = `${REWRITTEN}# concurrent edit\n`; + await expect( + repairHackLocalRoute({ + ...f.deps, + verifyLiveDns: async () => { + await writeFile(f.dnsmasqPath, edited); + throw new Error("probe did not resolve"); + }, + }) + ).rejects.toThrow("automatic restore failed"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(edited); + expect((await readReceipt(f.deps)).state).toBe("pending"); + expect((await rollbackHackLocalRoute(f.deps)).outcome).toBe("released"); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(edited); +}); + +test("an unavailable dnsmasq service refuses repair and does not block rollback", async () => { + const unavailable = async (): Promise<() => Promise> => { + throw new Error("Native DNS activation requires exactly one dnsmasq"); + }; + const repair = await fixture(); + expect( + await repairHackLocalRoute({ ...repair.deps, prepareRestart: unavailable }) + ).toMatchObject({ outcome: "refused", reason: "restart-unavailable" }); + expect(await readFile(repair.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await expect(stat(repair.deps.paths.receiptPath)).rejects.toThrow(); + + const f = await fixture(); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const result = await rollbackHackLocalRoute({ + ...f.deps, + prepareRestart: unavailable, + }); + expect(result).toMatchObject({ + outcome: "rolled-back", + restartSkipped: expect.stringContaining("exactly one dnsmasq"), + }); + expect(describeHackLocalRollback(result)).toContain( + "dnsmasq was not restarted" + ); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await expect(stat(f.resolverPath)).rejects.toThrow(); + expect((await readReceipt(f.deps)).state).toBe("rolled-back"); +}); + +test("repair and rollback refuse while another run holds the lock", async () => { + const f = await fixture(); + const release = await f.deps.acquireLock(); + if (!release) { + throw new Error("expected the lock"); + } + expect(await f.deps.acquireLock()).toBeNull(); + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "busy", + }); + expect(f.calls.authorize).toHaveLength(0); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + await release(); + + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const held = await f.deps.acquireLock(); + expect(await rollbackHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "busy", + }); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(REWRITTEN); + await held?.(); + expect( + (await readdir(join(f.home, "native-dns"))).filter((name) => + name.includes("lock") + ) + ).toEqual([]); +}); + +test("a lock left by a process that no longer exists is taken over", async () => { + const f = await fixture(); + await mkdir(join(f.home, "native-dns"), { mode: 0o700 }); + await writeFile(`${f.deps.paths.receiptPath}.lock`, "2147483646\n"); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + await expect(stat(`${f.deps.paths.receiptPath}.lock`)).rejects.toThrow(); +}); + +test("dnsmasq replacement refuses a linked or foreign-owned file", async () => { + const linked = await fixture(); + await link(linked.dnsmasqPath, join(linked.root, "dnsmasq.link")); + await expect( + linked.deps.replaceDnsmasq({ expected: ORIGINAL, next: REWRITTEN }) + ).rejects.toThrow("not a regular file owned by the current user"); + expect(await readFile(linked.dnsmasqPath, "utf8")).toBe(ORIGINAL); + + const uid = process.getuid?.() ?? 0; + const foreign = await fixture({ ownerUid: uid + 1 }); + await expect( + foreign.deps.replaceDnsmasq({ expected: ORIGINAL, next: REWRITTEN }) + ).rejects.toThrow("not a regular file owned by the current user"); + expect(await readFile(foreign.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect((await readdir(join(foreign.root, "etc"))).sort()).toEqual([ + "dnsmasq.conf", + "dnsmasq.d", + ]); +}); + +test("the receipt directory must be private and owned by the current user", async () => { + const shared = await fixture(); + await mkdir(join(shared.home, "native-dns"), { mode: 0o700 }); + await chmod(join(shared.home, "native-dns"), 0o755); + await expect(repairHackLocalRoute(shared.deps)).rejects.toThrow( + "receipt directory is not private" + ); + expect(await readFile(shared.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect(shared.calls.authorize).toHaveLength(0); + + const uid = process.getuid?.() ?? 0; + const foreign = await fixture({ ownerUid: uid + 1 }); + await expect(foreign.deps.acquireLock()).rejects.toThrow( + "receipt directory is not private" + ); +}); + +test("diagnostics never echo raw line contents or control characters", async () => { + const hostile = ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/\u001b[31mSECRET-VALUE" + ); + const f = await fixture({ dnsmasq: hostile }); + const report = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(report).toMatchObject({ status: "refused", reason: "unmanaged-line" }); + expect(report.detail).toBe( + "Line 5 claims .hack.local but is not in the Hack-managed address=/.hack.local/
form." + ); + const message = describeHackLocalRoute(report); + expect(message).not.toContain("\u001b"); + expect(message).not.toContain("SECRET-VALUE"); + + const foreign = analyzeHackLocalDnsmasq({ + content: ORIGINAL.replace( + "address=/.hack.local/172.30.0.2", + "address=/.hack.local/10.1.2.3" + ), + includeFiles: [], + }); + expect(foreign).toMatchObject({ kind: "refused", reason: "foreign-target" }); + expect(JSON.stringify(foreign)).not.toContain("10.1.2.3"); + + const included = analyzeHackLocalDnsmasq({ + content: ORIGINAL, + includeFiles: [ + { + path: "/x/\u001b]0;title\u0007.conf", + content: "address=/.hack.local/1.2.3.4\n", + }, + ], + }); + expect(JSON.stringify(included)).not.toContain("\\u001b"); + expect(JSON.stringify(included)).not.toContain("1.2.3.4"); +}); From 97dca0634082baa62e0dc97db1aafdce05328fb5 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 15:43:48 -0400 Subject: [PATCH 08/11] fix(native): refuse uninspected and unreadable DNS sources in the hack.local repair The hack.local repair now refuses with closed reasons instead of acting on DNS data it never inspected or throwing raw read errors: - uninspected-config-source: dnsmasq.conf or a dnsmasq.d include loads conf-file, conf-script, addn-hosts, hostsdir, servers-file, or a conf-dir other than /etc/dnsmasq.d,*.conf. The scan is shared with the scoped DNS plan (findUninspectedDnsmasqSource), which now also refuses servers-file and conf-script. - unreadable-config: a DNS file is a symlink (O_NOFOLLOW), invalid UTF-8, linked or oversized. Inspection and rollback refuse without echoing the read error or file content. Adds tests for the conf-file repro, every source directive, unreadable main and include files, rollback of an unreadable file, and the three defense-in-depth guards: the post-write hash recheck in restore, replaceManagedLine with zero or two matches, and the null resolver identity guard before removal. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 18 +- src/lib/native-domain-dns-plan.ts | 70 +++++-- src/lib/native-hack-local-route-host.ts | 1 + src/lib/native-hack-local-route.ts | 94 +++++++++- tests/native-domain-dns-plan.test.ts | 9 + tests/native-hack-local-route.test.ts | 231 +++++++++++++++++++++++- 6 files changed, 395 insertions(+), 28 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 8a1c2ad0b..abe2c5efa 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1331,6 +1331,11 @@ in place. It never adds a second line for the suffix, and the `.hack` and `nameserver 127.0.0.1`. The repair proceeds only when all of these hold: - The native relay owner is verified and listening on 127.0.0.1 at its receipted port. +- `dnsmasq.conf`, every `dnsmasq.d/*.conf` include file and the resolver file + are bounded regular UTF-8 files with one link and are not symlinks. +- Neither `dnsmasq.conf` nor an include file loads a DNS source Hack did not + inspect: `conf-file`, `conf-script`, `addn-hosts`, `hostsdir`, `servers-file`, + or a `conf-dir` other than `/etc/dnsmasq.d,*.conf`. - The line has the exact Hack-managed form and is the only `.hack.local` claim, including `dnsmasq.d` include files. - The target is one Hack wrote (`172.30.0.2` or `::1`). @@ -1339,11 +1344,11 @@ in place. It never adds a second line for the suffix, and the `.hack` and - Any existing resolver file contains only `nameserver 127.0.0.1`. Each unmet condition is a closed refusal reason: `relay-not-verified`, -`dnsmasq-config-missing`, `no-managed-line`, `unmanaged-line`, `foreign-target`, -`foreign-resolver`, `old-target-live`, `pending-receipt`, `invalid-receipt`, -`non-interactive`, `declined`, `busy`, `restart-unavailable` or -`inputs-changed`. Refusal details name lines by number and never repeat file -contents. The repair needs interactive confirmation and the native macOS +`dnsmasq-config-missing`, `unreadable-config`, `uninspected-config-source`, +`no-managed-line`, `unmanaged-line`, `foreign-target`, `foreign-resolver`, +`old-target-live`, `pending-receipt`, `invalid-receipt`, `non-interactive`, +`declined`, `busy`, `restart-unavailable` or `inputs-changed`. Refusal details +name files and lines by number and never repeat file contents. The repair needs interactive confirmation and the native macOS administrator prompt; without a terminal it refuses and names `hack doctor --fix` to run in one. Only one repair or rollback runs at a time; a second one refuses with `busy`. After the prompt the repair checks that the dnsmasq launchd job can @@ -1365,7 +1370,8 @@ the relay port. To undo the repair, run `hack global dns deactivate --domain hack.local`. It needs a terminal and refuses with `non-interactive` and the same command -otherwise. It restores the exact earlier line and verifies the whole-file hash +otherwise. It refuses with `unreadable-config` and changes nothing when +`dnsmasq.conf` is not a bounded regular UTF-8 file. It restores the exact earlier line and verifies the whole-file hash against the receipt. It removes the resolver file only if the repair created it and its identity still matches, then restarts dnsmasq. If dnsmasq is not running, or the job cannot be restarted safely, the rollback still restores the diff --git a/src/lib/native-domain-dns-plan.ts b/src/lib/native-domain-dns-plan.ts index 3db702654..c31ff68a1 100644 --- a/src/lib/native-domain-dns-plan.ts +++ b/src/lib/native-domain-dns-plan.ts @@ -204,17 +204,40 @@ function checkRuntimeConfigSource(opts: { } } -function checkConfigSources(opts: NativeDnsPlanOptions): void { +/** A config line that makes dnsmasq load DNS data Hack did not inspect. */ +export interface UninspectedDnsmasqSource { + /** One-based line number in the scanned content. */ + readonly line: number; + /** Lowercased directive name; always `conf-dir` or a SOURCE_DIRECTIVES entry. */ + readonly directive: string; +} + +/** + * Directives that read DNS data from another file, directory or program. + * `conf-dir` is handled separately because the inspected include directory + * itself is allowed. + */ +const SOURCE_DIRECTIVES: ReadonlySet = new Set([ + "conf-file", + "conf-script", + "addn-hosts", + "hostsdir", + "servers-file", +]); + +/** + * Pure scan of one dnsmasq configuration for sources Hack never inspected: + * `conf-file`, `conf-script`, `addn-hosts`, `hostsdir`, `servers-file`, and + * any `conf-dir` other than exactly `,*.conf`. Returns the first + * such line, or null. The result never carries the directive's value, so a + * caller can report it without echoing file contents. + */ +export function findUninspectedDnsmasqSource(opts: { + readonly content: string; + readonly includeDir: string; +}): UninspectedDnsmasqSource | null { const included = `${opts.includeDir},*.conf`; - for (const [index, arg] of opts.dnsmasqArgs.entries()) { - checkRuntimeConfigSource({ - arg, - next: opts.dnsmasqArgs[index + 1], - included, - mainPath: opts.mainConfig.path, - }); - } - for (const rawLine of (opts.mainConfig.content ?? "").split(LINE_BREAK)) { + for (const [index, rawLine] of opts.content.split(LINE_BREAK).entries()) { const line = rawLine.trim(); if (line.startsWith("#")) { continue; @@ -227,13 +250,32 @@ function checkConfigSources(opts: NativeDnsPlanOptions): void { const value = equal < 0 ? "" : line.slice(equal + 1).trim(); if ( (directive === "conf-dir" && value !== included) || - directive === "conf-file" || - directive === "addn-hosts" || - directive === "hostsdir" + SOURCE_DIRECTIVES.has(directive) ) { - refuse("the main configuration loads uninspected DNS files"); + return { line: index + 1, directive }; } } + return null; +} + +function checkConfigSources(opts: NativeDnsPlanOptions): void { + const included = `${opts.includeDir},*.conf`; + for (const [index, arg] of opts.dnsmasqArgs.entries()) { + checkRuntimeConfigSource({ + arg, + next: opts.dnsmasqArgs[index + 1], + included, + mainPath: opts.mainConfig.path, + }); + } + if ( + findUninspectedDnsmasqSource({ + content: opts.mainConfig.content ?? "", + includeDir: opts.includeDir, + }) + ) { + refuse("the main configuration loads uninspected DNS files"); + } } function checkParentClaim(opts: NativeDnsPlanOptions): string { diff --git a/src/lib/native-hack-local-route-host.ts b/src/lib/native-hack-local-route-host.ts index f7a520744..53e61c524 100644 --- a/src/lib/native-hack-local-route-host.ts +++ b/src/lib/native-hack-local-route-host.ts @@ -253,6 +253,7 @@ export function createHackLocalRouteFileDependencies( return { paths: { dnsmasqPath: opts.dnsmasqPath, + includeDir: opts.includeDir, resolverPath: opts.resolverPath, receiptPath: opts.receiptPath, }, diff --git a/src/lib/native-hack-local-route.ts b/src/lib/native-hack-local-route.ts index 768ed120a..73669a950 100644 --- a/src/lib/native-hack-local-route.ts +++ b/src/lib/native-hack-local-route.ts @@ -8,6 +8,7 @@ import { DEFAULT_PROJECT_TLD, } from "../constants.ts"; import { isRecord } from "./guards.ts"; +import { findUninspectedDnsmasqSource } from "./native-domain-dns-plan.ts"; /** * Native repair of the built-in `*.hack.local` route. @@ -72,6 +73,10 @@ export type HackLocalRouteRefusal = | "pending-receipt" | "relay-not-verified" | "dnsmasq-config-missing" + /** A DNS file is not a bounded, unlinked regular UTF-8 file. */ + | "unreadable-config" + /** dnsmasq loads a file, directory or program Hack did not inspect. */ + | "uninspected-config-source" | "no-managed-line" | "unmanaged-line" | "foreign-target" @@ -111,6 +116,8 @@ export interface HackLocalRouteReceipt { export interface HackLocalRoutePaths { readonly dnsmasqPath: string; + /** The include directory dnsmasq loads as `,*.conf`. */ + readonly includeDir: string; readonly resolverPath: string; readonly receiptPath: string; } @@ -335,6 +342,9 @@ type DnsmasqAnalysis = /** Pure classification of the managed line; exported for focused tests. */ export function analyzeHackLocalDnsmasq(opts: { readonly content: string | null; + /** Path of the main configuration, named in diagnostics only. */ + readonly path: string; + readonly includeDir: string; readonly includeFiles: readonly { readonly path: string; readonly content: string; @@ -347,6 +357,26 @@ export function analyzeHackLocalDnsmasq(opts: { detail: "The dnsmasq configuration does not exist.", }; } + // Any file dnsmasq would also load could claim hack.local out of sight, so + // the main file and every inspected include must load nothing else. + for (const file of [ + { path: opts.path, content: opts.content }, + ...opts.includeFiles, + ]) { + const source = findUninspectedDnsmasqSource({ + content: file.content, + includeDir: opts.includeDir, + }); + if (source) { + return { + kind: "refused", + reason: "uninspected-config-source", + detail: printable( + `${file.path} line ${source.line} uses ${source.directive}, which loads DNS data Hack did not inspect; refusing to change the hack.local route.` + ), + }; + } + } const included = opts.includeFiles.find((file) => configLines(file.content).some((line) => claimsHackLocal(line.text)) ); @@ -492,6 +522,35 @@ function refused(opts: { return { status: "refused", ...opts }; } +const UNREADABLE_DETAIL = + "The dnsmasq configuration, an included .conf file or the resolver is not a bounded, unlinked regular UTF-8 file (for example a symlink or invalid UTF-8); refusing to change it."; + +/** + * Read the DNS files an inspection needs. A file that cannot be read safely + * (a symlink rejected by O_NOFOLLOW, invalid UTF-8, a linked or oversized + * file) yields null so the caller refuses with a closed reason. The read + * error itself is dropped: it is never needed and must not leak content. + */ +async function readDnsFiles(deps: HackLocalRouteDependencies): Promise<{ + readonly content: string | null; + readonly includeFiles: readonly { + readonly path: string; + readonly content: string; + }[]; + readonly resolver: string | null; +} | null> { + try { + const [content, includeFiles, resolver] = await Promise.all([ + deps.readDnsmasq(), + deps.readIncludeFiles(), + deps.readResolver(), + ]); + return { content, includeFiles, resolver }; + } catch { + return null; + } +} + /** * Read-only inspection. Preconditions are checked in a fixed order so that a * missing relay never reads host DNS files and a live old target is never @@ -531,12 +590,22 @@ export async function inspectHackLocalRoute( receipt, }); } - const [content, includeFiles, resolver] = await Promise.all([ - deps.readDnsmasq(), - deps.readIncludeFiles(), - deps.readResolver(), - ]); - const analysis = analyzeHackLocalDnsmasq({ content, includeFiles }); + const files = await readDnsFiles(deps); + if (!files) { + return refused({ + reason: "unreadable-config", + detail: UNREADABLE_DETAIL, + paths, + receipt, + }); + } + const { content, includeFiles, resolver } = files; + const analysis = analyzeHackLocalDnsmasq({ + content, + path: paths.dnsmasqPath, + includeDir: paths.includeDir, + includeFiles, + }); if (analysis.kind === "refused") { return refused({ ...analysis, paths, receipt }); } @@ -701,6 +770,7 @@ function samePlan( left.resolverAction === right.resolverAction && left.relayPort === right.relayPort && left.paths.dnsmasqPath === right.paths.dnsmasqPath && + left.paths.includeDir === right.paths.includeDir && left.paths.resolverPath === right.paths.resolverPath && left.paths.receiptPath === right.paths.receiptPath ); @@ -1058,7 +1128,17 @@ async function rollbackUnderLock(opts: { readonly raw: string | null; }): Promise { const { deps, receipt } = opts; - const current = await deps.readDnsmasq(); + let current: string | null; + try { + current = await deps.readDnsmasq(); + } catch { + return { + outcome: "refused", + reason: "unreadable-config", + detail: + "The dnsmasq configuration is not a bounded, unlinked regular UTF-8 file; nothing changed.", + }; + } const samePaths = receipt.dnsmasqPath === deps.paths.dnsmasqPath && receipt.resolverPath === deps.paths.resolverPath; diff --git a/tests/native-domain-dns-plan.test.ts b/tests/native-domain-dns-plan.test.ts index de08a99f1..6ae43bf73 100644 --- a/tests/native-domain-dns-plan.test.ts +++ b/tests/native-domain-dns-plan.test.ts @@ -249,6 +249,15 @@ test("refuses uninspected dnsmasq configuration sources", () => { }) ) ).toThrow("uninspected DNS files"); + for (const directive of ["servers-file", "conf-script"]) { + expect(() => + planNativeDomainDns( + options({ + mainConfig: file(MAIN_CONFIG, `${V4_CONFIG}${directive}=/tmp/x\n`), + }) + ) + ).toThrow("uninspected DNS files"); + } for (const directive of ["addn-hosts", "hostsdir"]) { expect(() => planNativeDomainDns( diff --git a/tests/native-hack-local-route.test.ts b/tests/native-hack-local-route.test.ts index 510ef6d19..634510591 100644 --- a/tests/native-hack-local-route.test.ts +++ b/tests/native-hack-local-route.test.ts @@ -10,6 +10,7 @@ import { readFile, rm, stat, + symlink, unlink, writeFile, } from "node:fs/promises"; @@ -30,6 +31,7 @@ import { inspectHackLocalRoute, parseHackLocalRouteReceipt, repairHackLocalRoute, + replaceManagedLine, reportHackLocalRoute, rollbackHackLocalRoute, selectNativeDnsCommandDomain, @@ -49,6 +51,10 @@ const ORIGINAL = [ "", ].join("\n"); +/** Paths for pure analysis calls only; nothing reads or writes them. */ +const MAIN = "/brew/etc/dnsmasq.conf"; +const INCLUDE_DIR = "/brew/etc/dnsmasq.d"; + const roots: string[] = []; afterEach(async () => { @@ -408,7 +414,12 @@ test("a foreign target refuses", async () => { expect(f.calls.probes).toEqual([]); expect(await readFile(f.dnsmasqPath, "utf8")).toBe(dnsmasq); expect( - analyzeHackLocalDnsmasq({ content: dnsmasq, includeFiles: [] }) + analyzeHackLocalDnsmasq({ + content: dnsmasq, + path: MAIN, + includeDir: INCLUDE_DIR, + includeFiles: [], + }) ).toMatchObject({ kind: "refused", reason: "foreign-target" }); }); @@ -1088,6 +1099,8 @@ test("diagnostics never echo raw line contents or control characters", async () "address=/.hack.local/172.30.0.2", "address=/.hack.local/10.1.2.3" ), + path: MAIN, + includeDir: INCLUDE_DIR, includeFiles: [], }); expect(foreign).toMatchObject({ kind: "refused", reason: "foreign-target" }); @@ -1095,6 +1108,8 @@ test("diagnostics never echo raw line contents or control characters", async () const included = analyzeHackLocalDnsmasq({ content: ORIGINAL, + path: MAIN, + includeDir: INCLUDE_DIR, includeFiles: [ { path: "/x/\u001b]0;title\u0007.conf", @@ -1105,3 +1120,217 @@ test("diagnostics never echo raw line contents or control characters", async () expect(JSON.stringify(included)).not.toContain("\\u001b"); expect(JSON.stringify(included)).not.toContain("1.2.3.4"); }); + +test("a main configuration that loads an uninspected file refuses and changes nothing", async () => { + const f = await fixture(); + const other = join(f.root, "other.conf"); + await writeFile(other, "address=/.hack.local/10.9.9.9\n"); + await writeFile(f.dnsmasqPath, `${ORIGINAL}conf-file=${other}\n`); + const before = await snapshotTree(f.root, []); + + const report = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(report).toMatchObject({ + status: "refused", + reason: "uninspected-config-source", + }); + expect(report.detail).toBe( + `${f.dnsmasqPath} line 9 uses conf-file, which loads DNS data Hack did not inspect; refusing to change the hack.local route.` + ); + expect(report.detail).not.toContain("other.conf"); + expect(report.detail).not.toContain("10.9.9.9"); + + expect(await repairHackLocalRoute(f.deps)).toMatchObject({ + outcome: "refused", + reason: "uninspected-config-source", + }); + expect(f.calls.authorize).toHaveLength(0); + expect(f.calls.probes).toEqual([]); + expect(await snapshotTree(f.root, [])).toEqual(before); +}); + +test("every uninspected dnsmasq source refuses, in the main file or an include", () => { + const analyze = ( + content: string, + includeFiles: readonly { path: string; content: string }[] = [] + ) => + analyzeHackLocalDnsmasq({ + content, + path: MAIN, + includeDir: INCLUDE_DIR, + includeFiles, + }); + const sources = [ + "conf-file=/tmp/other.conf", + "conf-file = /tmp/other.conf", + "CONF-FILE=/tmp/other.conf", + "conf-script=/tmp/emit-config", + "addn-hosts=/tmp/hosts", + "hostsdir=/tmp/hosts.d", + "servers-file=/tmp/servers", + "conf-dir=/tmp/dnsmasq.d,*.conf", + `conf-dir=${INCLUDE_DIR}`, + `conf-dir=${INCLUDE_DIR}/,*.conf`, + ]; + for (const source of sources) { + expect(analyze(`${ORIGINAL}${source}\n`)).toMatchObject({ + kind: "refused", + reason: "uninspected-config-source", + }); + const included = analyze(ORIGINAL, [ + { path: `${INCLUDE_DIR}/user.conf`, content: `${source}\n` }, + ]); + expect(included).toMatchObject({ + kind: "refused", + reason: "uninspected-config-source", + }); + expect(JSON.stringify(included)).not.toContain("/tmp/"); + } + // The inspected include directory and commented directives are allowed. + for (const allowed of [ + `conf-dir=${INCLUDE_DIR},*.conf`, + "#conf-file=/tmp/other.conf", + " # servers-file=/tmp/servers", + ]) { + expect(analyze(`${ORIGINAL}${allowed}\n`)).toMatchObject({ + kind: "line", + target: "172.30.0.2", + }); + } +}); + +test("unreadable DNS files refuse with a closed reason and no file content", async () => { + const marker = "SECRET-MARKER"; + const invalidUtf8 = Buffer.concat([ + Buffer.from(`${ORIGINAL}# ${marker} `), + Buffer.from([0xff, 0xfe, 0x0a]), + ]); + const expectRefusal = async ( + f: Awaited> + ): Promise => { + const before = await snapshotTree(f.root, []); + const report = reportHackLocalRoute(await inspectHackLocalRoute(f.deps)); + expect(report).toMatchObject({ + status: "refused", + reason: "unreadable-config", + }); + const result = await repairHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "refused", + reason: "unreadable-config", + }); + for (const text of [JSON.stringify(report), JSON.stringify(result)]) { + expect(text).not.toContain(marker); + expect(text).not.toContain("10.9.9.9"); + } + expect(f.calls.authorize).toHaveLength(0); + expect(await snapshotTree(f.root, [])).toEqual(before); + }; + + const main = await fixture(); + await writeFile(main.dnsmasqPath, invalidUtf8); + await expectRefusal(main); + + const include = await fixture(); + await writeFile( + join(include.root, "etc", "dnsmasq.d", "bad.conf"), + invalidUtf8 + ); + await expectRefusal(include); + + const linked = await fixture(); + const target = join(linked.root, "elsewhere.conf"); + await writeFile(target, `# ${marker}\naddress=/.hack.local/10.9.9.9\n`); + await symlink(target, join(linked.root, "etc", "dnsmasq.d", "linked.conf")); + await expectRefusal(linked); +}); + +test("rollback refuses an unreadable dnsmasq configuration without writing", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const invalid = Buffer.concat([ + Buffer.from("# SECRET-MARKER "), + Buffer.from([0xc3, 0x28, 0x0a]), + ]); + await writeFile(f.dnsmasqPath, invalid); + const receipt = await readFile(f.deps.paths.receiptPath, "utf8"); + const result = await rollbackHackLocalRoute(f.deps); + expect(result).toMatchObject({ + outcome: "refused", + reason: "unreadable-config", + }); + expect(JSON.stringify(result)).not.toContain("SECRET-MARKER"); + expect(await readFile(f.dnsmasqPath)).toEqual(invalid); + expect(await readFile(f.deps.paths.receiptPath, "utf8")).toBe(receipt); + expect(f.calls.authorize).toHaveLength(1); +}); + +test("replaceManagedLine replaces exactly one whole line or nothing", () => { + const from = "address=/.hack.local/172.30.0.2"; + const to = "address=/.hack.local/127.0.0.1"; + expect(replaceManagedLine({ content: ORIGINAL, from, to })).toBe(REWRITTEN); + expect( + replaceManagedLine({ content: ORIGINAL.replace(`${from}\n`, ""), from, to }) + ).toBeNull(); + expect( + replaceManagedLine({ + content: ORIGINAL.replace(from, `${from}\n${from}`), + from, + to, + }) + ).toBeNull(); + // A longer line that merely starts with the managed text is not a match. + expect( + replaceManagedLine({ + content: ORIGINAL.replace(from, `${from}0`), + from, + to, + }) + ).toBeNull(); +}); + +test("rollback refuses when the restored file does not hash to the receipt", async () => { + const f = await fixture({ resolver: HACK_LOCAL_RESOLVER_CONTENT }); + expect((await repairHackLocalRoute(f.deps)).outcome).toBe("repaired"); + const restartsAfterRepair = f.calls.restart; + // The write reports success but lands bytes other than the receipt's. + const deps: HackLocalRouteDependencies = { + ...f.deps, + replaceDnsmasq: async ({ expected, next }) => + await f.deps.replaceDnsmasq({ expected, next: `${next}# extra\n` }), + }; + await expect(rollbackHackLocalRoute(deps)).rejects.toThrow( + "Restored dnsmasq configuration hash does not match" + ); + expect((await readReceipt(f.deps)).state).toBe("applied"); + expect(f.calls.restart).toBe(restartsAfterRepair); +}); + +test("a created resolver without a recorded identity is never removed", async () => { + const f = await fixture(); + const removals: unknown[] = []; + const deps: HackLocalRouteDependencies = { + ...f.deps, + // The resolver is created, but its identity never reaches the receipt. + createResolver: async (content) => { + await f.deps.createResolver(content); + throw new Error("identity lost"); + }, + removeResolverIfOwned: async (request) => { + removals.push(request); + return true; + }, + }; + await expect(repairHackLocalRoute(deps)).rejects.toThrow( + `${f.resolverPath} was retained because its ownership is unproven` + ); + expect(removals).toEqual([]); + expect(await readFile(f.resolverPath, "utf8")).toBe( + HACK_LOCAL_RESOLVER_CONTENT + ); + expect(await readFile(f.dnsmasqPath, "utf8")).toBe(ORIGINAL); + expect(await readReceipt(f.deps)).toMatchObject({ + state: "rolled-back", + resolverAction: "create", + resolverIdentity: null, + }); +}); From e9f8e08db97b7b780a7a3540690060653760acf5 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 15:43:58 -0400 Subject: [PATCH 09/11] test(macos): cover every Local Network hint call site Only `hack open --json` exercised the hint, so dropping it from the human open path, branch open, projects/status or either doctor path left tests green. Run `hack open`, `hack branch open`, `hack projects` and `hack status` from source with process.platform preloaded as darwin, stub `open` and `docker` on a hermetic PATH, and an isolated HOME/state, asserting one hint for a .local dev host and none for a non-.local host. Doctor probes Docker, resolvers, the keychain and /usr/bin/curl, so its compose and native tails now share `reportDoctorResults` with an injectable hint, and each path's targets come from exported builders that are tested directly. Co-Authored-By: Claude Opus 5.5 --- src/commands/doctor.ts | 89 +++++-- tests/helpers/darwin-platform-preload.ts | 11 + tests/local-network-hint-call-sites.test.ts | 264 ++++++++++++++++++++ 3 files changed, 340 insertions(+), 24 deletions(-) create mode 100644 tests/helpers/darwin-platform-preload.ts create mode 100644 tests/local-network-hint-call-sites.test.ts diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4e6af7b1e..770548368 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -902,23 +902,23 @@ const handleDoctor: CommandHandlerFor = async ({ const hasError = results.some((r) => r.status === "error"); - const browserHintTargets = doctorBrowserHintTargets({ - url: browser.url, - defaultDomain, + const reported = await reportDoctorResults({ + results, + hasError, + json, + browserHintTargets: composeDoctorBrowserHintTargets({ + url: browser.url, + defaultDomain, + }), + renderHuman: async () => { + await renderDoctorSummary(results); + emitSlowChecksNote(results); + await renderMacNote(results); + }, }); - if (json) { - const code = finishDoctorJson({ - results, - hasError, - }); - noteBrowserTargetsForLocalNetworkHint(browserHintTargets); - return code; + if (reported !== null) { + return reported; } - - await renderDoctorSummary(results); - emitSlowChecksNote(results); - await renderMacNote(results); - noteBrowserTargetsForLocalNetworkHint(browserHintTargets); if (!args.options.fix) { await renderRecoveryGuidance(results); } @@ -939,14 +939,53 @@ const handleDoctor: CommandHandlerFor = async ({ return 0; }; -/** The browser check concerns the reported origin, else the default domain. */ -function doctorBrowserHintTargets(opts: { +/** + * Compose doctor's browser check concerns the reported origin, else the + * default domain. + */ +export function composeDoctorBrowserHintTargets(opts: { readonly url: string | null; readonly defaultDomain: string; }): readonly string[] { return [opts.url ?? opts.defaultDomain]; } +/** Native doctor names a browser target only through `--browser-url`. */ +export function nativeDoctorBrowserHintTargets( + url: string | null +): readonly string[] { + return url ? [url] : []; +} + +/** + * Writes the doctor report, then the macOS Local Network hint for the + * browser targets. JSON mode writes the envelope to stdout and returns its + * exit code; the hint goes to stderr afterwards, so the payload is unchanged. + * Human mode runs `renderHuman` and returns null so the caller continues. + * `noteHint` defaults to the process-wide hint; tests inject an emitter. + */ +export async function reportDoctorResults(opts: { + readonly results: readonly TimedCheckResult[]; + readonly hasError: boolean; + readonly json: boolean; + readonly browserHintTargets: readonly string[]; + readonly renderHuman: () => Promise; + readonly noteHint?: (targets: readonly string[]) => boolean; +}): Promise { + const noteHint = opts.noteHint ?? noteBrowserTargetsForLocalNetworkHint; + if (opts.json) { + const code = finishDoctorJson({ + results: opts.results, + hasError: opts.hasError, + }); + noteHint(opts.browserHintTargets); + return code; + } + await opts.renderHuman(); + noteHint(opts.browserHintTargets); + return null; +} + function resolveDoctorStartDir(pathOption: string | undefined): string { return resolve(process.cwd(), pathOption ?? "."); } @@ -1088,14 +1127,16 @@ async function handleNativeDoctor(opts: { } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); - const browserHintTargets = opts.browser.url ? [opts.browser.url] : []; - if (opts.json) { - const code = finishDoctorJson({ results, hasError }); - noteBrowserTargetsForLocalNetworkHint(browserHintTargets); - return code; + const reported = await reportDoctorResults({ + results, + hasError, + json: opts.json, + browserHintTargets: nativeDoctorBrowserHintTargets(opts.browser.url), + renderHuman: () => renderDoctorSummary(results), + }); + if (reported !== null) { + return reported; } - await renderDoctorSummary(results); - noteBrowserTargetsForLocalNetworkHint(browserHintTargets); if (!opts.browser.url) { note( "Pass --browser-url https://your-app.hack.local --browser-result works|fails|permission-denied to check an exact app route and record a separate browser observation.", diff --git a/tests/helpers/darwin-platform-preload.ts b/tests/helpers/darwin-platform-preload.ts new file mode 100644 index 000000000..49c6e287e --- /dev/null +++ b/tests/helpers/darwin-platform-preload.ts @@ -0,0 +1,11 @@ +/** + * Bun `--preload` module for CLI subprocess tests: reports the platform as + * macOS so platform-gated output (such as the Local Network hint) is + * exercised on any host. It changes only `process.platform`; callers must + * also put stub executables (for example `open`) first on PATH so nothing + * real launches. + */ +Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, +}); diff --git a/tests/local-network-hint-call-sites.test.ts b/tests/local-network-hint-call-sites.test.ts new file mode 100644 index 000000000..a9c658a92 --- /dev/null +++ b/tests/local-network-hint-call-sites.test.ts @@ -0,0 +1,264 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { + composeDoctorBrowserHintTargets, + nativeDoctorBrowserHintTargets, + reportDoctorResults, +} from "../src/commands/doctor.ts"; +import { + createLocalNetworkHintEmitter, + LOCAL_NETWORK_HINT_MESSAGE, +} from "../src/lib/local-network-hint.ts"; +import { projectNameFixture } from "./helpers/project-name-fixture.ts"; + +/* + * Each command that prints or opens a browser-facing origin must show the + * macOS Local Network hint once for a `.local` host and never otherwise. + * + * CLI cases run the source CLI with `process.platform` preloaded as darwin, + * so the hint path runs on any host. PATH holds only stub `open` and + * `docker` executables plus system directories: nothing launches a browser + * or reaches Docker, and HOME/state live in a temporary fixture. + */ + +const LOCAL_HOST = "name-fixture.hack.local"; +const PUBLIC_HOST = "name-fixture.hack.gy"; +const CLI = resolve(import.meta.dir, "../index.ts"); +const DARWIN_PRELOAD = resolve( + import.meta.dir, + "helpers/darwin-platform-preload.ts" +); + +let cleanupRoot: string | null = null; + +afterEach(async () => { + if (cleanupRoot) { + await rm(cleanupRoot, { recursive: true, force: true }); + cleanupRoot = null; + } +}); + +function countHints(text: string): number { + return text.split(LOCAL_NETWORK_HINT_MESSAGE).length - 1; +} + +async function writeStub(path: string, body: string): Promise { + await writeFile(path, `#!/bin/sh\n${body}\n`); + await chmod(path, 0o755); +} + +/** Runs the source CLI as darwin against a registered `my-app` project. */ +async function runAsDarwin(opts: { + readonly devHost: string; + readonly args: readonly string[]; +}) { + const fixture = await projectNameFixture(); + cleanupRoot = fixture.root; + const target = await fixture.createProject("app", "my-app"); + await writeFile( + target.project.configFile, + JSON.stringify({ name: "my-app", dev_host: opts.devHost }) + ); + await fixture.writeRegistry([{ ...target.entry, devHost: opts.devHost }]); + + const tools = join(fixture.root, "tools"); + await mkdir(tools); + const openLog = join(fixture.root, "open.log"); + const dockerLog = join(fixture.root, "docker.log"); + await writeStub(join(tools, "open"), `printf '%s\\n' "$*" >> "${openLog}"`); + await writeStub( + join(tools, "docker"), + `printf '%s\\n' "$*" >> "${dockerLog}"\nexit 1` + ); + + const child = Bun.spawn( + [process.execPath, "--preload", DARWIN_PRELOAD, CLI, ...opts.args], + { + cwd: fixture.root, + env: { + ...fixture.env, + PATH: `${tools}:/usr/bin:/bin`, + DOCKER_HOST: `unix://${join(fixture.root, "no-docker.sock")}`, + HACK_NO_INTERACTIVE: "1", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const opened = await readFile(openLog, "utf8").catch(() => ""); + return { code, stdout, stderr, opened }; +} + +const CLI_CASES = [ + { + label: "hack open", + args: ["open", "--prefer", "dev", "--project", "my-app"], + opensBrowser: true, + }, + { + label: "hack branch open", + args: [ + "branch", + "open", + "feature-x", + "--prefer", + "dev", + "--project", + "my-app", + ], + opensBrowser: true, + }, + { label: "hack projects", args: ["projects"], opensBrowser: false }, + { label: "hack status", args: ["status"], opensBrowser: false }, +] as const; + +for (const testCase of CLI_CASES) { + test(`${testCase.label} hints once for a .local host on macOS`, async () => { + const result = await runAsDarwin({ + devHost: LOCAL_HOST, + args: testCase.args, + }); + expect(result.code, result.stderr).toBe(0); + expect(countHints(result.stderr), result.stderr).toBe(1); + expect(result.stdout).not.toContain("Local Network"); + if (testCase.opensBrowser) { + expect(result.opened).toContain(LOCAL_HOST); + expect(result.opened.trim().split("\n")).toHaveLength(1); + } else { + expect(result.stdout).toContain(LOCAL_HOST); + expect(result.opened).toBe(""); + } + }, 20_000); + + test(`${testCase.label} does not hint for a non-.local host`, async () => { + const result = await runAsDarwin({ + devHost: PUBLIC_HOST, + args: testCase.args, + }); + expect(result.code, result.stderr).toBe(0); + expect(countHints(result.stderr)).toBe(0); + if (testCase.opensBrowser) { + expect(result.opened).toContain(PUBLIC_HOST); + } else { + expect(result.stdout).toContain(PUBLIC_HOST); + } + }, 20_000); +} + +// Doctor runs host probes (Docker, resolver, keychain, curl), so its hint +// wiring is checked through the shared report step and each path's targets. + +function captureHints() { + const lines: string[] = []; + const emitter = createLocalNetworkHintEmitter({ + platform: "darwin", + write: (line) => { + lines.push(line); + }, + }); + return { + lines, + noteHint: (targets: readonly string[]) => emitter.note(targets), + }; +} + +async function report(opts: { + readonly json: boolean; + readonly browserHintTargets: readonly string[]; +}) { + const hints = captureHints(); + const events: string[] = []; + const stdout = spyOn(process.stdout, "write").mockImplementation(() => { + events.push("stdout"); + return true; + }); + try { + const code = await reportDoctorResults({ + results: [ + { name: "bun", status: "ok", message: "fixture", durationMs: 0 }, + ], + hasError: false, + json: opts.json, + browserHintTargets: opts.browserHintTargets, + renderHuman: async () => { + events.push("render"); + }, + noteHint: (targets) => { + const shown = hints.noteHint(targets); + if (shown) { + events.push("hint"); + } + return shown; + }, + }); + return { code, events, lines: hints.lines }; + } finally { + stdout.mockRestore(); + } +} + +const DOCTOR_PATHS = [ + { + label: "compose doctor", + local: composeDoctorBrowserHintTargets({ + url: null, + defaultDomain: "hack.local", + }), + other: composeDoctorBrowserHintTargets({ + url: null, + defaultDomain: "hack", + }), + }, + { + label: "compose doctor --browser-url", + local: composeDoctorBrowserHintTargets({ + url: "https://app.hack.local/", + defaultDomain: "hack", + }), + other: composeDoctorBrowserHintTargets({ + url: "https://app.hack/", + defaultDomain: "hack.local", + }), + }, + { + label: "native doctor --browser-url", + local: nativeDoctorBrowserHintTargets("https://app.hack.local/"), + other: nativeDoctorBrowserHintTargets("https://app.hack/"), + }, +] as const; + +for (const path of DOCTOR_PATHS) { + for (const json of [false, true]) { + const mode = json ? "--json" : "human"; + test(`${path.label} (${mode}) hints once for a .local target`, async () => { + const result = await report({ json, browserHintTargets: path.local }); + expect(result.code).toBe(json ? 0 : null); + expect(result.lines).toEqual([`${LOCAL_NETWORK_HINT_MESSAGE}\n`]); + // The hint follows the report, so JSON output is complete before it. + expect(result.events.at(-1)).toBe("hint"); + expect(result.events).toContain(json ? "stdout" : "render"); + }); + + test(`${path.label} (${mode}) does not hint for a non-.local target`, async () => { + const result = await report({ json, browserHintTargets: path.other }); + expect(result.lines).toEqual([]); + expect(result.events).toContain(json ? "stdout" : "render"); + }); + } +} + +test("native doctor without --browser-url names no browser target", async () => { + expect(nativeDoctorBrowserHintTargets(null)).toEqual([]); + const result = await report({ + json: false, + browserHintTargets: nativeDoctorBrowserHintTargets(null), + }); + expect(result.lines).toEqual([]); +}); From 255b7af12ff869f320d472514a7929ca56703100 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 16:04:30 -0400 Subject: [PATCH 10/11] test(storage): pin first file up finalization and isolate harness HOME Add a source CLI regression for a first up with new file material and no saved file material: finalization keeps its completion step, the second finalize pass, and the post-completion file-mount readiness check. The test fails when the retirement condition keeps only the saved-files arm. Native-compose CLI harnesses now give the child a private HOME under the fixture root instead of the contributor's HOME. Co-Authored-By: Claude Opus 5.5 --- tests/helpers/native-compose-command.ts | 4 +- tests/helpers/native-compose-files.ts | 14 +++-- tests/native-compose-file-command.test.ts | 53 +++++++++++++++++++ ...compose-storage-activation-command.test.ts | 3 +- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/tests/helpers/native-compose-command.ts b/tests/helpers/native-compose-command.ts index b928ec72e..2d5c39329 100644 --- a/tests/helpers/native-compose-command.ts +++ b/tests/helpers/native-compose-command.ts @@ -55,6 +55,8 @@ export async function fixture( cleanupGuards.set(root, opts.cleanupAllowed); } await mkdir(join(root, ".hack")); + // Private HOME for child CLIs; the contributor's home is never selected. + await mkdir(join(root, "user-home"), { mode: 0o700 }); await Bun.write( join(root, ".hack/hack.project.json"), JSON.stringify({ @@ -246,7 +248,7 @@ export async function invoke( { cwd: root, env: { - HOME: process.env.HOME, + HOME: join(root, "user-home"), LANG: "C", PATH: `${root}:/usr/bin:/bin`, HACK_HOME: join(root, "home"), diff --git a/tests/helpers/native-compose-files.ts b/tests/helpers/native-compose-files.ts index 76f015624..957b07662 100644 --- a/tests/helpers/native-compose-files.ts +++ b/tests/helpers/native-compose-files.ts @@ -70,6 +70,8 @@ export async function fileCommandFixture(source: unknown = FILE_SOURCE) { const root = join(parent, "checkout"); await mkdir(join(root, ".hack"), { recursive: true }); await mkdir(join(parent, "home"), { mode: 0o700 }); + // Private HOME for child CLIs; the contributor's home is never selected. + await mkdir(join(parent, "user-home"), { mode: 0o700 }); await Bun.write( join(root, ".hack/hack.project.json"), JSON.stringify(source) @@ -152,7 +154,9 @@ await fail(); } export function spawnFiles( fixture: { readonly parent: string; readonly root: string }, - args = ["up", "--detach", "--json"] + args = ["up", "--detach", "--json"], + /** Diagnostics only, such as the passive phase trace. */ + extraEnv: Readonly> = {} ) { const child = Bun.spawn( [ @@ -165,7 +169,7 @@ export function spawnFiles( { cwd: fixture.root, env: { - HOME: process.env.HOME, + HOME: join(fixture.parent, "user-home"), LANG: "C", PATH: `${fixture.parent}:/usr/bin:/bin`, HACK_HOME: join(fixture.parent, "home"), @@ -176,6 +180,7 @@ export function spawnFiles( HACK_COMPOSE_STARTUP_TIMEOUT_MS: "3000", CI: "1", HACK_EXECUTION_MODE: "non_interactive", + ...extraEnv, }, stdin: "ignore", stdout: "pipe", @@ -187,9 +192,10 @@ export function spawnFiles( } export async function invokeFiles( fixture: { readonly parent: string; readonly root: string }, - args = ["up", "--detach", "--json"] + args = ["up", "--detach", "--json"], + extraEnv: Readonly> = {} ) { - const child = spawnFiles(fixture, args); + const child = spawnFiles(fixture, args, extraEnv); const timer = setTimeout(() => child.kill("SIGKILL"), 45_000); try { const [code, stdout, stderr] = await Promise.all([ diff --git a/tests/native-compose-file-command.test.ts b/tests/native-compose-file-command.test.ts index 87057b2e5..9196a2356 100644 --- a/tests/native-compose-file-command.test.ts +++ b/tests/native-compose-file-command.test.ts @@ -166,6 +166,59 @@ test("source CLI delivers actual acquired binary, managed and empty files with e expect(await fileJournal(ready.document)).toContain('"phase":"retired"'); await assertFileTransport(fixture); }, 120_000); +// Finalization may take its single proof pass only when the file retirement +// step would merely read. A first up has new file material and no saved file +// material: its completion step must still run, so the post-completion +// ownership pass re-observes the exact file mounts (files.assertReady) and the +// second finalize witness pass follows it. +test("first file up without saved file material keeps the completion step, post-completion file check and second finalize pass", async () => { + const fixture = await fileCommandFixture(); + const up = await invokeFiles(fixture, undefined, { + HACK_NATIVE_COMPOSE_PHASE_TRACE: "1", + }); + expect(up.code).toBe(0); + const phases = up.stderr + .split("\n") + .filter((line) => line.startsWith('{"diagnostic":"native-compose-phase",')) + .map((line) => JSON.parse(line) as { phase: string; boundary: string }) + .filter((row) => row.boundary === "begin") + .map((row) => row.phase); + const pass = [ + "finalize.fresh", + "finalize.generation", + "finalize.owned", + "finalize.remember-storage", + "finalize.witnesses", + "finalize.pending", + "finalize.pending-check", + ]; + expect(phases.slice(phases.indexOf("finalize.fresh"))).toEqual([ + ...pass, + "finalize.before-complete", + ...pass, + "finalize.save", + ]); + // Exact file-mount observations after the engine start: workload readiness, + // the first finalize pass's unbound-root check, the completion step's + // readiness check and the post-completion ownership pass's readiness check. + // Without the completion step only the first two remain. + const requests = await assertFileTransport(fixture); + const started = requests.findIndex((args) => args[0] === "compose"); + expect(started).toBeGreaterThanOrEqual(0); + expect( + requests + .slice(started) + .filter( + (args) => + args[0] === "container" && + args[1] === "inspect" && + String(args[3]).includes('"mounts":{{json .Mounts}}') + ) + ).toHaveLength(4); + const ready = await fileCommandState(fixture.root); + expect(ready.pending).toBeNull(); + expect(ready.current.stopped).toBe(false); +}, 120_000); test("unchanged authored file up acquires new bytes and retires its prior snapshot instead of reusing a warm generation", async () => { const fixture = await fileCommandFixture(); expect((await invokeFiles(fixture)).code).toBe(0); diff --git a/tests/native-compose-storage-activation-command.test.ts b/tests/native-compose-storage-activation-command.test.ts index 131162a95..e44bc003c 100644 --- a/tests/native-compose-storage-activation-command.test.ts +++ b/tests/native-compose-storage-activation-command.test.ts @@ -71,7 +71,8 @@ async function ownedColdCase( ], cwd: root, env: { - HOME: process.env.HOME ?? root, + // A private HOME under the fixture, never the contributor's. + HOME: join(root, "user-home"), LANG: "C", PATH: `${root}:/usr/bin:/bin`, HACK_HOME: join(root, "home"), From d320f20bbd453882e93db62a68687e9519a4b679 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 16:12:10 -0400 Subject: [PATCH 11/11] fix(native): count user-declared runtime metadata in hostname changes Mask the reserved HACK_* runtime metadata keys only in the normalized Compose document, where Hack generates them. Values declared in .hack/docker-compose.yml stay literals, so changing them still refuses a retaining hostname-change restart. Add regression tests for the restart preflight route check and for handleNativeUp passing the runtime metadata selection to native startup. Scope the consumer guidance to Compose-backed and native image projects, regenerate the checked-in surfaces, and document that selecting or removing a non-443 HTTPS frontend also refuses a retained restart. Co-Authored-By: Claude Opus 5.5 --- .codex/skills/hack-cli/SKILL.md | 4 +- .cursor/rules/hack.mdc | 2 +- docs/cli.md | 10 +- docs/guides/native-candidate.md | 12 +- examples/basic/AGENTS.md | 4 +- examples/basic/CLAUDE.md | 4 +- .../src/project/hostname_change.rs | 68 +++++++- plugins/hack/rules/hack.mdc | 2 +- plugins/hack/skills/hack-cli/SKILL.md | 4 +- src/agents/instruction-source.ts | 2 +- src/agents/integration-ownership.ts | 2 + src/agents/integration-revision.ts | 2 +- tests/native-project-start.test.ts | 48 ++++++ ...tive-up-runtime-metadata-selection.test.ts | 147 ++++++++++++++++++ 14 files changed, 285 insertions(+), 26 deletions(-) create mode 100644 tests/native-up-runtime-metadata-selection.test.ts diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 28776a4d0..60646d6f2 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -14,7 +14,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `51163ae32e56` (version alone is not a freshness guarantee). +- Content revision: `7c86dd4dda99` (version alone is not a freshness guarantee). ## Product boundary @@ -159,7 +159,7 @@ Use `hack` as the primary interface for local-first development. - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Project targeting diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index 2dcc4e8f2..02bce0726 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -9,7 +9,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `51163ae32e56` (version alone is not a freshness guarantee). +- Content revision: `7c86dd4dda99` (version alone is not a freshness guarantee). ## Product boundary diff --git a/docs/cli.md b/docs/cli.md index e12326214..4a3244459 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -450,10 +450,12 @@ port; host variables never do. Native-specific limits: - A graph whose data was retained by an earlier release is restored exactly as it was admitted and does not gain these variables. It receives them after it is next started without retained data. - These values are part of the retained graph's reviewed input. A retaining restart that changes - only route hostnames (for example a domain migration) remains supported, because the metadata - keys are excluded from the hostname-change fingerprint. Changing the native HTTPS port alone - changes the metadata without a hostname change, so the retained graph refuses to start with the - new port; start it with its original port. + only route hostnames (for example a domain migration) remains supported, because the generated + metadata keys are excluded from the hostname-change fingerprint. Values you declare for these + keys in `.hack/docker-compose.yml` still count. Changing the native HTTPS port, or selecting or + removing the native HTTPS frontend when its port is not 443, changes the metadata without a + hostname change, so the retained graph refuses to start. Start it with its original HTTPS + frontend selection and port. - Authored `.hack/hack.project.json` configuration does not deliver these variables yet, on either backend. diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index cc5cb9bd9..139a4f84d 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -470,11 +470,17 @@ New normalized shared-source graphs also capture a separate hostname-change contract. A retaining restart can change only the literal hostnames of already routed services: the service set, upstream ports, TLS settings, probes, commands, environment declarations, images, mounts, networks and volumes must stay the same. -The one exception is Hack's reserved +The one exception is the reserved [runtime host metadata](../cli.md#runtime-host-metadata) keys (`HACK_BRANCH`, `HACK_COMPOSE_PROJECT`, `HACK_DEV_*`, `HACK_ALIAS_*`, `HACK_SERVICE_*` and -`HACK_RUNTIME_METADATA`): their public values are derived from the hostnames, so -the fingerprint ignores them. A metadata change without a hostname change still refuses. +`HACK_RUNTIME_METADATA`) that Hack generates into the normalized Compose: their +public values are derived from the hostnames, so the normalized fingerprint ignores +them. A value you declare for one of these keys in `.hack/docker-compose.yml` is an +ordinary literal, so changing it still refuses. A metadata change without a hostname +change also still refuses. This includes changing `HACK_NATIVE_HTTPS_PORT`, and also +selecting or removing the native HTTPS frontend when its port is not 443, because the +advertised URLs gain or lose that port. Start the retained graph with the HTTPS +frontend selection and port it was admitted with. Both original and normalized Compose declarations are checked; only fingerprints are retained. Changed dependency-cache inputs still refuse. The old ownership plan and named data identities remain fixed, while normalized input provenance and diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index c5fb011c0..1dde1356a 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `51163ae32e56` (version alone is not a freshness guarantee). +- Content revision: `7c86dd4dda99` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -135,7 +135,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Project targeting: - From repo root, commands use that project automatically. diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index c5fb011c0..1dde1356a 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `51163ae32e56` (version alone is not a freshness guarantee). +- Content revision: `7c86dd4dda99` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -135,7 +135,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Project targeting: - From repo root, commands use that project automatically. diff --git a/packages/runtime-core/src/project/hostname_change.rs b/packages/runtime-core/src/project/hostname_change.rs index 4d81b78e4..fdefac2b8 100644 --- a/packages/runtime-core/src/project/hostname_change.rs +++ b/packages/runtime-core/src/project/hostname_change.rs @@ -5,9 +5,11 @@ use serde_json::Value; use sha2::{Digest, Sha256}; /// Hack-owned public runtime host metadata keys (the CLI's runtime host metadata -/// contract). Their values are derived from the effective route hostnames, branch and -/// HTTPS ingress port, so a hostname change necessarily changes them. Like the route -/// hostnames they are excluded from the fingerprint; every other literal still counts. +/// contract). Hack generates them only into the normalized document, with values derived +/// from the effective route hostnames, branch and HTTPS ingress port, so a hostname +/// change necessarily changes them. They are excluded from the normalized document's +/// fingerprint only: a value the user declares in the original Compose file is a literal +/// like any other and still counts (Hack never overrides a declared key). const RUNTIME_METADATA_KEYS: [&str; 10] = [ "HACK_ALIAS_HOST", "HACK_ALIAS_URL", @@ -34,12 +36,14 @@ fn without_runtime_metadata(service: &mut Value) { } } -fn masked(bytes: &[u8]) -> Option { +fn masked(bytes: &[u8], generated_metadata: bool) -> Option { let mut value = yaml::parse(bytes).ok()?; let services = value.get_mut("services")?.as_object_mut()?; let mut routed = false; for service in services.values_mut() { - without_runtime_metadata(service); + if generated_metadata { + without_runtime_metadata(service); + } let Some(labels) = service.get_mut("labels") else { continue; }; @@ -68,8 +72,8 @@ fn masked(bytes: &[u8]) -> Option { /// Unsupported original routing simply cannot opt into future hostname changes. /// Both documents matter: normalization must not conceal unrelated original edits. pub(super) fn fingerprint(original: &[u8], normalized: &[u8]) -> Option { - let original = masked(original)?; - let normalized = masked(normalized)?; + let original = masked(original, false)?; + let normalized = masked(normalized, true)?; let bytes = zeroize::Zeroizing::new( serde_json::to_vec(&("hack-hostname-raw-v1", original, normalized)).ok()?, ); @@ -175,4 +179,54 @@ mod tests { ); } } + + #[test] + fn user_declared_metadata_keys_still_count_in_the_original() { + let declared = |host: &str, url: &str| { + BASE.replace("caddy: web.hack", &format!("caddy: {host}")) + .replace( + "environment: {TOKEN: literal}", + &format!("environment: {{TOKEN: literal, HACK_DEV_URL: '{url}'}}"), + ) + }; + // Hack skips a declared key, so the normalized document carries the user's value. + let before = declared("web.hack", "https://custom.example"); + let expected = fingerprint(before.as_bytes(), before.as_bytes()).unwrap(); + let moved = declared("web.v5.hack.gy", "https://custom.example"); + assert_eq!( + fingerprint(moved.as_bytes(), moved.as_bytes()), + Some(expected.clone()) + ); + let edited = declared("web.v5.hack.gy", "https://other.example"); + assert_ne!( + fingerprint(edited.as_bytes(), edited.as_bytes()), + Some(expected) + ); + let list = |url: &str| { + format!( + "services: {{web: {{environment: ['HACK_DEV_URL={url}'], labels: ['caddy=a.hack', 'caddy.reverse_proxy={{{{upstreams 80}}}}', 'caddy.tls=internal']}}}}" + ) + }; + let plain = list("https://custom.example"); + let edited = list("https://other.example"); + assert_ne!( + fingerprint(plain.as_bytes(), plain.as_bytes()), + fingerprint(edited.as_bytes(), edited.as_bytes()) + ); + // Generated metadata differences in the normalized document still do not count. + let base = fingerprint(BASE.as_bytes(), BASE.as_bytes()).unwrap(); + for (host, port) in [("web.hack", ""), ("web.v5.hack.gy", ":8443")] { + let original = BASE.replace("caddy: web.hack", &format!("caddy: {host}")); + let generated = original.replace( + "environment: {TOKEN: literal}", + &format!( + "environment: {{TOKEN: literal, HACK_DEV_URL: 'https://{host}{port}', HACK_DEV_HOST: {host}}}" + ), + ); + assert_eq!( + fingerprint(original.as_bytes(), generated.as_bytes()), + Some(base.clone()) + ); + } + } } diff --git a/plugins/hack/rules/hack.mdc b/plugins/hack/rules/hack.mdc index e184f0ef9..0476d0826 100644 --- a/plugins/hack/rules/hack.mdc +++ b/plugins/hack/rules/hack.mdc @@ -14,7 +14,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `51163ae32e56` (version alone is not a freshness guarantee). +- Content revision: `7c86dd4dda99` (version alone is not a freshness guarantee). ## Product boundary diff --git a/plugins/hack/skills/hack-cli/SKILL.md b/plugins/hack/skills/hack-cli/SKILL.md index bbf2ff1ba..1e276cf65 100644 --- a/plugins/hack/skills/hack-cli/SKILL.md +++ b/plugins/hack/skills/hack-cli/SKILL.md @@ -14,7 +14,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `51163ae32e56` (version alone is not a freshness guarantee). +- Content revision: `7c86dd4dda99` (version alone is not a freshness guarantee). ## Product boundary @@ -159,7 +159,7 @@ Use `hack` as the primary interface for local-first development. - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Project targeting diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 25a1cb569..6007a128d 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -253,7 +253,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch.", "Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`).", "Linked worktrees pick a branch instance automatically (see Linked git worktrees).", - "Containers receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.", + "Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.", ], }, diff --git a/src/agents/integration-ownership.ts b/src/agents/integration-ownership.ts index 4156a6251..63b5b68b6 100644 --- a/src/agents/integration-ownership.ts +++ b/src/agents/integration-ownership.ts @@ -106,6 +106,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "121d8f26374d24f2a09890a3", // v5.0.0-next.3..next "6449442a8f230412d4e5f9e7", // v5.0.0-next.4..next "d83da046cf7541ba30950880", // v5.0.0-next.4..next (offline env-matrix pointer) + "0406c99877ff104fd57b4a14", // v5.0.0-next.4..next (runtime metadata backend scope) ]), "hack-init-skill": new Set([ "cd15fb6d8ed8e25071d05e75", // v3.1.0..v4.0.3 @@ -133,6 +134,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "06cf6d0920afc1780b9946ba", // v5.0.0-next.3..next "cf0a31e197f9034485413674", // v5.0.0-next.4..next "3a99c9097855f93808e482cb", // v5.0.0-next.4..next (offline env-matrix pointer) + "e449b8956a07989244762940", // v5.0.0-next.4..next (runtime metadata backend scope) ]), }; diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index d418edef4..49a9a7378 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "51163ae32e56"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "7c86dd4dda99"; diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 8f8d9e8c7..1d53d4be3 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -2760,3 +2760,51 @@ test("startup without a runtime metadata selection leaves the reviewed graph unc expect(reviewed).not.toContain("HACK_"); expect(saved && Object.hasOwn(saved, "runtimeMetadata")).toBe(false); }); +test("restart preflight refuses runtime host metadata that differs from reviewed routes", async () => { + const { opts } = await httpsFixture(); + const review = opts.dependencies.review!; + const run: NativeProjectRun = { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + runtimeMetadata: true, + }; + const preflight = (hostnames: readonly string[]) => + preflightNativeRestart({ + runtime: opts.runtime, + scope: opts.scope, + composeFile: opts.composeFile, + runtimeMetadata, + httpsPort: httpsSelection.httpsPort, + run, + dependencies: { + prepare: opts.dependencies.prepare, + review: async (request) => + await review({ + ...request, + run: async (checked) => + await request.run({ + ...checked, + report: { + plan: { + enrollment_compatible: true, + services: { + web: { active: true, routing: { port: 3000, hostnames } }, + }, + }, + }, + }), + }), + dependencies: async () => [], + invoke: restartPreflightInvoke, + }, + }); + await preflight(["web.example.com"]); + await expect(preflight(["other.example.com"])).rejects.toThrow( + "Native runtime metadata" + ); +}); diff --git a/tests/native-up-runtime-metadata-selection.test.ts b/tests/native-up-runtime-metadata-selection.test.ts new file mode 100644 index 000000000..a053c22a3 --- /dev/null +++ b/tests/native-up-runtime-metadata-selection.test.ts @@ -0,0 +1,147 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, +} from "../src/constants.ts"; +import { restoreEnv } from "./helpers/env.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +// `hack up` on the native backend must hand startNativeProject the public runtime +// metadata selection; without it native containers silently lose HACK_DEV_URL etc. +const startups: Record[] = []; +const startMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/native-project-start.ts", + overrides: { + startNativeProject: async (opts: Record) => { + startups.push(opts); + return 0; + }, + }, +}); +// Keep this a legacy Compose project test: no authored or adopted dispatch. +const authoredMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/native-compose-command.ts", + overrides: { tryNativeComposeCommand: async () => null }, +}); +const branchesMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/branches.ts", + overrides: { + resolveEffectiveBranch: async () => ({ + branch: null, + source: "none", + gitBranch: null, + }), + }, +}); +const { upCommand } = await import("../src/commands/project.ts"); + +const ENV_KEYS = [ + "HACK_HOME", + "HACK_RUNTIME_BACKEND", + "HACK_NATIVE_BINARY", + "HACK_NATIVE_HOME", + "HACK_NATIVE_HTTPS_PORT", + "HACK_NATIVE_CADDY_BINARY", + "HACK_NATIVE_CADDY_SHA256", +] as const; +const savedEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); +const roots: string[] = []; + +beforeAll(() => { + startMock.activate(); + authoredMock.activate(); + branchesMock.activate(); +}); +afterEach(async () => { + startups.length = 0; + for (const key of ENV_KEYS) { + restoreEnv(key, savedEnv.get(key)); + } + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +afterAll(() => { + startMock.deactivate(); + authoredMock.deactivate(); + branchesMock.deactivate(); +}); + +async function nativeProject(config: Record) { + const root = await mkdtemp(join(tmpdir(), "native-up-metadata-")); + roots.push(root); + await mkdir(join(root, ".hack")); + await writeFile( + join(root, ".hack", PROJECT_COMPOSE_FILENAME), + "name: metadata-compose\nservices:\n web:\n image: alpine:3.20\n" + ); + await writeFile( + join(root, ".hack", PROJECT_CONFIG_FILENAME), + JSON.stringify(config) + ); + for (const key of ENV_KEYS) { + Reflect.deleteProperty(process.env, key); + } + process.env.HACK_HOME = join(root, ".global-hack"); + process.env.HACK_RUNTIME_BACKEND = "native"; + process.env.HACK_NATIVE_BINARY = join(root, "hack-local"); + process.env.HACK_NATIVE_HOME = join(root, "native-home"); + return root; +} + +async function up(root: string, branch?: string): Promise { + const argv = ["--path", root, ...(branch ? ["--branch", branch] : [])]; + const input = { + ctx: { cwd: root, cli: CLI_SPEC }, + args: { + options: { + path: root, + project: undefined, + env: undefined, + branch, + detach: false, + profile: undefined, + target: undefined, + json: false, + }, + positionals: { services: [] }, + raw: { argv, positionals: [] }, + }, + } as unknown as Parameters[0]; + return await upCommand.handler(input); +} + +test("native up passes the base dev host, OAuth alias and Compose project name", async () => { + const root = await nativeProject({ + name: "metadata-config", + dev_host: "meta.hack", + oauth: { enabled: true, tld: "gy" }, + }); + expect(await up(root, "feature-a")).toBe(0); + expect(startups).toHaveLength(1); + const startup = startups[0]; + expect(startup?.scope).toMatchObject({ branch: "feature-a" }); + expect(startup?.runtimeMetadata).toEqual({ + devHost: "meta.hack", + aliasHost: "meta.hack.gy", + projectName: "metadata-compose", + }); +}); + +test("native up without an OAuth alias passes a null alias host", async () => { + const root = await nativeProject({ dev_host: "plain.hack" }); + expect(await up(root)).toBe(0); + expect(startups[0]?.scope).toMatchObject({ branch: null }); + expect(startups[0]?.runtimeMetadata).toEqual({ + devHost: "plain.hack", + aliasHost: null, + projectName: "metadata-compose", + }); +});