Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .codearbiter/gate-events.log
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
"version": "0.1.33",
"version": "0.1.34",
"private": true,
"license": "AGPL-3.0-only",
"engines": {
Expand Down
19 changes: 19 additions & 0 deletions plugins/ca-pi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 35 additions & 3 deletions plugins/ca-pi/extensions/codearbiter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 } : {}
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion plugins/ca-pi/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
"version": "0.1.33",
"version": "0.1.34",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
Expand Down
50 changes: 47 additions & 3 deletions plugins/ca-pi/tools/src/background-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<PiShellLaunch>;
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,
Expand All @@ -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 } : {}),
});
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion plugins/ca-pi/tools/src/native-background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Loading
Loading