From 3d26b784ac67ad3c2678dd1815394cde9c73a3a2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 02:05:04 -0400 Subject: [PATCH] fix(native): re-observe a sandbox-less bridge endpoint instead of refusing adopted Compose (HACK-1421) Docker lists an endpoint that is not joined to a sandbox as `ep-` in a bridge's `.Containers`, for a moment on every container start and exit. The bridge read takes no container lock, so the retained-resource binding proof could see that key while a job or service changed state. The member check accepted only 64-hex container IDs, so it refused the binding identity immediately. Inside an adopted start, that refusal surfaces as a closed E_CONFIG_INVALID with no ordered-refusal detail, which matches the flaky native-compose-adoption-job-worktrees failure at alpha-start-7. A placeholder member is now never attributed, admitted or saved. It marks that bridge read as transient, and the existing snapshot loop discards the observation and re-reads under the same aggregate deadline and output allowance. A placeholder that persists until the deadline still refuses, and any other non-ID key still refuses identity at once. The completed-job scenario's cli-result line also names a fixed refusal message by closed token (refusal=legacy-state, legacy-changed, input-access, ...), so a recurrence says which owner refused without any value. Co-Authored-By: Claude Opus 5.5 --- docs/reference/native-compose-adoption.md | 4 + src/lib/native-compose-adoption-binding.ts | 73 ++++++++++++----- ...native-compose-adoption-job-diagnostics.ts | 56 +++++++++++-- tests/native-compose-adoption-binding.test.ts | 75 ++++++++++++++++++ ...e-compose-adoption-job-diagnostics.test.ts | 79 ++++++++++++++++++- 5 files changed, 259 insertions(+), 28 deletions(-) diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 88b353453..08025937e 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -354,6 +354,10 @@ Container observations bracket the active-member read. If an original changes running state while all immutable facts remain verified, the mixed observation is discarded within the same aggregate deadline and output allowance. Foreign members, replaced identities and stable membership mismatches refuse immediately. +Docker keys an endpoint that is not joined to a sandbox as `ep-`. This +happens for a moment while any container starts or exits. Such a member is never +attributed, admitted or saved: the observation is discarded and re-read within +the same deadline, and a placeholder that persists until the deadline refuses. This is not an atomic daemon snapshot: delayed endpoint removal can still refuse, and fresh successful job completion remains the scheduler's separate decision. The Docker routing environment is captured for later comparison. Queries never diff --git a/src/lib/native-compose-adoption-binding.ts b/src/lib/native-compose-adoption-binding.ts index 100f2b24d..b7dead20d 100644 --- a/src/lib/native-compose-adoption-binding.ts +++ b/src/lib/native-compose-adoption-binding.ts @@ -68,6 +68,13 @@ import { import type { LegacyComposeSourceBindIntent } from "./native-config-import-storage.ts"; const ID = /^[a-f0-9]{64}$/; +/** + * Docker lists an endpoint that is not joined to a sandbox as `ep-` + * in a bridge's `.Containers`. That happens for a moment on every container + * start (endpoint created, sandbox not yet joined) and exit (sandbox left, + * endpoint not yet deleted), and the bridge read takes no container lock. + */ +const ENDPOINT_PLACEHOLDER = /^ep-[a-f0-9]{64}$/; const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/; const ENGINE_ID = /^[a-zA-Z0-9:._-]{1,128}$/; const TIME = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; @@ -708,6 +715,28 @@ function containerRows( }) .sort((a, b) => a.service.localeCompare(b.service)); } +/** + * Split a bridge's `.Containers` keys into container IDs and a transient flag. + * A placeholder is never attributed, admitted or retained: it only marks this + * bridge read as mid-transition so the caller re-observes under its original + * deadline. Any other non-ID key, or a duplicate, refuses as before. + */ +function bridgeMembers(value: unknown): { + readonly ids: readonly string[]; + readonly transient: boolean; +} { + requireValue( + Array.isArray(value) && + value.every( + (key) => + typeof key === "string" && + (ID.test(key) || ENDPOINT_PLACEHOLDER.test(key)) + ) && + new Set(value).size === value.length + ); + const ids = value.filter((key: string) => ID.test(key)); + return { ids, transient: ids.length !== value.length }; +} function networkRow( rows: readonly Record[], intent: LegacyComposeStorageIntent @@ -736,11 +765,9 @@ function networkRow( timestamp(row.createdAt) && row.driver === "bridge" && row.scope === "local" && - row.internal === (intent.ownedNetwork?.internal ?? false) && - Array.isArray(row.containers) && - row.containers.every((id) => typeof id === "string" && ID.test(id)) && - new Set(row.containers).size === row.containers.length + row.internal === (intent.ownedNetwork?.internal ?? false) ); + const members = bridgeMembers(row.containers); return { network: { id: row.id, @@ -753,7 +780,8 @@ function networkRow( } : {}), }, - containerIds: row.containers, + containerIds: members.ids, + transient: members.transient, }; } function pluralNetworkRows( @@ -762,7 +790,9 @@ function pluralNetworkRows( ): { readonly networks: readonly LegacyComposeVerifiedNetwork[]; readonly members: ReadonlyMap; + readonly transient: boolean; } { + let transient = false; const declared = intent.ownedNetworks?.networks; requireValue(declared && declared.length === 2 && rows.length === 2); requireValue( @@ -794,7 +824,8 @@ function pluralNetworkRows( ]); const expected = declared.find((network) => network.name === row.name); const id = row.id; - const rowMembers = row.containers; + const rowMembers = bridgeMembers(row.containers); + transient ||= rowMembers.transient; requireValue( expected && typeof id === "string" && @@ -805,16 +836,11 @@ function pluralNetworkRows( row.internal === expected.internal && row.driver === "bridge" && row.scope === "local" && - timestamp(row.createdAt) && - Array.isArray(rowMembers) && - rowMembers.every( - (member) => typeof member === "string" && ID.test(member) - ) && - new Set(rowMembers).size === rowMembers.length + timestamp(row.createdAt) ); ids.add(id); names.add(expected.name); - members.set(expected.logical, [...rowMembers].sort()); + members.set(expected.logical, [...rowMembers.ids].sort()); return { id, name: expected.name, @@ -824,7 +850,7 @@ function pluralNetworkRows( }; }) .sort((a, b) => a.logical.localeCompare(b.logical)); - return { networks, members }; + return { networks, members, transient }; } async function engine(probe: Probe) { const rows = lines( @@ -981,8 +1007,14 @@ async function inspectResources( ) ); } - return { plural, single }; + return { + plural, + single, + transient: plural?.transient === true || single?.transient === true, + }; }; + // The first bridge read only fixes identity and refuses foreign members; the + // loop below always re-reads, so a placeholder here needs no extra scan. let { plural, single } = await readNetwork(); const networkIdentity = JSON.stringify(plural?.networks ?? single?.network); let ingress: NativeComposeIngressBinding | undefined; @@ -1019,18 +1051,21 @@ async function inspectResources( let containerFacts: readonly Record[]; let containers: LegacyComposeVerifiedContainer[]; for (;;) { - ({ plural, single } = await readNetwork()); + let transient: boolean; + ({ plural, single, transient } = await readNetwork()); requireValue( JSON.stringify(plural?.networks ?? single?.network) === networkIdentity ); const after = await readContainers(); // Both scans validate all labels, mounts and configured attachments against - // the same originals. Only running state is transient; stopped aliases may - // be absent under the existing contract. No completion authority is granted. + // the same originals. Only running state and a sandbox-less endpoint + // placeholder are transient; stopped aliases may be absent under the + // existing contract. No completion authority is granted. requireValue(JSON.stringify(after.containers) === containerIdentity); if ( + transient || JSON.stringify(before.facts.map((row) => [row.id, row.running])) !== - JSON.stringify(after.facts.map((row) => [row.id, row.running])) + JSON.stringify(after.facts.map((row) => [row.id, row.running])) ) { // Reuse this aggregate probe's original deadline and output allowance. // Never accept mixed membership, reset a timer, or retry an identity error. diff --git a/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts b/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts index 1e8dbc068..4bc581022 100644 --- a/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts +++ b/tests/e2e/scenarios/native-compose-adoption-job-diagnostics.ts @@ -1,5 +1,8 @@ import { isRecord } from "../../../src/lib/guards.ts"; import type { LegacyComposeOrderedRefusal } from "../../../src/lib/native-compose-adoption-diagnostics.ts"; +import { LegacyComposeAdoptedGenerationError } from "../../../src/lib/native-compose-adoption-generation.ts"; +import { LEGACY_ADOPTION_SELECTION_MESSAGES } from "../../../src/lib/native-compose-adoption-marker.ts"; +import { ProjectInputAccessError } from "../../../src/lib/project-input-selection.ts"; import type { CliResult } from "../harness.ts"; const STAGES = [ @@ -15,6 +18,7 @@ const STAGES = [ type Stage = (typeof STAGES)[number]; const CODES = [ "E_CONFIG_INVALID", + "E_PERMISSION", "E_LIFECYCLE_FAILED", "E_STATE", "E_NATIVE_COMPOSE_ADOPTION", @@ -58,6 +62,46 @@ const SCHEDULER_REASONS: readonly SchedulerReason[] = [ "completion-authority", ]; const MAX_REPLY_BYTES = 64 * 1024; +/** + * Closed refusal tokens for the fixed, value-free messages an adopted start can + * end with (HACK-1421). Only exact matches map to a token; the message itself + * is never emitted, and any other text is `other`. + */ +const REFUSALS: readonly (readonly [string, string])[] = [ + ...( + [ + ["legacy-state", "E_LEGACY_ADOPTION_STATE"], + ["legacy-busy", "E_LEGACY_ADOPTION_BUSY"], + ["legacy-changed", "E_LEGACY_ADOPTION_CHANGED"], + ["legacy-unsupported", "E_LEGACY_ADOPTION_UNSUPPORTED"], + ["legacy-cancelled", "E_LEGACY_ADOPTION_CANCELLED"], + ["legacy-deadline", "E_LEGACY_ADOPTION_DEADLINE"], + ["legacy-literal-env", "E_LEGACY_ADOPTION_LITERAL_ENV"], + ] as const + ).map( + ([token, code]) => + [token, new LegacyComposeAdoptedGenerationError(code).message] as const + ), + [ + "adopted-refused", + "Adopted Compose ownership or execution refused; original data is retained. Values omitted.", + ], + [ + "adopted-cancelled", + "Adopted Compose operation cancelled; pending ownership is retained. Values omitted.", + ], + ["marker-adoption", LEGACY_ADOPTION_SELECTION_MESSAGES.adoption], + ["marker-permission", LEGACY_ADOPTION_SELECTION_MESSAGES.permission], + // The marker's input-access message is the shared project-input guard's. + ["input-access", new ProjectInputAccessError("unreadable").message], +]; + +function refusalToken(message: unknown): string { + if (typeof message !== "string") { + return "unavailable"; + } + return REFUSALS.find(([, text]) => text === message)?.[0] ?? "other"; +} function isStage(value: unknown): value is Stage { return STAGES.some((stage) => stage === value); @@ -98,6 +142,7 @@ function cliFacts(stdout: string) { code: "unavailable", orderedStage: "unavailable", orderedReason: "unavailable", + refusal: "unavailable", } as const; if (Buffer.byteLength(stdout) > MAX_REPLY_BYTES) { return unavailable; @@ -108,7 +153,7 @@ function cliFacts(stdout: string) { return unavailable; } if (value.ok === true) { - return { ...unavailable, code: "none" }; + return { ...unavailable, code: "none", refusal: "none" }; } if (value.ok === false && isRecord(value.error)) { const error = value.error; @@ -119,6 +164,7 @@ function cliFacts(stdout: string) { code, orderedStage: ordered?.stage ?? "unavailable", orderedReason: ordered?.reason ?? "unavailable", + refusal: refusalToken(error.message), }; } } catch { @@ -128,8 +174,8 @@ function cliFacts(stdout: string) { } /** - * Record only closed start/restart substages, CLI codes and exact ordered-refusal - * detail. No reply values, paths, argv, identifiers, arbitrary errors or + * Record only closed start/restart substages, CLI codes, exact ordered-refusal + * detail and a closed token for a fixed refusal message. No reply values, paths, argv, identifiers, arbitrary errors or * elapsed-budget claims are emitted. Logging failure never replaces the result. */ export function createCompletedJobFixtureStartDiagnostics(opts: { @@ -180,11 +226,11 @@ export function createCompletedJobFixtureStartDiagnostics(opts: { const timeout = result.timedOut ? "yes" : "no"; const facts = cliFacts(result.stdout); emit( - `stage=cli-result exit=${exit} timed-out=${timeout} code=${facts.code} ordered-stage=${facts.orderedStage} ordered-reason=${facts.orderedReason}` + `stage=cli-result exit=${exit} timed-out=${timeout} code=${facts.code} ordered-stage=${facts.orderedStage} ordered-reason=${facts.orderedReason} refusal=${facts.refusal}` ); } catch { emit( - "stage=cli-result exit=unavailable timed-out=unavailable code=unavailable ordered-stage=unavailable ordered-reason=unavailable" + "stage=cli-result exit=unavailable timed-out=unavailable code=unavailable ordered-stage=unavailable ordered-reason=unavailable refusal=unavailable" ); } }, diff --git a/tests/native-compose-adoption-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index 50aa6b414..f3a0848e2 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -156,6 +156,12 @@ if (fixture.mode?.startsWith('source-bind-c2-') && kind === 'container' && actio writeFileSync(root+'/fixture.json',JSON.stringify(fixture)); } } +if (fixture.mode?.startsWith("snapshot-placeholder-") && kind === "network" && action === "inspect") { + // Docker keys an endpoint that has no sandbox (mid start/stop) as ep-; this read only. + const at = fixture.mode.slice("snapshot-placeholder-".length); + const key = at.endsWith("-malformed") ? "ep-not-an-endpoint" : "ep-" + "d".repeat(64); + if (at.startsWith("always") || at === String(networkOrdinal)) for (const bridge of fixture.network) bridge.containers = [...bridge.containers, key]; +} if (kind === "info") {console.log(JSON.stringify({id: fixture.engine, os: "linux"}));} else if (action === "ls") {for (const row of fixture[kind]) console.log(JSON.stringify({id: row.id, name: kind === 'container' ? row.name.slice(1) : row.name, project: row.project ?? ""}));} else { @@ -646,6 +652,75 @@ test("moving snapshot guard exhausts the original shared deadline", async () => expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); }, 10_000); +function networkInspections(observed: readonly string[][]): number { + return observed.filter( + ([kind, action]) => kind === "network" && action === "inspect" + ).length; +} + +test.each([ + // The identity-only first read is always followed by a fresh loop read. + ["1", 0], + // A placeholder in the accepted loop read costs exactly one more scan. + ["2", 1], +] as const)( + "a sandbox-less endpoint placeholder on bridge read %s is re-observed, never admitted or refused (HACK-1421)", + async (ordinal, extra) => { + const clean = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const original = await clean.resolveBinding({ projectRoot }); + const baseline = networkInspections(await commands()); + await rm(join(root, "commands")); + fixture.mode = `snapshot-placeholder-${ordinal}`; + await writeFile(join(root, "snapshot-network-ordinal"), "0"); + await save(); + const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + expect(await acquired.resolveBinding({ projectRoot })).toEqual(original); + expect(networkInspections(await commands())).toBe(baseline + extra); + expect(JSON.stringify(original)).not.toContain("ep-"); + expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); + }, + 60_000 +); + +test("a placeholder on one of two owned bridges is re-observed with both bridges (HACK-1421)", async () => { + await twoBridges(); + const clean = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const original = await clean.resolveBinding({ projectRoot }); + const baseline = networkInspections(await commands()); + await rm(join(root, "commands")); + // Reads 1-2 fix identity; read 3 is the first bridge of the accepted loop scan. + fixture.mode = "snapshot-placeholder-3"; + await writeFile(join(root, "snapshot-network-ordinal"), "0"); + await save(); + const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + expect(await acquired.resolveBinding({ projectRoot })).toEqual(original); + expect(networkInspections(await commands())).toBe(baseline + 2); + expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); +}, 60_000); + +test("a persistent endpoint placeholder exhausts the original shared deadline", async () => { + fixture.mode = "snapshot-placeholder-always"; + await save(); + const started = Date.now(); + await refusal( + acquireLegacyComposeAdoptionBinding({ projectRoot, timeoutMs: 6000 }), + "E_LEGACY_COMPOSE_BINDING_PROBE" + ); + expect(Date.now() - started).toBeLessThan(9000); + // Identity read plus at least two discarded loop reads before the deadline. + expect(networkInspections(await commands())).toBeGreaterThan(2); +}, 20_000); + +test("a malformed placeholder-shaped bridge member still refuses identity without retry", async () => { + fixture.mode = "snapshot-placeholder-always-malformed"; + await save(); + await refusal( + acquireLegacyComposeAdoptionBinding({ projectRoot }), + "E_LEGACY_COMPOSE_BINDING_IDENTITY" + ); + expect(networkInspections(await commands())).toBe(1); +}, 10_000); + test("moving snapshot guard rejects a selected ID on its undeclared bridge before retry", async () => { await twoBridges(); fixture.mode = "snapshot-wrong-bridge"; diff --git a/tests/native-compose-adoption-job-diagnostics.test.ts b/tests/native-compose-adoption-job-diagnostics.test.ts index e2b196f13..e26446607 100644 --- a/tests/native-compose-adoption-job-diagnostics.test.ts +++ b/tests/native-compose-adoption-job-diagnostics.test.ts @@ -8,6 +8,9 @@ import { legacyComposeOrderedError, legacyComposeOrderedRefusal, } from "../src/lib/native-compose-adoption-diagnostics.ts"; +import { LegacyComposeAdoptedGenerationError } from "../src/lib/native-compose-adoption-generation.ts"; +import { LegacyComposeAdoptionSelectionError } from "../src/lib/native-compose-adoption-marker.ts"; +import { projectInputAccessFault } from "../src/lib/project-input-selection.ts"; import { createCompletedJobFixtureStartDiagnostics } from "./e2e/scenarios/native-compose-adoption-job-diagnostics.ts"; function diagnostics(messages: string[]) { @@ -118,8 +121,10 @@ test("completed-job start diagnostics omit reply values and unknown codes or lab "ordered-stage=unavailable ordered-reason=unavailable" ); expect(messages[1]).toContain("code=unavailable"); + expect(messages[0]).toContain("refusal=other"); + expect(messages[2]).toContain("refusal=unavailable"); expect(messages[3]).toContain( - "timed-out=yes code=unavailable ordered-stage=unavailable ordered-reason=unavailable" + "timed-out=yes code=unavailable ordered-stage=unavailable ordered-reason=unavailable refusal=unavailable" ); expect(messages.at(-1)).toBe( "start-operation=unavailable worktree=unavailable stage=fresh-exit status=end" @@ -154,7 +159,7 @@ test("completed-job CLI diagnostics report only the owner-issued closed ordered }), }); expect(messages.at(-1)).toBe( - `start-operation=restart worktree=alpha stage=cli-result exit=nonzero timed-out=no code=E_CONFIG_INVALID ordered-stage=${issued.stage} ordered-reason=${issued.reason}` + `start-operation=restart worktree=alpha stage=cli-result exit=nonzero timed-out=no code=E_CONFIG_INVALID ordered-stage=${issued.stage} ordered-reason=${issued.reason} refusal=other` ); } expect(messages.join("\n")).not.toContain("canary"); @@ -195,7 +200,7 @@ test("completed-job CLI diagnostics refuse malformed, extra and unrelated detail }), }); expect(messages.at(-1)).toBe( - `start-operation=restart worktree=alpha stage=cli-result exit=nonzero timed-out=no code=${code} ordered-stage=unavailable ordered-reason=unavailable` + `start-operation=restart worktree=alpha stage=cli-result exit=nonzero timed-out=no code=${code} ordered-stage=unavailable ordered-reason=unavailable refusal=other` ); } expect(messages.join("\n")).not.toContain("canary"); @@ -238,6 +243,72 @@ test("completed-job CLI diagnostics treat an unreadable reply as unavailable", ( }); expect(() => diagnostic.cliOutcome(result)).not.toThrow(); expect(messages).toEqual([ - "start-operation=restart worktree=alpha stage=cli-result exit=unavailable timed-out=unavailable code=unavailable ordered-stage=unavailable ordered-reason=unavailable", + "start-operation=restart worktree=alpha stage=cli-result exit=unavailable timed-out=unavailable code=unavailable ordered-stage=unavailable ordered-reason=unavailable refusal=unavailable", ]); }); + +test("completed-job CLI diagnostics name a fixed refusal message by closed token only (HACK-1421)", () => { + const messages: string[] = []; + const diagnostic = diagnostics(messages); + const refusal = (error: HackCliError) => { + diagnostic.cliOutcome({ + exitCode: 1, + timedOut: false, + stdout: renderCliResult({ result: errorResultFromUnknown({ error }) }), + }); + return messages.at(-1)?.split(" ").at(-1); + }; + // The adopted command rewraps a store refusal as E_CONFIG_INVALID with its fixed message. + for (const [code, token] of [ + ["E_LEGACY_ADOPTION_STATE", "legacy-state"], + ["E_LEGACY_ADOPTION_BUSY", "legacy-busy"], + ["E_LEGACY_ADOPTION_CHANGED", "legacy-changed"], + ["E_LEGACY_ADOPTION_UNSUPPORTED", "legacy-unsupported"], + ["E_LEGACY_ADOPTION_CANCELLED", "legacy-cancelled"], + ["E_LEGACY_ADOPTION_DEADLINE", "legacy-deadline"], + ["E_LEGACY_ADOPTION_LITERAL_ENV", "legacy-literal-env"], + ] as const) { + expect( + refusal( + new HackCliError({ + code: "E_CONFIG_INVALID", + message: new LegacyComposeAdoptedGenerationError(code).message, + }) + ) + ).toBe(`refusal=${token}`); + } + expect( + refusal( + new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Adopted Compose ownership or execution refused; original data is retained. Values omitted.", + }) + ) + ).toBe("refusal=adopted-refused"); + expect(refusal(new LegacyComposeAdoptionSelectionError())).toBe( + "refusal=marker-adoption" + ); + expect(refusal(new LegacyComposeAdoptionSelectionError("permission"))).toBe( + "refusal=marker-permission" + ); + expect(messages.at(-1)).toContain("code=E_PERMISSION"); + expect(refusal(new LegacyComposeAdoptionSelectionError("input-access"))).toBe( + "refusal=input-access" + ); + const fault = Object.assign(new Error("private-path-canary"), { + code: "ENOTDIR", + }); + expect(refusal(projectInputAccessFault(fault))).toBe("refusal=input-access"); + expect( + refusal( + new HackCliError({ + code: "E_CONFIG_INVALID", + message: `${new LegacyComposeAdoptedGenerationError("E_LEGACY_ADOPTION_STATE").message} private-suffix-canary`, + }) + ) + ).toBe("refusal=other"); + expect(messages.join("\n")).not.toContain("canary"); + expect(messages.join("\n")).not.toContain("ENOTDIR"); + expect(messages.join("\n")).not.toContain("values omitted"); +});