From 744671f4def47e5c3e5f3cc6491987fecacea622 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sun, 26 Jul 2026 14:02:11 -0400 Subject: [PATCH] fix(pi): a failed background-job spawn now says why `launch` refuses with `undefined` on five paths. Four are policy decisions the caller can predict; the fifth is a spawn failure it cannot. That error was discarded by a bare `catch {}`, so a Git Bash launch that failed on CI reached the operator as `expected undefined to be defined` - the runtime's correct, fail-closed refusal was indistinguishable from a real fault. The diagnostic is per-launch rather than runtime-scoped, because four launches can be in flight at once and a shared slot would report one launch's environment failure as another's - the same misdiagnosis, relocated. The detail comes from a closed vocabulary: a recognized Windows containment reason (#428's extractor) or an errno shape, never `error.message`, which embeds the resolved shell path and working directory. That follows the convention already set on the adjacent childFailure() channel rather than widening a second channel to free text. AC-4 is deliberately not closed. Diagnostics do not stop the flake; they make the next occurrence explain itself instead of asserting on `undefined`. CHANGELOG: A background job that cannot start its shell now reports why, with a bounded reason code, instead of a generic block message. Ref: #504 Claude-Session: https://claude.ai/code/session_01JgdLb6n8mUdkFiKDTA37ML --- .codearbiter/gate-events.log | 18 +++ package.json | 2 +- plugins/ca-pi/CHANGELOG.md | 19 +++ plugins/ca-pi/extensions/codearbiter.js | 38 ++++- plugins/ca-pi/package.json | 2 +- plugins/ca-pi/tools/src/background-jobs.ts | 50 +++++- plugins/ca-pi/tools/src/native-background.ts | 7 +- .../ca-pi/tools/test/background-jobs.test.ts | 146 ++++++++++++++++++ plugins/ca-pi/tools/test/commands.test.ts | 61 ++++++++ 9 files changed, 334 insertions(+), 9 deletions(-) diff --git a/.codearbiter/gate-events.log b/.codearbiter/gate-events.log index da5c973e..a44b18e5 100644 --- a/.codearbiter/gate-events.log +++ b/.codearbiter/gate-events.log @@ -987,3 +987,21 @@ Claude-Session: https://claude.ai/code/session_015ZDVU1BzUqnnPVHbZ397bX') stages [2026-07-26T14:10:47Z] REMIND [H-12] hook=post-write-edit.py | plugins/ca-pi/tools/test/security.test.ts is governed by ADR-0013 (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. [2026-07-26T14:52:33Z] BLOCK [H-03] hook=pre-bash.py | Wildcard staging is prohibited — a directory ('plugins/ca-sandbox/tools/') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). [2026-07-26T16:34:00Z] REMIND [H-12] hook=post-write-edit.py | core/surface/skills/release/SKILL.md is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:40:09Z] BLOCK [H-18] host=claude hook=pre-bash.py | .codearbiter/CONTEXT.md is the activation switch every enforcement hook reads (#159) — shell rewrites, edits, or deletions that could flip `arbiter: enabled` off or corrupt its frontmatter are prohibited. Edit it through the sanctioned init path. +[2026-07-26T17:47:37Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/test/background-jobs.test.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:47:37Z] REMIND [H-10] host=claude hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. +[2026-07-26T17:47:56Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/test/commands.test.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:49:05Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/src/background-jobs.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:49:08Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/src/background-jobs.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:49:24Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/src/background-jobs.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:49:29Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/src/background-jobs.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:49:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/src/background-jobs.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:49:41Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/src/native-background.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:50:49Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/test/commands.test.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:50:54Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/test/commands.test.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:51:02Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/test/commands.test.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:57:45Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/package.json is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:57:45Z] REMIND [H-07] host=claude hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5). +[2026-07-26T17:57:51Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/CHANGELOG.md is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:58:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca-pi/tools/test/background-jobs.test.ts is governed by ADR-0013-add-ca-pi-sibling-governance-plugin (Add ca-pi as a sibling governance plugin using the shared core and a thin Pi adapter). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T17:58:38Z] REMIND [H-10] host=claude hook=post-write-edit.py | Possible hardcoded secret. Run the secret-handling check before committing. The commit will block until the gate records a pass. diff --git a/package.json b/package.json index c8da7562..a8525b85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.33", + "version": "0.1.34", "private": true, "license": "AGPL-3.0-only", "engines": { diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md index 76ba10d6..6767b302 100644 --- a/plugins/ca-pi/CHANGELOG.md +++ b/plugins/ca-pi/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to `ca-pi` are documented in this file. +## [0.1.34] - 2026-07-26 + +### Fixed + +- A background job that could not start its shell now says why. `launch` refuses + with `undefined` on five paths; four are policy decisions the caller can + predict, and the fifth is an environment failure it cannot - a spawn that + threw. That error was discarded by a bare `catch {}`, so a Git Bash launch + that failed on CI surfaced only as `expected undefined to be defined`, and the + runtime's designed, fail-closed refusal was indistinguishable from a real + fault. The spawn path now reports a per-launch diagnostic, and the background + bash tool surfaces it instead of the generic block message (#504). +- The refusal detail is drawn from a closed vocabulary - a recognized Windows + containment reason or an errno shape - never from `error.message`, which + embeds the resolved shell path and working directory. Following the same + bounded-reason-code convention already used for the child-process diagnostic + channel (#428) keeps one convention across both layers rather than widening a + second channel to free text. + ## [0.1.33] - 2026-07-26 ### Fixed diff --git a/plugins/ca-pi/extensions/codearbiter.js b/plugins/ca-pi/extensions/codearbiter.js index 6ba803a8..7e5890e9 100644 --- a/plugins/ca-pi/extensions/codearbiter.js +++ b/plugins/ca-pi/extensions/codearbiter.js @@ -2752,19 +2752,45 @@ function authorizationCurrent(authorization) { return false; } } +var SPAWN_REFUSAL_ERRNO = /^E[A-Z]{1,15}$/u; +function spawnRefusalDetail(error) { + if (!(error instanceof Error)) return void 0; + const reason = windowsRefusalReasonFromMessage(error.message); + if (reason !== void 0) return reason; + const code = error.code; + return typeof code === "string" && SPAWN_REFUSAL_ERRNO.test(code) ? code : void 0; +} +function describeSpawnRefusal(error) { + let detail; + try { + detail = spawnRefusalDetail(error); + } catch { + detail = void 0; + } + return detail === void 0 ? "Background job could not start the configured shell; run /ca-doctor." : `Background job could not start the configured shell (${detail}); run /ca-doctor.`; +} +function reportRefusal(sink, diagnostic) { + if (sink === void 0) return; + try { + sink(diagnostic); + } catch { + } +} function parseRuntimeLaunchInput(input) { const record2 = fixedDataRecord2( input, ["authorization", "command", "cwd", "env", "label", "shellPath"], - ["commandPrefix", "timeoutMs"] + ["commandPrefix", "onRefusal", "timeoutMs"] ); if (record2 === void 0) return void 0; const command = record2.descriptors.command?.value; const commandPrefix = record2.descriptors.commandPrefix?.value; const cwd = record2.descriptors.cwd?.value; const shellPath = record2.descriptors.shellPath?.value; + const onRefusal = record2.descriptors.onRefusal?.value; const env = boundedEnvironment(record2.descriptors.env?.value); if (!boundedString(cwd, 4096, 8192) || env === void 0) return void 0; + if (onRefusal !== void 0 && typeof onRefusal !== "function") return void 0; const shell = piShellLaunch({ shellPath, command, @@ -2776,6 +2802,7 @@ function parseRuntimeLaunchInput(input) { cwd, env, label: record2.descriptors.label?.value, + ...onRefusal === void 0 ? {} : { onRefusal }, shell, ...record2.keys.includes("timeoutMs") ? { timeoutMs: record2.descriptors.timeoutMs?.value } : {} }); @@ -2861,11 +2888,12 @@ var SessionBackgroundJobRuntime = class { env: parsed.env, stdio: ["pipe", "pipe", "pipe", "pipe"] }); - } catch { + } catch (error) { releaseOwnership(); this.#pendingOwnership.delete(ownership); const terminal = this.#manager.transitionJob({ id: job.id, state: "failed" }); if (terminal !== void 0) this.#publish(terminal, "completed"); + reportRefusal(parsed.onRefusal, describeSpawnRefusal(error)); return void 0; } let finish; @@ -3290,6 +3318,7 @@ function createNativeBackgroundController(pi, options) { const launch = await options.resolveLaunch(value.cwd); if (!stable(value, context) || launch === void 0 || currentToolSignal()?.aborted === true) return await toolFailure(); const startedAt = now(); + let spawnRefusal; const job = await value.runtime.launch({ authorization: { lease: value.lease, @@ -3298,12 +3327,15 @@ function createNativeBackgroundController(pi, options) { ...frozen, cwd: value.cwd, env: launch.env, + onRefusal: (diagnostic) => { + spawnRefusal = diagnostic; + }, shellPath: launch.shellPath, ...launch.commandPrefix === void 0 ? {} : { commandPrefix: launch.commandPrefix } }); if (job === void 0) { if (!runtimeHealthy(value)) degrade(value); - return await toolFailure(value.runtime.health().diagnostic); + return await toolFailure(value.runtime.health().diagnostic ?? spawnRefusal); } if (!(stable(value, context) && currentToolSignal()?.aborted !== true)) { await value.runtime.cancel(job.id); diff --git a/plugins/ca-pi/package.json b/plugins/ca-pi/package.json index 9c3f5be9..95b35ca6 100644 --- a/plugins/ca-pi/package.json +++ b/plugins/ca-pi/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.33", + "version": "0.1.34", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/plugins/ca-pi/tools/src/background-jobs.ts b/plugins/ca-pi/tools/src/background-jobs.ts index 86001525..49e8cb5c 100644 --- a/plugins/ca-pi/tools/src/background-jobs.ts +++ b/plugins/ca-pi/tools/src/background-jobs.ts @@ -4,7 +4,7 @@ import { posix, win32 } from "node:path"; import type { LifecycleAuthorization } from "./contracts.ts"; import { publishActivity } from "./activity.ts"; import type { ActivityPublisher, ActivityState } from "./activity.ts"; -import { openProcessTree } from "./process-tree.ts"; +import { openProcessTree, windowsRefusalReasonFromMessage } from "./process-tree.ts"; import type { ManagedProcessTree, ProcessTreeCleanupReason, @@ -81,6 +81,13 @@ export interface BackgroundJobLaunchInput { readonly label: string; readonly shellPath: string; readonly timeoutMs?: number; + /** + * #504: invoked only when the spawn itself failed — the single refusal an operator cannot + * predict, as against the four policy refusals that also resolve `undefined`. Per-launch by + * construction, so overlapping launches never read each other's environment failure. + * Diagnosis only: it is never awaited, and a throw from it is swallowed. + */ + readonly onRefusal?: (diagnostic: string) => void; } export interface BackgroundJobRuntime { @@ -621,26 +628,60 @@ function authorizationCurrent(authorization: LifecycleAuthorization): boolean { catch { return false; } } +/** + * #504: the `#openTree` refusal is the one launch path that reports an ENVIRONMENT failure + * rather than a policy decision the caller could have predicted, so it is the one that must + * say why. The detail comes from a closed vocabulary — #428's supervisor refusal reasons, or + * an errno shape — and never from `error.message`, which embeds the resolved shell path and + * cwd and is not a contract any caller may widen. + */ +const SPAWN_REFUSAL_ERRNO = /^E[A-Z]{1,15}$/u; + +function spawnRefusalDetail(error: unknown): string | undefined { + if (!(error instanceof Error)) return undefined; + const reason = windowsRefusalReasonFromMessage(error.message); + if (reason !== undefined) return reason; + const code = (error as NodeJS.ErrnoException).code; + return typeof code === "string" && SPAWN_REFUSAL_ERRNO.test(code) ? code : undefined; +} + +function describeSpawnRefusal(error: unknown): string { + let detail: string | undefined; + try { detail = spawnRefusalDetail(error); } catch { detail = undefined; } + return detail === undefined + ? "Background job could not start the configured shell; run /ca-doctor." + : `Background job could not start the configured shell (${detail}); run /ca-doctor.`; +} + +/** Diagnosis reporting is fail-soft at the producer boundary, exactly like publishActivity. */ +function reportRefusal(sink: ((diagnostic: string) => void) | undefined, diagnostic: string): void { + if (sink === undefined) return; + try { sink(diagnostic); } catch { /* Producer behavior is authoritative. */ } +} + function parseRuntimeLaunchInput(input: unknown): Readonly<{ authorization: LifecycleAuthorization; cwd: string; env: NodeJS.ProcessEnv; label: unknown; + onRefusal?: (diagnostic: string) => void; shell: Readonly; timeoutMs?: unknown; }> | undefined { const record = fixedDataRecord( input, ["authorization", "command", "cwd", "env", "label", "shellPath"], - ["commandPrefix", "timeoutMs"], + ["commandPrefix", "onRefusal", "timeoutMs"], ); if (record === undefined) return undefined; const command = record.descriptors.command?.value as unknown; const commandPrefix = record.descriptors.commandPrefix?.value as unknown; const cwd = record.descriptors.cwd?.value as unknown; const shellPath = record.descriptors.shellPath?.value as unknown; + const onRefusal = record.descriptors.onRefusal?.value as unknown; const env = boundedEnvironment(record.descriptors.env?.value); if (!boundedString(cwd, 4_096, 8_192) || env === undefined) return undefined; + if (onRefusal !== undefined && typeof onRefusal !== "function") return undefined; const shell = piShellLaunch({ shellPath: shellPath as string, command: command as string, @@ -652,6 +693,7 @@ function parseRuntimeLaunchInput(input: unknown): Readonly<{ cwd, env, label: record.descriptors.label?.value, + ...(onRefusal === undefined ? {} : { onRefusal: onRefusal as (diagnostic: string) => void }), shell, ...(record.keys.includes("timeoutMs") ? { timeoutMs: record.descriptors.timeoutMs?.value } : {}), }); @@ -750,11 +792,13 @@ class SessionBackgroundJobRuntime implements BackgroundJobRuntime { env: parsed.env, stdio: ["pipe", "pipe", "pipe", "pipe"], }); - } catch { + } catch (error) { releaseOwnership(); this.#pendingOwnership.delete(ownership); const terminal = this.#manager.transitionJob({ id: job.id, state: "failed" }); if (terminal !== undefined) this.#publish(terminal, "completed"); + // Fail-closed first, diagnosis second: the caller's sink never precedes the terminal state. + reportRefusal(parsed.onRefusal, describeSpawnRefusal(error)); return undefined; } let finish!: () => void; diff --git a/plugins/ca-pi/tools/src/native-background.ts b/plugins/ca-pi/tools/src/native-background.ts index 5aef6f7e..4be0d19a 100644 --- a/plugins/ca-pi/tools/src/native-background.ts +++ b/plugins/ca-pi/tools/src/native-background.ts @@ -322,6 +322,10 @@ export function createNativeBackgroundController( const launch = await options.resolveLaunch(value.cwd); if (!stable(value, context) || launch === undefined || currentToolSignal()?.aborted === true) return await toolFailure(); const startedAt = now(); + // #504: four of the five refusal paths are policy decisions this caller can predict; + // the fifth is a spawn failure it cannot. Capture that one per launch so a real + // environment failure reaches the operator instead of the generic block message. + let spawnRefusal: string | undefined; const job = await value.runtime.launch({ authorization: { lease: value.lease, @@ -331,12 +335,13 @@ export function createNativeBackgroundController( ...frozen, cwd: value.cwd, env: launch.env, + onRefusal: (diagnostic) => { spawnRefusal = diagnostic; }, shellPath: launch.shellPath, ...(launch.commandPrefix === undefined ? {} : { commandPrefix: launch.commandPrefix }), }); if (job === undefined) { if (!runtimeHealthy(value)) degrade(value); - return await toolFailure(value.runtime.health().diagnostic); + return await toolFailure(value.runtime.health().diagnostic ?? spawnRefusal); } if (!(stable(value, context) && currentToolSignal()?.aborted !== true)) { await value.runtime.cancel(job.id); diff --git a/plugins/ca-pi/tools/test/background-jobs.test.ts b/plugins/ca-pi/tools/test/background-jobs.test.ts index 061b23aa..4c3fc339 100644 --- a/plugins/ca-pi/tools/test/background-jobs.test.ts +++ b/plugins/ca-pi/tools/test/background-jobs.test.ts @@ -581,6 +581,152 @@ describe("session-local background job state", () => { expect(openTree).toHaveBeenCalledTimes(1); }); + // #504: `launch` refuses with `undefined` on five paths. Four are policy refusals the + // caller can predict; the fifth is an environment failure it cannot. These pin the + // fifth path to a per-launch diagnostic drawn from a CLOSED vocabulary - #428's + // supervisor refusal reasons, or an errno shape - so a real spawn failure explains + // itself without opening a free-text channel to the operator. + const SPAWN_REFUSAL = (detail?: string) => detail === undefined + ? "Background job could not start the configured shell; run /ca-doctor." + : `Background job could not start the configured shell (${detail}); run /ca-doctor.`; + + function refusalFixture(openTree: BackgroundJobRuntimeDependencies["openTree"]) { + const activityEvents: unknown[] = []; + const runtime = createBackgroundJobRuntime({ openTree, activity: { publish: (event) => activityEvents.push(event) } })!; + const lease = Object.freeze({}); + return { + activityEvents, + runtime, + lease, + base: { + authorization: { lease, isCurrent: (candidate: unknown) => candidate === lease }, + command: "printf ok", + cwd: process.cwd(), + env: [] as readonly (readonly [string, string | undefined])[], + shellPath: process.platform === "win32" ? process.execPath : "/bin/bash", + }, + }; + } + + test("reports an allowlisted containment reason when the spawn refuses, and nothing on a policy refusal", async () => { + const openTree = vi.fn(async () => { + throw new Error("Windows Job Object holder refused containment (arming): ready-timeout"); + }); + const { runtime, lease, base, activityEvents } = refusalFixture(openTree); + const diagnostics: string[] = []; + + expect(await runtime.launch({ ...base, label: "spawn refusal", onRefusal: (d) => diagnostics.push(d) })).toBeUndefined(); + expect(diagnostics).toEqual([SPAWN_REFUSAL("ready-timeout")]); + + // OB-6: the refusal stays fail-closed - terminal state reached, published, nothing owned. + expect(runtime.getJob(1)).toMatchObject({ state: "failed" }); + expect(runtime.activeJobIds()).toEqual([]); + expect(activityEvents).toEqual([ + { kind: "job", id: "1", label: "spawn refusal", state: "active" }, + { kind: "job", id: "1", label: "spawn refusal", state: "completed" }, + ]); + + // OB-1 negative: a policy refusal is predictable and reports no spawn diagnostic. + diagnostics.length = 0; + expect(await runtime.launch({ + ...base, label: "stale", authorization: { lease, isCurrent: () => false }, + onRefusal: (d) => diagnostics.push(d), + })).toBeUndefined(); + expect(diagnostics).toEqual([]); + expect(openTree).toHaveBeenCalledTimes(1); + }); + + test("reports a bounded errno and never the shell path, cwd, command, or env", async () => { + const secret = "fixture-env-value"; + const openTree = vi.fn(async () => { + const error: NodeJS.ErrnoException = new Error( + `ENOENT: no such file or directory, realpath '${process.cwd()}/missing-shell'`, + ); + error.code = "ENOENT"; + throw error; + }); + const { runtime, base } = refusalFixture(openTree); + const diagnostics: string[] = []; + + expect(await runtime.launch({ + ...base, label: "errno", env: [["SECRET", secret]], onRefusal: (d) => diagnostics.push(d), + })).toBeUndefined(); + + expect(diagnostics).toEqual([SPAWN_REFUSAL("ENOENT")]); + const reported = diagnostics.join("\n"); + for (const forbidden of [process.cwd(), base.shellPath, "missing-shell", "printf ok", "SECRET", secret]) { + expect(reported).not.toContain(forbidden); + } + }); + + test("degrades to a generic diagnostic when the spawn failure carries no recognized reason", async () => { + for (const thrown of [ + new Error("process-tree launch identities are invalid"), + Object.assign(new Error("hostile"), { code: "ENOENT; rm -rf /" }), + Object.assign(new Error("hostile"), { code: "E".repeat(64) }), + Object.assign(new Error("hostile"), { code: 13 }), + "not an error at all", + ]) { + const { runtime, base } = refusalFixture(vi.fn(async () => { throw thrown; })); + const diagnostics: string[] = []; + expect(await runtime.launch({ ...base, label: "unknown", onRefusal: (d) => diagnostics.push(d) })).toBeUndefined(); + expect(diagnostics).toEqual([SPAWN_REFUSAL()]); + expect(diagnostics[0]).not.toContain("hostile"); + expect(diagnostics[0]).not.toContain("identities"); + expect(diagnostics[0]).not.toContain("rm -rf"); + } + }); + + test("correlates each spawn diagnostic to its own launch when launches overlap", async () => { + // A runtime-scoped "last refusal" field cannot pass this: the two launches settle in + // the reverse of the order they spawned, so a shared slot reports one launch's + // environment failure as the other's - the exact misdiagnosis #504 is about. + const queued = ["ready-timeout", "pid-invalid"]; + let index = 0; + let release!: () => void; + const gate = new Promise((resolveGate) => { release = resolveGate; }); + const openTree = vi.fn(async () => { + const mine = queued[index++]!; + if (mine === "ready-timeout") await gate; + throw new Error(`Windows contained Pi launch was refused: ${mine}`); + }); + const { runtime, base } = refusalFixture(openTree); + const first: string[] = []; + const second: string[] = []; + + const slower = runtime.launch({ ...base, label: "first", onRefusal: (d) => first.push(d) }); + const faster = runtime.launch({ ...base, label: "second", onRefusal: (d) => second.push(d) }); + expect(await faster).toBeUndefined(); + expect(second).toEqual([SPAWN_REFUSAL("pid-invalid")]); + release(); + expect(await slower).toBeUndefined(); + + expect(first).toEqual([SPAWN_REFUSAL("ready-timeout")]); + expect(second).toEqual([SPAWN_REFUSAL("pid-invalid")]); + }); + + test("a throwing or malformed refusal sink never changes the refusal path", async () => { + const openTree = vi.fn(async () => { throw new Error("Windows contained Pi launch was refused: spawn-error"); }); + const { runtime, base, activityEvents } = refusalFixture(openTree); + + expect(await runtime.launch({ + ...base, label: "hostile sink", onRefusal: () => { throw new Error("sink exploded"); }, + })).toBeUndefined(); + expect(runtime.getJob(1)).toMatchObject({ state: "failed" }); + expect(activityEvents).toEqual([ + { kind: "job", id: "1", label: "hostile sink", state: "active" }, + { kind: "job", id: "1", label: "hostile sink", state: "completed" }, + ]); + expect(await runtime.dispose()).toBe(true); + + // A non-function sink is malformed input, refused before anything is spawned. + const malformed = refusalFixture(vi.fn(async () => { throw new Error("unreachable"); })); + expect(await malformed.runtime.launch({ + ...malformed.base, label: "malformed sink", onRefusal: "not a function" as never, + })).toBeUndefined(); + expect(malformed.activityEvents).toEqual([]); + }); + async function runLiveGitBashJob( openTree?: BackgroundJobRuntimeDependencies["openTree"], ): Promise { diff --git a/plugins/ca-pi/tools/test/commands.test.ts b/plugins/ca-pi/tools/test/commands.test.ts index 6f1cb04e..db9bc337 100644 --- a/plugins/ca-pi/tools/test/commands.test.ts +++ b/plugins/ca-pi/tools/test/commands.test.ts @@ -106,6 +106,67 @@ describe("generated Pi command aliases", () => { expect(notifications.some((message) => message.includes("Background job completed:"))).toBe(false); }); + // #504 OB-5: the tool already asks the runtime why a launch refused, but only the sticky + // health diagnostic could ever answer. A spawn failure left the operator with the generic + // "Background job launch was blocked" and no cause. + test("surfaces the launch refusal diagnostic, preferring runtime health when both are present", async () => { + const spawnRefusal = "Background job could not start the configured shell (ENOENT); run /ca-doctor."; + const unhealthy = "Background job cleanup could not be verified; run /ca-doctor."; + + // The tool re-checks runtime health at entry (native-background.ts), so a runtime that is + // already unhealthy never launches. The precedence case only arises when the runtime + // degrades DURING the launch — which is exactly when both diagnostics are available. + async function failureText( + health: (launched: boolean) => Readonly<{ healthy: boolean; diagnostic?: string }>, + ): Promise { + const fixture = await tempPlugin(); + const commands = new Map unknown }>(); + const sourceInfo = { path: resolve(fixture.root, "extensions", "codearbiter.js"), source: "fixture", scope: "user" as const, origin: "package" as const, baseDir: fixture.root }; + const pi = { + on: () => undefined, + registerCommand: (name: string, options: { handler: (args: string, context: ExtensionContextPort) => unknown }) => { commands.set(name, options); }, + sendUserMessage: () => undefined, + getCommands: () => [...commands.keys()].map((name) => ({ name, source: "extension" as const, sourceInfo })), + } satisfies ParentPiPort; + const lease = Object.freeze({}); + let launched = false; + const runtime: BackgroundJobRuntime = { + launch: async (input) => { + launched = true; + input.onRefusal?.(spawnRefusal); + return undefined; + }, + cancel: async () => true, stop: async () => true, settled: async () => undefined, + health: () => health(launched), + getJob: () => undefined, listJobs: () => [], activeJobIds: () => [], + tail: () => undefined, dispose: async () => true, + }; + const controller = createNativeBackgroundController(pi, { + packageRoot: fixture.root, currentLifecycle: () => lease, toolOwnershipValid: () => true, + createRuntime: () => runtime, createAuditLifecycleId: () => "c".repeat(64), + resolveLaunch: async () => ({ shellPath: process.execPath, env: [] }), + audit: async () => true, + }); + const context: ExtensionContextPort = { + cwd: fixture.root, signal: undefined, mode: "tui", hasUI: true, + isProjectTrusted: () => true, sessionManager: { getSessionId: () => "session" }, + ui: { setStatus: () => undefined, notify: () => undefined }, + }; + controller.register(context); expect(controller.activate(context)).toBe(true); + const result = await controller.toolFactory(fixture.root).execute( + "refusal", { command: "work", label: "refusal" }, undefined, undefined, context, + ); + expect(result.isError).toBe(true); + return (result.content as Array<{ text: string }>)[0]!.text; + } + + expect(await failureText(() => ({ healthy: true }))).toBe(spawnRefusal); + // A sticky unhealthy runtime is the more actionable fact and still wins. + expect(await failureText((launched) => launched + ? { healthy: false, diagnostic: unhealthy } + : { healthy: true })).toBe(unhealthy); + }); + test("cancels and terminal-audits without publishing when the tool signal aborts during launch audit", async () => { const fixture = await tempPlugin(); const commands = new Map unknown }>();