From 96d35af6fe2a159ed0892726625ca927419ca7c2 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Fri, 18 Sep 2026 03:48:30 +0800 Subject: [PATCH] [CTX-0059] fix(campaign): require admission and run-owned cleanup --- src/campaign.ts | 211 +++++++++++++++++----- tests/campaign.test.ts | 387 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 544 insertions(+), 54 deletions(-) diff --git a/src/campaign.ts b/src/campaign.ts index 3121ce5..28f2d4a 100644 --- a/src/campaign.ts +++ b/src/campaign.ts @@ -25,6 +25,23 @@ import { DIR_MODE } from "./auth.js"; +function isAbsoluteSocketPath(socketPath: string): boolean { + const platform = (globalThis as { process?: { platform?: string } }).process + ?.platform; + if (platform === "win32") { + return /^[A-Za-z]:[\\/]/.test(socketPath) || socketPath.startsWith("\\\\"); + } + return socketPath.startsWith("/"); +} + +function campaignFailureMessage(error: unknown): string { + try { + return error instanceof Error ? error.message : String(error); + } catch { + return "unknown campaign failure"; + } +} + // --------------------------------------------------------------------------- // ctl exit codes and envelope (consumer mirror of bitty-app/src/ctl.rs) // --------------------------------------------------------------------------- @@ -228,6 +245,7 @@ export type CtlInvocation = { */ export interface CtlDispatcher { dispatch(invocation: CtlInvocation): Promise; + attestSocketTarget?(absoluteSocketPath: string): boolean; } /** A synchronous or asynchronous dispatch handler. */ @@ -448,6 +466,13 @@ export class ProcessCtlDispatcher implements CtlDispatcher { this.spawn = spawn; } + attestSocketTarget(absoluteSocketPath: string): boolean { + return ( + isAbsoluteSocketPath(absoluteSocketPath) && + this.socketPath === absoluteSocketPath + ); + } + async dispatch(invocation: CtlInvocation): Promise { const args = [...this.baseArgs]; if (this.socketPath !== undefined) { @@ -817,7 +842,7 @@ export async function probeEnvelopeConformance( }); } catch (err) { results.push( - fail(name, `dispatch threw: ${(err as Error).message ?? String(err)}`), + fail(name, `dispatch threw: ${campaignFailureMessage(err)}`), ); continue; } @@ -849,7 +874,7 @@ export async function probeEnvelopeConformance( try { envelope = parseCtlEnvelope(result.stdout); } catch (err) { - results.push(fail(name, (err as Error).message, evidence)); + results.push(fail(name, campaignFailureMessage(err), evidence)); continue; } evidence.push(`ok=${envelope.ok}`); @@ -994,6 +1019,9 @@ export async function probeWorkspaceIdRoundTrip( ): Promise { const name = "workspace:id-round-trip"; const evidence: string[] = []; + const problems: string[] = []; + let owned: string | undefined; + let count = 0; try { const listed = workspaceNamesFrom( await dispatchEnvelope(dispatcher, { @@ -1003,6 +1031,12 @@ export async function probeWorkspaceIdRoundTrip( }), ); evidence.push(`baseline=[${listed.join(",")}]`); + const baseline = new Set( + listed.map((id) => { + assertSafeWorkspaceId(id); + return id.replace(/^ws:?0*(?=\d)/, "ws:"); + }), + ); const created = workspaceCreatedFrom( await dispatchEnvelope(dispatcher, { @@ -1012,6 +1046,19 @@ export async function probeWorkspaceIdRoundTrip( }), ); evidence.push(`created=${created}`); + if (!WORKSPACE_ID_RE.test(created)) { + throw new CampaignError( + "MissingField", + `workspace new created non-id '${created}'`, + ); + } + if (baseline.has(created.replace(/^ws:?0*(?=\d)/, "ws:"))) { + throw new CampaignError( + "MissingField", + "workspace new returned a pre-existing id", + ); + } + owned = created; const afterNew = workspaceNamesFrom( await dispatchEnvelope(dispatcher, { @@ -1022,11 +1069,10 @@ export async function probeWorkspaceIdRoundTrip( ); evidence.push(`afterNew=[${afterNew.join(",")}]`); + count = afterNew.length; if (afterNew.length === 0) { - return fail(name, "workspace list empty after new", evidence); + problems.push("workspace list empty after new"); } - - const problems: string[] = []; for (const identifier of afterNew) { try { assertSafeWorkspaceId(identifier); @@ -1054,33 +1100,34 @@ export async function probeWorkspaceIdRoundTrip( if (!accepted) { problems.push(`listed id ${identifier} rejected by focus`); } - if (opts.elevated === true) { + } + } catch (err) { + problems.push(campaignFailureMessage(err)); + } finally { + if (opts.elevated === true && owned !== undefined) { + try { const closed = await dispatchEnvelope(dispatcher, { verb: "workspace.close", - args: ["workspace", "close", identifier, "--format", "json"], + args: ["workspace", "close", owned, "--format", "json"], elevated: true, }); if (!closed.ok) { problems.push( - `listed id ${identifier} rejected by close (${closed.error.class}/${closed.error.code})`, + `owned id ${owned} rejected by close (${closed.error.class}/${closed.error.code})`, ); + } else { + evidence.push(`close(${owned})=ok`); } + } catch (err) { + problems.push( + `owned workspace cleanup failed: ${campaignFailureMessage(err)}`, + ); } } - if (!WORKSPACE_ID_RE.test(created)) { - problems.push(`workspace new created non-id '${created}'`); - } - if (problems.length > 0) { - return fail(name, problems.join("; "), evidence); - } - return pass( - name, - `round-trip ok for ${afterNew.length} workspace id(s)`, - evidence, - ); - } catch (err) { - return fail(name, (err as Error).message, evidence); } + return problems.length > 0 + ? fail(name, problems.join("; "), evidence) + : pass(name, `round-trip ok for ${count} workspace id(s)`, evidence); } // --------------------------------------------------------------------------- @@ -1138,7 +1185,7 @@ export async function probeTerminalTextShape( `lines=${text.split("\n").length}`, ]); } catch (err) { - return fail(name, (err as Error).message, [`terminal=${terminalId}`]); + return fail(name, campaignFailureMessage(err), [`terminal=${terminalId}`]); } } @@ -1268,7 +1315,7 @@ export async function probeTerminalSpawnObservability( evidence, ); } catch (err) { - return fail(name, (err as Error).message, evidence); + return fail(name, campaignFailureMessage(err), evidence); } } @@ -1489,6 +1536,7 @@ export function summarizeCampaign( export type CampaignOptions = { dispatcher: CtlDispatcher; + mutationConsent?: { socketPath: string; disposable: boolean }; terminalId?: string; /** Socket preflight inputs; omitted to skip the preflight probe. */ socket?: { @@ -1522,6 +1570,47 @@ export async function runCampaign( options: CampaignOptions, ): Promise { const results: ProbeResult[] = []; + if (options.socket !== undefined) { + try { + results.push( + probeSocketDirPreflight(preflightSocketParentDir(options.socket)), + ); + } catch (err) { + results.push(fail("socket:parent-dir-0700", campaignFailureMessage(err))); + } + if (results[0]?.status !== "pass") return summarizeCampaign(results); + } else { + results.push( + skip("socket:parent-dir-0700", "no socket configured (headless)"), + ); + } + const consent = options.mutationConsent; + let allowMutations = false; + try { + allowMutations = + consent?.disposable === true && + consent.socketPath.length > 0 && + isAbsoluteSocketPath(consent.socketPath) && + consent.socketPath === options.socket?.socketPath && + options.dispatcher.attestSocketTarget?.(consent.socketPath) === true; + } catch (err) { + results.push(fail("campaign:admission", campaignFailureMessage(err))); + return summarizeCampaign(results); + } + if ( + (consent !== undefined || + options.closeWorkspaces === true || + options.keystrokeTarget !== undefined) && + !allowMutations + ) { + results.push( + fail( + "campaign:admission", + "mutations require disposable-instance consent matching a preflighted socket", + ), + ); + return summarizeCampaign(results); + } const allowKeystrokes = keystrokeProbeOptIn(options.keystrokeTarget); const matrix = allowKeystrokes && options.keystrokeTarget !== undefined @@ -1530,15 +1619,54 @@ export async function runCampaign( keystrokeProbeExpectation(options.keystrokeTarget), ] : (options.matrix ?? CTL_VERB_MATRIX); + const admittedMatrix = matrix.filter((row) => { + if (row.args[1] === "close") { + results.push( + skip( + `envelope:${row.verb}`, + "close requires a run-owned identifier; exercised by workspace cleanup only", + ), + ); + return false; + } + const readOnly = + !row.elevated && + CTL_VERB_MATRIX.some( + (known) => + [ + "instance.list", + "window.list", + "view.list", + "terminal.list", + "terminal.text", + "workspace.list", + ].includes(known.verb) && + row.verb === known.verb && + row.args.length === known.args.length && + row.args.every((arg, i) => arg === known.args[i]), + ); + if (!readOnly && !allowMutations) { + results.push( + skip( + `envelope:${row.verb}`, + "mutating or unknown probe requires disposable-instance consent", + ), + ); + return false; + } + return true; + }); results.push( - ...(await probeEnvelopeConformance(options.dispatcher, matrix, { + ...(await probeEnvelopeConformance(options.dispatcher, admittedMatrix, { keystrokeTarget: options.keystrokeTarget, })), ); results.push( - await probeWorkspaceIdRoundTrip(options.dispatcher, { - elevated: options.closeWorkspaces ?? false, - }), + allowMutations + ? await probeWorkspaceIdRoundTrip(options.dispatcher, { + elevated: options.closeWorkspaces ?? false, + }) + : skip("workspace:id-round-trip", "requires disposable-instance consent"), ); results.push( await probeTerminalTextShape( @@ -1547,31 +1675,21 @@ export async function runCampaign( ), ); results.push( - await probeTerminalSpawnObservability(options.dispatcher, { - elevated: true, - }), + allowMutations + ? await probeTerminalSpawnObservability(options.dispatcher, { + elevated: true, + }) + : skip( + "terminal:spawn-observable", + "requires disposable-instance consent", + ), ); - if (options.socket !== undefined) { - results.push( - probeSocketDirPreflight( - preflightSocketParentDir({ - socketPath: options.socket.socketPath, - runtimeUid: options.socket.runtimeUid, - stat: options.socket.stat, - requiredMode: options.socket.requiredMode, - }), - ), - ); - } else { - results.push( - skip("socket:parent-dir-0700", "no socket configured (headless)"), - ); - } results.push(...probePanelPluginHooks()); return summarizeCampaign(results); } export type LiveCampaignConfig = ProcessDispatcherConfig & { + mutationConsent?: CampaignOptions["mutationConsent"]; terminalId?: string; /** Runtime uid for the socket preflight; required for the preflight probe. */ runtimeUid?: number; @@ -1608,6 +1726,7 @@ export async function runLiveCampaign( : undefined; return runCampaign({ dispatcher, + mutationConsent: config.mutationConsent, terminalId: config.terminalId, closeWorkspaces: config.closeWorkspaces, keystrokeTarget: config.keystrokeTarget, diff --git a/tests/campaign.test.ts b/tests/campaign.test.ts index 2cc7d39..19f8fe4 100644 --- a/tests/campaign.test.ts +++ b/tests/campaign.test.ts @@ -142,11 +142,20 @@ function resultFor(invocation: CtlInvocation): CtlResult { throw new Error(`unscripted verb ${invocation.verb}`); } +function attestFixtureDispatcher( + dispatcher: ScriptedCtlDispatcher, +): ScriptedCtlDispatcher { + return Object.assign(dispatcher, { + attestSocketTarget: (socketPath: string) => + socketPath === fixtureSocketPath, + }); +} + function campaignDispatcher( overrides: Record = {}, ): ScriptedCtlDispatcher { - return new ScriptedCtlDispatcher( - (inv) => overrides[inv.verb] ?? resultFor(inv), + return attestFixtureDispatcher( + new ScriptedCtlDispatcher((inv) => overrides[inv.verb] ?? resultFor(inv)), ); } @@ -615,6 +624,354 @@ describe("durable dispatcher seam", () => { }); }); +const fixtureSocketPath = `${process.cwd()}/fixture.sock`; +const admittedCampaign = { + mutationConsent: { socketPath: fixtureSocketPath, disposable: true as const }, + socket: { + socketPath: fixtureSocketPath, + runtimeUid: OK_DIR.ownerUid, + stat: () => OK_DIR, + }, +}; + +describe("campaign admission and ownership", () => { + test("relative endpoints are refused even when consent and dispatcher strings match", async () => { + const calls: string[][] = []; + const dispatcher = new ProcessCtlDispatcher( + { socketPath: "fixture.sock", cwd: "child" }, + (_program, args) => { + calls.push([...args]); + return makeOkResult("core.view.list", { views: [] }); + }, + ); + const report = await runCampaign({ + ...admittedCampaign, + dispatcher, + mutationConsent: { socketPath: "fixture.sock", disposable: true }, + socket: { ...admittedCampaign.socket, socketPath: "fixture.sock" }, + }); + expect(report.ok).toBe(false); + expect(calls).toEqual([]); + }); + + test("windows drive-letter and UNC endpoints are refused on this host", async () => { + for (const socketPath of [ + "C:\\bitty\\fixture.sock", + "\\\\pipe\\fixture.sock", + ]) { + const calls: string[][] = []; + const dispatcher = new ProcessCtlDispatcher( + { socketPath }, + (_program, args) => { + calls.push([...args]); + return makeOkResult("core.view.list", { views: [] }); + }, + ); + const report = await runCampaign({ + dispatcher, + mutationConsent: { socketPath, disposable: true }, + socket: { + socketPath, + runtimeUid: OK_DIR.ownerUid, + stat: () => OK_DIR, + }, + }); + expect(report.ok).toBe(false); + expect(calls).toEqual([]); + } + }); + + test("custom dispatchers without target attestation are refused", async () => { + const dispatcher = new ScriptedCtlDispatcher(resultFor); + const socketPath = `${process.cwd()}/fixture.sock`; + const report = await runCampaign({ + dispatcher, + mutationConsent: { socketPath, disposable: true }, + socket: { ...admittedCampaign.socket, socketPath }, + }); + expect(report.ok).toBe(false); + expect(dispatcher.seen()).toEqual([]); + }); + + test("an absolute endpoint is passed unchanged to a process fake with another cwd", async () => { + const calls: string[][] = []; + const dispatcher = new ProcessCtlDispatcher( + { socketPath: fixtureSocketPath, cwd: "child" }, + (_program, args) => { + calls.push([...args]); + return makeOkResult("core.view.list", { views: [] }); + }, + ); + await runCampaign({ ...admittedCampaign, dispatcher }); + expect(calls.length).toBeGreaterThan(0); + expect( + calls.every( + (args) => args[args.indexOf("--socket") + 1] === fixtureSocketPath, + ), + ).toBe(true); + }); + + test("custom target attestation is checked against the preflight endpoint", async () => { + const dispatcher = Object.assign(new ScriptedCtlDispatcher(resultFor), { + attestSocketTarget: (socketPath: string) => + socketPath === `${process.cwd()}/other.sock`, + }); + const report = await runCampaign({ ...admittedCampaign, dispatcher }); + expect(report.ok).toBe(false); + expect(dispatcher.seen()).toEqual([]); + }); + + for (const thrown of [null, undefined, "fixture failure", 7]) { + test(`target attestation reports thrown ${String(thrown)}`, async () => { + const dispatcher = Object.assign(new ScriptedCtlDispatcher(resultFor), { + attestSocketTarget: () => { + throw thrown; + }, + }); + const report = await runCampaign({ ...admittedCampaign, dispatcher }); + expect(report.ok).toBe(false); + expect( + report.results.find((r) => r.name === "campaign:admission")?.detail, + ).toBe(String(thrown)); + expect(dispatcher.seen()).toEqual([]); + }); + test(`preflight reports thrown ${String(thrown)} without dispatch`, async () => { + const dispatcher = campaignDispatcher(); + const report = await runCampaign({ + ...admittedCampaign, + dispatcher, + socket: { + ...admittedCampaign.socket, + stat: () => { + throw thrown; + }, + }, + }); + expect(report.ok).toBe(false); + expect(report.results[0]?.detail).toContain(String(thrown)); + expect(dispatcher.seen()).toEqual([]); + }); + + test(`observation and cleanup report thrown ${String(thrown)}`, async () => { + const dispatcher = new ScriptedCtlDispatcher((inv) => { + if ( + inv.verb === "workspace.list.after-new" || + inv.verb === "workspace.close" + ) + throw thrown; + return resultFor(inv); + }); + const result = await probeWorkspaceIdRoundTrip(dispatcher, { + elevated: true, + }); + expect(result.status).toBe("fail"); + expect(result.detail).toBe( + `${String(thrown)}; owned workspace cleanup failed: ${String(thrown)}`, + ); + expect( + dispatcher + .seen() + .filter((inv) => inv.verb === "workspace.close") + .map((inv) => inv.args[2]), + ).toEqual(["ws:2"]); + }); + } + + test("process dispatcher target must match the consented socket", async () => { + const calls: string[][] = []; + const dispatcher = new ProcessCtlDispatcher( + { socketPath: "other.sock" }, + (_program, args) => { + calls.push([...args]); + return makeOkResult("core.view.list", { views: [] }); + }, + ); + const report = await runCampaign({ ...admittedCampaign, dispatcher }); + expect(report.ok).toBe(false); + expect(calls).toEqual([]); + }); + + test("read-only labels do not admit different arguments without consent", async () => { + const dispatcher = campaignDispatcher(); + await runCampaign({ + dispatcher, + matrix: [ + { + verb: "workspace.list", + args: ["workspace", "new", "--format", "json"], + outcome: "ok", + elevated: false, + note: "benign custom row", + }, + ], + }); + expect(dispatcher.seen().map((inv) => inv.verb)).toEqual(["terminal.text"]); + }); + + test("cleanup failure is reported without closing another ID", async () => { + const dispatcher = campaignDispatcher({ + "workspace.close": makeErrorResult( + "core.workspace.close", + DENIED, + EXIT_PERM, + ), + }); + const result = await probeWorkspaceIdRoundTrip(dispatcher, { + elevated: true, + }); + expect(result.status).toBe("fail"); + expect(result.detail).toContain("rejected by close"); + expect( + dispatcher + .seen() + .filter((inv) => inv.verb === "workspace.close") + .map((inv) => inv.args[2]), + ).toEqual(["ws:2"]); + }); + + test("default campaign dispatches only read-only probes", async () => { + const dispatcher = campaignDispatcher(); + await runCampaign({ dispatcher }); + expect(dispatcher.seen().length).toBeGreaterThan(0); + expect( + dispatcher + .seen() + .every((inv) => + [ + "instance.list", + "window.list", + "view.list", + "terminal.list", + "terminal.text", + "workspace.list", + ].includes(inv.verb), + ), + ).toBe(true); + }); + + for (const admission of [ + "failed", + "missing", + "mismatch", + "throws", + "not-disposable", + ] as const) { + test(`refuses mutations when admission is ${admission}`, async () => { + const dispatcher = campaignDispatcher(); + const report = await runCampaign({ + ...admittedCampaign, + dispatcher, + closeWorkspaces: true, + keystrokeTarget: { terminalId: "t:42", allowLiveKeystrokes: true }, + mutationConsent: { + socketPath: + admission === "mismatch" + ? `${process.cwd()}/other.sock` + : fixtureSocketPath, + disposable: admission !== "not-disposable", + }, + socket: + admission === "missing" + ? undefined + : { + ...admittedCampaign.socket, + stat: () => { + if (admission === "throws") + throw new Error("fixture stat failed"); + return admission === "failed" + ? { ...OK_DIR, mode: 0o755 } + : OK_DIR; + }, + }, + }); + expect(report.ok).toBe(false); + expect(dispatcher.seen()).toEqual([]); + }); + } + + test("preflight runs before any dispatch and admission does not opt into keystrokes", async () => { + let checked = false; + const dispatcher = attestFixtureDispatcher( + new ScriptedCtlDispatcher((inv) => { + expect(checked).toBe(true); + return resultFor(inv); + }), + ); + await runCampaign({ + ...admittedCampaign, + dispatcher, + socket: { + ...admittedCampaign.socket, + stat: () => { + checked = true; + return OK_DIR; + }, + }, + closeWorkspaces: true, + }); + expect(dispatcher.seen().some((inv) => inv.verb === "terminal.spawn")).toBe( + true, + ); + expect( + dispatcher.seen().some((inv) => inv.verb === KEYSTROKE_PROBE_VERB), + ).toBe(false); + expect( + dispatcher + .seen() + .filter((inv) => inv.args[1] === "close") + .map((inv) => inv.args[2]), + ).toEqual(["ws:2"]); + }); + + test("cleanup closes only the created workspace, not newly listed or baseline IDs", async () => { + const dispatcher = campaignDispatcher({ + "workspace.list.after-new": makeOkResult("core.workspace.list", { + workspaces: ["ws1", "ws2", "ws:3", "ws:2"], + }), + }); + await probeWorkspaceIdRoundTrip(dispatcher, { elevated: true }); + expect( + dispatcher + .seen() + .filter((inv) => inv.verb === "workspace.close") + .map((inv) => inv.args[2]), + ).toEqual(["ws:2"]); + }); + + test("a created ID already present under a baseline alias is never closed", async () => { + const dispatcher = campaignDispatcher({ + "workspace.new": makeOkResult("core.workspace.new", { created: "ws:01" }), + }); + const result = await probeWorkspaceIdRoundTrip(dispatcher, { + elevated: true, + }); + expect(result.status).toBe("fail"); + expect( + dispatcher.seen().some((inv) => inv.verb === "workspace.close"), + ).toBe(false); + }); + + test("owned cleanup runs after a later observation failure", async () => { + const dispatcher = campaignDispatcher({ + "workspace.list.after-new": makeErrorResult( + "core.workspace.list", + UNAVAILABLE, + EXIT_RUNTIME, + ), + }); + const result = await probeWorkspaceIdRoundTrip(dispatcher, { + elevated: true, + }); + expect(result.status).toBe("fail"); + expect( + dispatcher + .seen() + .filter((inv) => inv.verb === "workspace.close") + .map((inv) => inv.args[2]), + ).toEqual(["ws:2"]); + }); +}); + describe("campaign aggregation", () => { test("summarize counts and only fails on failure", () => { const report = summarizeCampaign([ @@ -641,13 +998,24 @@ describe("campaign aggregation", () => { }); expect(report.failed).toBe(0); expect(report.ok).toBe(true); - expect(report.skipped).toBe(panelPluginCoverageHooks().length); + expect( + report.results.find((r) => r.name === "workspace:id-round-trip")?.status, + ).toBe("skip"); + expect( + report.results.find((r) => r.name === "terminal:spawn-observable") + ?.status, + ).toBe("skip"); }); test("headless run without a socket skips the preflight", async () => { const report = await runCampaign({ dispatcher: campaignDispatcher() }); expect(report.ok).toBe(true); - expect(report.skipped).toBe(panelPluginCoverageHooks().length + 1); + expect( + report.results.find((r) => r.name === "socket:parent-dir-0700")?.status, + ).toBe("skip"); + expect( + report.results.find((r) => r.name === "workspace:id-round-trip")?.status, + ).toBe("skip"); }); test("headless run fails when the D1 guard trips", async () => { @@ -780,11 +1148,14 @@ describe("keystroke-injection probe gating (H-DEV-04)", () => { test("opted-in campaign appends the probe for the scratch terminal only", async () => { const seen: string[][] = []; - const dispatcher = new ScriptedCtlDispatcher((inv) => { - seen.push(inv.args); - return makeOkResult(`core.${inv.verb}`, { ok: true }); - }); + const dispatcher = attestFixtureDispatcher( + new ScriptedCtlDispatcher((inv) => { + seen.push(inv.args); + return makeOkResult(`core.${inv.verb}`, { ok: true }); + }), + ); await runCampaign({ + ...admittedCampaign, dispatcher, keystrokeTarget: { terminalId: "t:42", allowLiveKeystrokes: true }, });