@bridge_gpt/mcp-server 0.2.34 → 0.2.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +32 -13
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +259 -0
- package/build/setup-epic.js +772 -28
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -72,7 +72,15 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
72
72
|
spawnProcess: () => {
|
|
73
73
|
throw new Error("spawnProcess not configured for this test");
|
|
74
74
|
},
|
|
75
|
-
readFile: async () => {
|
|
75
|
+
readFile: async (filePath) => {
|
|
76
|
+
// BAPI-731: the prepared worktree's own `.mcp.json` must carry the required
|
|
77
|
+
// `bridge-api` registration or the runner refuses to spawn (an isolated
|
|
78
|
+
// worker has no operator-scoped registration to fall back to). Serve a
|
|
79
|
+
// minimal valid one so pre-existing spawn tests still reach the spawn they
|
|
80
|
+
// were written to assert on.
|
|
81
|
+
if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
|
|
82
|
+
return JSON.stringify({ mcpServers: { "bridge-api": { command: "node", args: [] } } });
|
|
83
|
+
}
|
|
76
84
|
// Mirror real fs/promises: a missing-file rejection carries code "ENOENT"
|
|
77
85
|
// (so BAPI-664 command provisioning treats absent files as fillable).
|
|
78
86
|
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
@@ -81,19 +89,80 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
81
89
|
mkdir: async () => undefined,
|
|
82
90
|
stat: async () => ({ mode: 0o644 }),
|
|
83
91
|
statfs: async () => ({ bavail: 1_000_000, bsize: 4096 }),
|
|
92
|
+
// BAPI-731 worker config isolation boundaries. In-memory fakes: no test ever
|
|
93
|
+
// creates, permissions, or removes a real directory. Isolation is
|
|
94
|
+
// fail-CLOSED, so a spawn-path test that did not supply these would refuse to
|
|
95
|
+
// spawn — these defaults keep the many pre-existing spawn tests exercising
|
|
96
|
+
// what they were written to exercise.
|
|
97
|
+
mkdtemp: async (prefix) => `${prefix}test`,
|
|
98
|
+
chmod: async () => { },
|
|
99
|
+
rmRecursive: async () => { },
|
|
100
|
+
readdir: async () => [],
|
|
101
|
+
lstatPath: async () => ({ isDirectory: true, isSymbolicLink: false, mtimeMs: 0 }),
|
|
102
|
+
tmpdir: () => "/tmp",
|
|
84
103
|
sleep: clock.sleep,
|
|
85
104
|
now: clock.now,
|
|
86
105
|
setTimer: clock.setTimer,
|
|
87
106
|
clearTimer: clock.clearTimer,
|
|
88
|
-
|
|
107
|
+
// `ANTHROPIC_API_KEY` is the isolation strategy's required credential source
|
|
108
|
+
// (see `worker-config-isolation.ts`); without it capability evaluation
|
|
109
|
+
// reports `unsupported-auth-layout` and no spawn test could run.
|
|
110
|
+
// `BAPI_API_KEY` is the seed credential for the isolated Bridge store
|
|
111
|
+
// (BAPI-740/F2); without it the runner refuses pre-spawn with
|
|
112
|
+
// `ContractError.BridgeCredentialUnavailable` and no spawn test could run.
|
|
113
|
+
env: {
|
|
114
|
+
ANTHROPIC_API_KEY: "test-key-not-a-real-credential",
|
|
115
|
+
BAPI_API_KEY: "test-bapi-key-not-a-real-credential",
|
|
116
|
+
},
|
|
89
117
|
cwd: "/repo",
|
|
90
118
|
platform: "linux",
|
|
91
119
|
homedir: () => "/home/tester",
|
|
92
120
|
fetch: async () => ({ status: 204, text: async () => "" }),
|
|
93
121
|
log: () => { },
|
|
94
122
|
errorLog: () => { },
|
|
123
|
+
mcpServerInvocation: {
|
|
124
|
+
form: "absolute-build-path",
|
|
125
|
+
nodeExecutable: "node",
|
|
126
|
+
serverEntryPath: "/repo/mcp_server/build/index.js",
|
|
127
|
+
},
|
|
95
128
|
};
|
|
96
|
-
|
|
129
|
+
const merged = { ...base, ...overrides };
|
|
130
|
+
// BAPI-731: two fail-CLOSED spawn gates depend on ambient fixture state that
|
|
131
|
+
// predates them, so an override supplied for an unrelated reason would
|
|
132
|
+
// silently turn a spawn test into a refusal test. Both are re-applied here as
|
|
133
|
+
// FALLBACKS — an override that deliberately exercises a refusal still wins,
|
|
134
|
+
// because it either supplies its own key/registration or explicitly omits one.
|
|
135
|
+
// 1. Isolation needs an Anthropic key, and the Bridge-store seeding refusal
|
|
136
|
+
// (BAPI-740/F2) needs a resolvable `BAPI_API_KEY`. A test overriding `env`
|
|
137
|
+
// for some other purpose keeps both unless it sets the key itself — an
|
|
138
|
+
// override that deliberately exercises a refusal supplies its own value
|
|
139
|
+
// (empty string included: presence wins over the fallback).
|
|
140
|
+
if (overrides.env) {
|
|
141
|
+
const fallbacks = {};
|
|
142
|
+
for (const key of ["ANTHROPIC_API_KEY", "BAPI_API_KEY"]) {
|
|
143
|
+
if (!(key in overrides.env))
|
|
144
|
+
fallbacks[key] = base.env[key];
|
|
145
|
+
}
|
|
146
|
+
merged.env = { ...fallbacks, ...overrides.env };
|
|
147
|
+
}
|
|
148
|
+
// 2. The required-registration check reads the worktree's `.mcp.json`. A test
|
|
149
|
+
// overriding `readFile` (usually to script command provisioning) still gets
|
|
150
|
+
// the default registration for that one path when its own read rejects.
|
|
151
|
+
if (overrides.readFile) {
|
|
152
|
+
const override = overrides.readFile;
|
|
153
|
+
merged.readFile = async (filePath) => {
|
|
154
|
+
try {
|
|
155
|
+
return await override(filePath);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
|
|
159
|
+
return base.readFile(filePath);
|
|
160
|
+
}
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return merged;
|
|
97
166
|
}
|
|
98
167
|
/** Sensible default executor options for tests. */
|
|
99
168
|
export function makeTestOptions(overrides = {}) {
|
|
@@ -111,6 +180,9 @@ export function makeTestOptions(overrides = {}) {
|
|
|
111
180
|
baseBranch: "main",
|
|
112
181
|
advisoryParserEnabled: false,
|
|
113
182
|
defaultJobTimeoutSeconds: 1200,
|
|
183
|
+
// The executor requires an explicit base URL (BAPI-676) — supply a fake one
|
|
184
|
+
// so preflight-driven tests are not fatal on the URL prerequisite.
|
|
185
|
+
baseUrl: "http://localhost:8002",
|
|
114
186
|
...overrides,
|
|
115
187
|
};
|
|
116
188
|
}
|
package/build/executor/types.js
CHANGED
|
@@ -1,18 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* are a dumb, pull-based local worker. They MUST NOT import from the v1
|
|
6
|
-
* conductor event/ledger graph — `conductor/store.ts`, `conductor/taxonomy.ts`,
|
|
7
|
-
* `conductor/event-accessors.ts`, `conductor/epic-runtime.ts`,
|
|
8
|
-
* `conductor/epic-reconcile.ts`, `conductor/epic-state.ts`, or any conductor
|
|
9
|
-
* producer / supervisor-runtime module. Allowed conductor reuse is limited to
|
|
10
|
-
* read-only / deterministic helpers — the deny-enforcement preflight
|
|
11
|
-
* (`conductor/deny-enforcement-preflight.ts`), and (BAPI-535 T3b) the local merge
|
|
12
|
-
* executor (`conductor/local-merge.ts`) plus the read-only Bridge API access +
|
|
13
|
-
* merge request/response types (`conductor/bridge-api-client.ts`) for the
|
|
14
|
-
* deterministic `merge` job.
|
|
15
|
-
* All subprocess / HTTP / filesystem / clock access is behind injected deps so
|
|
16
|
-
* every requirement is unit-testable on Linux CI with no real I/O.
|
|
17
|
-
*/
|
|
18
|
-
export {};
|
|
1
|
+
/** Normalize either heartbeat response form into the structured shape. */
|
|
2
|
+
export function normalizeHeartbeatResult(response) {
|
|
3
|
+
return typeof response === "string" ? { outcome: response } : response;
|
|
4
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* MCP config is ever added. When no safe alias is available, `--model` is omitted.
|
|
7
7
|
*/
|
|
8
8
|
import { resolveAgentSpec, resolveModelAlias, isModelTier, isValidModelAlias, } from "../agent-registry.js";
|
|
9
|
+
import { ExecutorNamedError } from "./job-errors.js";
|
|
9
10
|
import { isRecoveryJobType } from "./job-types.js";
|
|
10
11
|
/**
|
|
11
12
|
* Resolve the Claude model alias from a job payload. Accepts a direct
|
|
@@ -38,13 +39,17 @@ export function resolveExecutorModelAlias(payload) {
|
|
|
38
39
|
* `implement` and the recovery jobs, builds the conservative
|
|
39
40
|
* `/implement-ticket <ticket_key> --auto` prompt.
|
|
40
41
|
*
|
|
41
|
-
* Structured `payload.prompt_spec` rendering
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* returns `ok:false` from this function by design — that non-ok
|
|
45
|
-
* signal for the runner to fall through to prompt-spec rendering,
|
|
46
|
-
* failure. A real spawn job with neither an explicit prompt, a
|
|
47
|
-
* nor a `prompt_spec` is the genuine contract failure.
|
|
42
|
+
* Structured `payload.prompt_spec` rendering is NOT done here: it needs the
|
|
43
|
+
* prepared worktree/git state, so `job-runner.ts` renders it in the prepared-spawn
|
|
44
|
+
* path AFTER worktree preparation. A job with a `prompt_spec` and no explicit
|
|
45
|
+
* prompt therefore returns `ok:false` from this function by design — that non-ok
|
|
46
|
+
* result is the signal for the runner to fall through to prompt-spec rendering,
|
|
47
|
+
* not a terminal failure. A real spawn job with neither an explicit prompt, a
|
|
48
|
+
* synthesis path, nor a `prompt_spec` is the genuine contract failure.
|
|
49
|
+
*
|
|
50
|
+
* BAPI-725: `spec_review` now arrives with an explicit server-built
|
|
51
|
+
* `payload.prompt` and so resolves on the first branch, like `implement` with an
|
|
52
|
+
* explicit prompt. It no longer depends on the prompt-spec fall-through.
|
|
48
53
|
*/
|
|
49
54
|
/**
|
|
50
55
|
* True for the job types that synthesize `/implement-ticket <KEY> --auto` when no
|
|
@@ -75,10 +80,18 @@ export function resolveExecutorPrompt(job) {
|
|
|
75
80
|
// change addressing, and merge-conflict resolution (see .claude/commands/
|
|
76
81
|
// implement-ticket.md "Clean session exit"). So the recovery jobs that operate
|
|
77
82
|
// on the ticket's existing branch/PR — `remediate`/`ci_fix`/`rebase` — resume
|
|
78
|
-
//
|
|
79
|
-
//
|
|
83
|
+
// by RE-ENTERING THE COMPLETE RECIPE from step 1, not a distinct resume path.
|
|
84
|
+
// That re-entry is safe (BAPI-746), backed by re-entry handling already
|
|
85
|
+
// present in three of the recipe's own steps, not a new assumption: plan
|
|
86
|
+
// generation is idempotent because the server reuses a fresh existing plan
|
|
87
|
+
// instead of regenerating it (BAPI-745, `reused: true`); commit-and-push.md
|
|
88
|
+
// treats a clean tree with an already-pushed branch/PR as a legitimate
|
|
89
|
+
// re-entry rather than an empty implementation; and create-pr.md treats an
|
|
90
|
+
// already-open pull request on the head branch as satisfying its success
|
|
91
|
+
// condition. Without synthesizing this command, a code_review
|
|
80
92
|
// `changes_requested` (near-certain in real runs) enqueues a `remediate` job
|
|
81
|
-
// that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap
|
|
93
|
+
// that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap
|
|
94
|
+
// #3).
|
|
82
95
|
const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
|
|
83
96
|
? job.ticket_key.trim()
|
|
84
97
|
: null;
|
|
@@ -90,16 +103,64 @@ export function resolveExecutorPrompt(job) {
|
|
|
90
103
|
error: `no usable prompt for job_type '${job.job_type}' (no payload.prompt and no ticket_key)`,
|
|
91
104
|
};
|
|
92
105
|
}
|
|
106
|
+
/** The ratified default — the pre-BAPI-725 behavior for every job. */
|
|
107
|
+
export const DEFAULT_WORKER_PERMISSION_POSTURE = "skip_permissions";
|
|
108
|
+
const WORKER_PERMISSION_POSTURES = [
|
|
109
|
+
"skip_permissions",
|
|
110
|
+
"accept_edits",
|
|
111
|
+
];
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the permission posture at the payload boundary.
|
|
114
|
+
*
|
|
115
|
+
* - ABSENT (`undefined`/`null`) → the ratified `skip_permissions` default, so
|
|
116
|
+
* legacy jobs enqueued before BAPI-725 keep working unchanged.
|
|
117
|
+
* - a supported value → returned as-is.
|
|
118
|
+
* - present but unsupported → THROWS a named contract error before argv
|
|
119
|
+
* construction, so the job fails loudly instead of spawning under a posture
|
|
120
|
+
* nobody chose.
|
|
121
|
+
*
|
|
122
|
+
* The thrown message names the FIELD and nothing else. An operator can paste a
|
|
123
|
+
* credential into a policy value by mistake, and this error text travels to the
|
|
124
|
+
* server through `/fail` — so echoing the supplied value would turn a typo into
|
|
125
|
+
* an exfiltration path.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveWorkerPermissionPosture(payload) {
|
|
128
|
+
const p = payload && typeof payload === "object" ? payload : {};
|
|
129
|
+
const raw = p.worker_permission_posture;
|
|
130
|
+
if (raw === undefined || raw === null) {
|
|
131
|
+
return DEFAULT_WORKER_PERMISSION_POSTURE;
|
|
132
|
+
}
|
|
133
|
+
if (typeof raw === "string" &&
|
|
134
|
+
WORKER_PERMISSION_POSTURES.includes(raw)) {
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
throw new ExecutorNamedError("ContractError.PermissionPosture", "job payload worker_permission_posture is present but is not a supported posture " +
|
|
138
|
+
"(expected 'skip_permissions' or 'accept_edits').");
|
|
139
|
+
}
|
|
93
140
|
/**
|
|
94
141
|
* Build the exact headless Claude argv. `--model <alias>` is included only when
|
|
95
142
|
* an alias is provided. No MCP config arguments are ever emitted.
|
|
143
|
+
*
|
|
144
|
+
* Exactly ONE permission form is emitted, selected by `posture`:
|
|
145
|
+
* `--dangerously-skip-permissions` for `skip_permissions`, or the two adjacent
|
|
146
|
+
* entries `--permission-mode acceptEdits` for `accept_edits`. They are mutually
|
|
147
|
+
* exclusive by construction — emitting both would let the skip flag win silently
|
|
148
|
+
* and make the policy key look effective while doing nothing.
|
|
149
|
+
*
|
|
150
|
+
* The posture changes how Claude PROMPTS, not what it may do: the deny layer is
|
|
151
|
+
* provisioned by `job-runner.ts` before the spawn in both postures.
|
|
96
152
|
*/
|
|
97
|
-
export function buildClaudeExecutorArgv(prompt, alias) {
|
|
153
|
+
export function buildClaudeExecutorArgv(prompt, alias, posture = DEFAULT_WORKER_PERMISSION_POSTURE) {
|
|
98
154
|
const argv = ["-p", prompt, "--output-format", "stream-json", "--verbose"];
|
|
99
155
|
if (alias) {
|
|
100
156
|
argv.push("--model", alias);
|
|
101
157
|
}
|
|
102
|
-
|
|
158
|
+
if (posture === "accept_edits") {
|
|
159
|
+
argv.push("--permission-mode", "acceptEdits");
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
argv.push("--dangerously-skip-permissions");
|
|
163
|
+
}
|
|
103
164
|
return argv;
|
|
104
165
|
}
|
|
105
166
|
/** The worker executable is always exactly `claude`. */
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/** Restrictive mode for the isolation directory itself (owner-only). */
|
|
2
|
+
export const ISOLATION_DIR_MODE = 0o700;
|
|
3
|
+
/** Owner-only mode for the seeded Bridge credential file. */
|
|
4
|
+
export const ISOLATION_FILE_MODE = 0o600;
|
|
5
|
+
/**
|
|
6
|
+
* Fixed name prefix for every directory this module creates. The startup sweep
|
|
7
|
+
* recognizes ONLY this prefix, which is what keeps it from deleting unrelated
|
|
8
|
+
* temporary directories that happen to share the parent.
|
|
9
|
+
*/
|
|
10
|
+
export const ISOLATION_DIR_PREFIX = "bapi-conductor-claude-cfg-";
|
|
11
|
+
/**
|
|
12
|
+
* Age past which the startup sweep may remove an orphan, deliberately far above
|
|
13
|
+
* any supported worker lifetime (the longest executor job timeout is measured in
|
|
14
|
+
* hours, not days). A directory younger than this may still belong to a LIVE
|
|
15
|
+
* worker owned by a concurrent executor process, so the threshold is the only
|
|
16
|
+
* thing preventing the sweep from deleting an in-use configuration.
|
|
17
|
+
*/
|
|
18
|
+
export const ISOLATION_ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
/** Platforms whose POSIX mode model supports the `0700` containment above. */
|
|
20
|
+
const SUPPORTED_PLATFORMS = ["darwin", "linux"];
|
|
21
|
+
/** Fixed remediation text per reason. Never includes a path or a value. */
|
|
22
|
+
const REASON_MESSAGES = {
|
|
23
|
+
"unsupported-platform": "worker config isolation is not supported on this platform (POSIX 0700/0600 containment is required)",
|
|
24
|
+
"unsupported-auth-layout": "no ANTHROPIC_API_KEY is available to the executor; an OAuth/subscription-only login does not " +
|
|
25
|
+
"follow an isolated CLAUDE_CONFIG_DIR, and copying operator credentials is not permitted",
|
|
26
|
+
"missing-filesystem-dependency": "the executor was constructed without the filesystem boundaries worker config isolation requires",
|
|
27
|
+
"unsafe-isolation-parent": "the isolation parent directory is a symbolic link or not a directory; refusing to create an " +
|
|
28
|
+
"isolated config directory beneath it",
|
|
29
|
+
"directory-creation-failed": "the isolated config directory could not be created or permissioned",
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Render a capability failure as a bounded, secret-free sentence naming only the
|
|
33
|
+
* platform and the fixed reason — the exact text posted as an `error_message`.
|
|
34
|
+
*/
|
|
35
|
+
export function formatWorkerConfigIsolationReason(platform, reason) {
|
|
36
|
+
return `worker config isolation unavailable on ${platform}: ${REASON_MESSAGES[reason]}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether isolation CAN be established here, without creating anything.
|
|
40
|
+
*
|
|
41
|
+
* Read-only by construction — this is what lets `doctor` reuse it (BAPI-731
|
|
42
|
+
* requirement 4) while keeping doctor's no-writes contract. It never reads the
|
|
43
|
+
* operator's `.claude.json`, never touches the credential store, and never
|
|
44
|
+
* returns a credential value: the auth check is presence-only.
|
|
45
|
+
*/
|
|
46
|
+
export function evaluateWorkerConfigIsolationCapability(deps) {
|
|
47
|
+
const platform = String(deps.platform);
|
|
48
|
+
if (!SUPPORTED_PLATFORMS.includes(deps.platform)) {
|
|
49
|
+
return { supported: false, platform, reason: "unsupported-platform" };
|
|
50
|
+
}
|
|
51
|
+
// Presence-only. The VALUE is never read into any result, log, or diagnostic.
|
|
52
|
+
const apiKey = deps.env?.ANTHROPIC_API_KEY;
|
|
53
|
+
if (typeof apiKey !== "string" || apiKey.trim().length === 0) {
|
|
54
|
+
return { supported: false, platform, reason: "unsupported-auth-layout" };
|
|
55
|
+
}
|
|
56
|
+
return { supported: true, platform, strategy: "api-key-env" };
|
|
57
|
+
}
|
|
58
|
+
function failure(platform, reason) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
platform,
|
|
62
|
+
reason,
|
|
63
|
+
message: formatWorkerConfigIsolationReason(platform, reason),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Create a private, owner-only configuration directory for ONE job attempt.
|
|
68
|
+
*
|
|
69
|
+
* The directory lives under the executor's temporary root — never inside the
|
|
70
|
+
* worktree, which would put executor-owned state into the diff the worker is
|
|
71
|
+
* about to commit and would be destroyed by the worktree's own reset/clean paths.
|
|
72
|
+
*
|
|
73
|
+
* Seeds NOTHING of the operator's Claude configuration. Inventory finding 3
|
|
74
|
+
* established that no trust entry is needed to keep a headless worker from
|
|
75
|
+
* prompting, and finding 4 established that the ANTHROPIC credential travels in
|
|
76
|
+
* the environment. No projected fragment of the operator's configuration is
|
|
77
|
+
* written, so none can get stale or over-grant.
|
|
78
|
+
*
|
|
79
|
+
* The single exception is {@link SeededBridgeCredential} — see that type for why
|
|
80
|
+
* an empty directory is NOT sufficient for the Bridge credential specifically:
|
|
81
|
+
* finding 4 does not extend to `BAPI_API_KEY`, which is denied from the worker
|
|
82
|
+
* environment on purpose, leaving the redirected store as its only route in.
|
|
83
|
+
*/
|
|
84
|
+
export async function establishWorkerConfigIsolation(deps, options = {}) {
|
|
85
|
+
const capability = evaluateWorkerConfigIsolationCapability(deps);
|
|
86
|
+
if (!capability.supported) {
|
|
87
|
+
return failure(capability.platform, capability.reason);
|
|
88
|
+
}
|
|
89
|
+
const platform = capability.platform;
|
|
90
|
+
let parent;
|
|
91
|
+
try {
|
|
92
|
+
parent = deps.tmpdir();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
96
|
+
}
|
|
97
|
+
if (typeof parent !== "string" || parent.length === 0) {
|
|
98
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
99
|
+
}
|
|
100
|
+
// Reject a symlinked or non-directory parent BEFORE creating anything beneath
|
|
101
|
+
// it: `mkdtemp` under a symlink would silently place a credential-adjacent
|
|
102
|
+
// directory wherever the link points.
|
|
103
|
+
try {
|
|
104
|
+
const parentMeta = await deps.lstatPath(parent);
|
|
105
|
+
if (!parentMeta || parentMeta.isSymbolicLink || !parentMeta.isDirectory) {
|
|
106
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
111
|
+
}
|
|
112
|
+
// `mkdtemp` is atomic and collision-free, so two concurrent jobs on the same
|
|
113
|
+
// executor can never be handed the same directory.
|
|
114
|
+
let created;
|
|
115
|
+
try {
|
|
116
|
+
created = await deps.mkdtemp(deps.join(parent, ISOLATION_DIR_PREFIX));
|
|
117
|
+
if (typeof created !== "string" || created.length === 0) {
|
|
118
|
+
return failure(platform, "directory-creation-failed");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// The raw error may carry a host path; only the fixed reason escapes.
|
|
123
|
+
return failure(platform, "directory-creation-failed");
|
|
124
|
+
}
|
|
125
|
+
// Verify what we actually got is a real directory and not a link, then narrow
|
|
126
|
+
// it to owner-only. `mkdtemp` already creates at 0700 on POSIX; the explicit
|
|
127
|
+
// chmod makes the guarantee independent of the platform's umask behavior.
|
|
128
|
+
try {
|
|
129
|
+
const meta = await deps.lstatPath(created);
|
|
130
|
+
if (!meta || meta.isSymbolicLink || !meta.isDirectory) {
|
|
131
|
+
await safeDispose(deps, created);
|
|
132
|
+
return failure(platform, "unsafe-isolation-parent");
|
|
133
|
+
}
|
|
134
|
+
await deps.chmod(created, ISOLATION_DIR_MODE);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
await safeDispose(deps, created);
|
|
138
|
+
return failure(platform, "directory-creation-failed");
|
|
139
|
+
}
|
|
140
|
+
// The isolated XDG root lives INSIDE the disposable directory, so a single
|
|
141
|
+
// recursive removal reclaims both and no second lifetime needs tracking.
|
|
142
|
+
const xdgConfigHome = deps.join(created, "xdg");
|
|
143
|
+
try {
|
|
144
|
+
await deps.mkdir(xdgConfigHome, { recursive: true });
|
|
145
|
+
await deps.chmod(xdgConfigHome, ISOLATION_DIR_MODE);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
await safeDispose(deps, created);
|
|
149
|
+
return failure(platform, "directory-creation-failed");
|
|
150
|
+
}
|
|
151
|
+
// Seed the one credential the redirect would otherwise strip. Fail-CLOSED,
|
|
152
|
+
// matching this module's posture: a worker that cannot reach its Bridge MCP
|
|
153
|
+
// tools cannot do the job it was dispatched for, and the failure mode is a
|
|
154
|
+
// clean exit that looks like success. Refusing to spawn is strictly better
|
|
155
|
+
// than spawning a worker guaranteed to accomplish nothing.
|
|
156
|
+
const credential = options.bridgeCredential;
|
|
157
|
+
if (credential) {
|
|
158
|
+
if (!deps.writeFile) {
|
|
159
|
+
await safeDispose(deps, created);
|
|
160
|
+
return failure(platform, "missing-filesystem-dependency");
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const bridgeDir = deps.join(xdgConfigHome, "bridge");
|
|
164
|
+
await deps.mkdir(bridgeDir, { recursive: true });
|
|
165
|
+
await deps.chmod(bridgeDir, ISOLATION_DIR_MODE);
|
|
166
|
+
const storePath = deps.join(bridgeDir, "credentials.json");
|
|
167
|
+
await deps.writeFile(storePath,
|
|
168
|
+
// Exactly the shape resolveCredentialBundle reads: a logical target
|
|
169
|
+
// mapping to its named secret values.
|
|
170
|
+
`${JSON.stringify({ [`bapi:${credential.repoName}`]: { BAPI_API_KEY: credential.apiKey } }, null, 2)}\n`);
|
|
171
|
+
// Narrow AFTER writing: the content is secret, and a permissive window
|
|
172
|
+
// between create and chmod is the whole risk being closed here.
|
|
173
|
+
await deps.chmod(storePath, ISOLATION_FILE_MODE);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// The raw error may carry a host path or the secret; only the fixed
|
|
177
|
+
// reason escapes.
|
|
178
|
+
await safeDispose(deps, created);
|
|
179
|
+
return failure(platform, "directory-creation-failed");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
let disposed = false;
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
isolation: {
|
|
186
|
+
claudeConfigDir: created,
|
|
187
|
+
xdgConfigHome,
|
|
188
|
+
async dispose() {
|
|
189
|
+
if (disposed)
|
|
190
|
+
return null;
|
|
191
|
+
disposed = true;
|
|
192
|
+
try {
|
|
193
|
+
await deps.rmRecursive(created);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Best-effort by contract: cleanup failure must never replace an
|
|
198
|
+
// already-determined job outcome, and the raw error never escapes.
|
|
199
|
+
return "worker config isolation directory could not be removed";
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Remove a partially-created directory, swallowing every failure. */
|
|
206
|
+
async function safeDispose(deps, path) {
|
|
207
|
+
try {
|
|
208
|
+
await deps.rmRecursive(path);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
/* best-effort */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Remove stale isolation directories left behind by an ungraceful executor exit
|
|
216
|
+
* (SIGKILL, OOM, host reboot) where per-job disposal never ran.
|
|
217
|
+
*
|
|
218
|
+
* Deliberately conservative, because this function deletes directories: it looks
|
|
219
|
+
* only inside the executor's own temporary root, recognizes only entries
|
|
220
|
+
* carrying {@link ISOLATION_DIR_PREFIX}, requires NON-following metadata to say
|
|
221
|
+
* the entry is a real directory (so a symlink is skipped rather than followed),
|
|
222
|
+
* and requires an age beyond {@link ISOLATION_ORPHAN_MAX_AGE_MS} so a live
|
|
223
|
+
* concurrent executor's active job directory is never removed.
|
|
224
|
+
*
|
|
225
|
+
* Never throws: startup must proceed even if the sweep cannot run at all.
|
|
226
|
+
*/
|
|
227
|
+
export async function sweepOrphanedWorkerConfigDirectories(deps) {
|
|
228
|
+
const result = { removed: 0, skipped: 0, diagnostics: [] };
|
|
229
|
+
const maxAgeMs = typeof deps.maxAgeMs === "number" ? deps.maxAgeMs : ISOLATION_ORPHAN_MAX_AGE_MS;
|
|
230
|
+
let parent;
|
|
231
|
+
try {
|
|
232
|
+
parent = deps.tmpdir();
|
|
233
|
+
if (typeof parent !== "string" || parent.length === 0) {
|
|
234
|
+
result.diagnostics.push("worker config isolation sweep skipped: no temporary root");
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
result.diagnostics.push("worker config isolation sweep skipped: no temporary root");
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
let entries;
|
|
243
|
+
try {
|
|
244
|
+
entries = await deps.readdir(parent);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
result.diagnostics.push("worker config isolation sweep skipped: temporary root unreadable");
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
const cutoff = deps.now() - maxAgeMs;
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
// Prefix match FIRST: anything the module did not create is not ours to
|
|
253
|
+
// delete, whatever its age.
|
|
254
|
+
if (typeof entry !== "string" || !entry.startsWith(ISOLATION_DIR_PREFIX)) {
|
|
255
|
+
result.skipped += 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const candidate = deps.join(parent, entry);
|
|
259
|
+
let meta;
|
|
260
|
+
try {
|
|
261
|
+
meta = await deps.lstatPath(candidate);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
result.skipped += 1;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// A symlink is skipped, never followed — removing it recursively could
|
|
268
|
+
// delete whatever it points at.
|
|
269
|
+
if (!meta || meta.isSymbolicLink || !meta.isDirectory) {
|
|
270
|
+
result.skipped += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!(meta.mtimeMs < cutoff)) {
|
|
274
|
+
result.skipped += 1;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
await deps.rmRecursive(candidate);
|
|
279
|
+
result.removed += 1;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
result.skipped += 1;
|
|
283
|
+
result.diagnostics.push("worker config isolation sweep could not remove a stale directory");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|