@bridge_gpt/mcp-server 0.2.37 → 0.2.39
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 +193 -16
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/ocapi-shape.js +23 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +6 -2
- package/build/sfcc/reads-custom-object-def.js +33 -21
- package/build/sfcc/reads-site-preference.js +20 -7
- package/build/sfcc/reads-system-object.js +11 -5
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/write-result.js +16 -7
- package/build/sfcc/writes-custom-object-def.js +12 -4
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- package/build/executor/worker-config-isolation.js +0 -287
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only virtual clock + fake executor deps (BAPI-534).
|
|
3
|
+
*
|
|
4
|
+
* NOT a `*.test.ts` (so it is not auto-run) and imports nothing from the v1
|
|
5
|
+
* conductor graph — it only builds injectable fakes for the executor unit tests.
|
|
6
|
+
* The virtual clock fires timers in TIME order so concurrent `sleep`s and the
|
|
7
|
+
* process-timeout/no-op-duration timers interleave deterministically.
|
|
8
|
+
*/
|
|
9
|
+
import { resolveAgentSpec } from "../agent-registry.js";
|
|
10
|
+
import { createClaudeExecutorAdapter } from "../agent-launchers/claude-executor-adapter.js";
|
|
11
|
+
import { validateExecutorAdapterCapabilities } from "../agent-launchers/executor-adapter.js";
|
|
1
12
|
export class VirtualClock {
|
|
2
13
|
t = 0;
|
|
3
14
|
timers = [];
|
|
@@ -33,10 +44,38 @@ async function flushMicrotasks() {
|
|
|
33
44
|
// worker spawn, adding several microtask turns. Drain generously so a
|
|
34
45
|
// `tickUntil` flush still reaches the spawn/exit in one pass (draining an
|
|
35
46
|
// already-empty queue is a harmless no-op).
|
|
36
|
-
|
|
47
|
+
//
|
|
48
|
+
// BAPI-791: there is no pre-spawn credential resolution any more — worker
|
|
49
|
+
// authentication is no longer checked before spawning at all — but the
|
|
50
|
+
// budget is kept generously wide rather than narrowed back down. The failure
|
|
51
|
+
// a wide budget prevents is SILENT and misleading — a `tickUntil(() =>
|
|
52
|
+
// spawned)` that returns before the spawn lets a test register its own timer
|
|
53
|
+
// FIRST, so the process appears to exit before a timeout that was actually
|
|
54
|
+
// armed correctly, and a timeout test passes/fails for the wrong reason.
|
|
55
|
+
for (let i = 0; i < 512; i++)
|
|
37
56
|
await Promise.resolve();
|
|
38
57
|
}
|
|
39
58
|
/** A controllable fake owned process for `implement` spawn tests. */
|
|
59
|
+
/**
|
|
60
|
+
* The stream-json `system`/`init` line a healthy default worker emits (BAPI-790).
|
|
61
|
+
*
|
|
62
|
+
* Its server set matches the default `.mcp.json` fixture served by
|
|
63
|
+
* `makeFakeExecutorDeps`, so the per-spawn MCP surface assertion sees a MATCH and
|
|
64
|
+
* stays silent. Without it every unrelated spawn test would trip the advisory
|
|
65
|
+
* "no init event was observed" warning and start asserting against a log line it
|
|
66
|
+
* was never written to care about.
|
|
67
|
+
*
|
|
68
|
+
* A test that wants the absent/mismatch paths supplies its own stdout.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_WORKER_INIT_EVENT_LINE = `${JSON.stringify({
|
|
71
|
+
type: "system",
|
|
72
|
+
subtype: "init",
|
|
73
|
+
mcp_servers: [{ name: "bridge-api", status: "connected" }],
|
|
74
|
+
})}\n`;
|
|
75
|
+
/** A single-chunk stdout stream carrying {@link DEFAULT_WORKER_INIT_EVENT_LINE}. */
|
|
76
|
+
async function* defaultWorkerStdout() {
|
|
77
|
+
yield DEFAULT_WORKER_INIT_EVENT_LINE;
|
|
78
|
+
}
|
|
40
79
|
export function makeControllableProcess(stdout) {
|
|
41
80
|
let resolveWait;
|
|
42
81
|
const waitP = new Promise((r) => {
|
|
@@ -47,7 +86,7 @@ export function makeControllableProcess(stdout) {
|
|
|
47
86
|
return {
|
|
48
87
|
proc: {
|
|
49
88
|
pid: 4242,
|
|
50
|
-
stdout: stdout ??
|
|
89
|
+
stdout: stdout ?? defaultWorkerStdout(),
|
|
51
90
|
stderr: null,
|
|
52
91
|
wait: () => waitP,
|
|
53
92
|
kill: (s) => {
|
|
@@ -67,17 +106,35 @@ export function makeControllableProcess(stdout) {
|
|
|
67
106
|
}
|
|
68
107
|
/** Build a full fake `ExecutorDeps`, driven by a `VirtualClock`. */
|
|
69
108
|
export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
109
|
+
// BAPI-793: in-memory backing for the exclusive-create boundary the worktree
|
|
110
|
+
// lock uses. Declared here (not inside `base`) so the create, the read-back,
|
|
111
|
+
// and the removal all address ONE store — a lock that could be created but
|
|
112
|
+
// never read back would silently never release, and a second job in the same
|
|
113
|
+
// test would then see a phantom holder.
|
|
114
|
+
const exclusiveFiles = new Map();
|
|
70
115
|
const base = {
|
|
71
116
|
runCommand: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
|
72
117
|
spawnProcess: () => {
|
|
73
118
|
throw new Error("spawnProcess not configured for this test");
|
|
74
119
|
},
|
|
75
120
|
readFile: async (filePath) => {
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
121
|
+
// A file this deps object created exclusively (the BAPI-793 worktree lock)
|
|
122
|
+
// reads back verbatim, so ownership-checked release works against the fake
|
|
123
|
+
// filesystem exactly as it does against a real one.
|
|
124
|
+
const exclusive = exclusiveFiles.get(filePath);
|
|
125
|
+
if (exclusive !== undefined)
|
|
126
|
+
return exclusive;
|
|
127
|
+
// The prepared worktree's own `.mcp.json` must carry the required
|
|
128
|
+
// `bridge-api` registration or the runner refuses to spawn. Serve a
|
|
79
129
|
// minimal valid one so pre-existing spawn tests still reach the spawn they
|
|
80
130
|
// were written to assert on.
|
|
131
|
+
//
|
|
132
|
+
// BAPI-790 made this fixture carry MORE weight, deliberately: the same
|
|
133
|
+
// file is now both the pre-spawn requirement AND the source of the
|
|
134
|
+
// `--mcp-config` path and the expected server-name set the init-event
|
|
135
|
+
// assertion checks. A test that wants to model a MISSING or MALFORMED
|
|
136
|
+
// registration must therefore override this read explicitly — it will not
|
|
137
|
+
// get that state by accident, which is the point.
|
|
81
138
|
if (typeof filePath === "string" && filePath.endsWith(".mcp.json")) {
|
|
82
139
|
return JSON.stringify({ mcpServers: { "bridge-api": { command: "node", args: [] } } });
|
|
83
140
|
}
|
|
@@ -87,7 +144,30 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
87
144
|
},
|
|
88
145
|
writeFile: async () => { },
|
|
89
146
|
mkdir: async () => undefined,
|
|
90
|
-
|
|
147
|
+
// BAPI-793 worktree lock, backed by a per-deps in-memory namespace. Real
|
|
148
|
+
// exclusivity semantics (a second create for the same path rejects with
|
|
149
|
+
// EEXIST) so a fixture that genuinely contends behaves like the filesystem,
|
|
150
|
+
// but scoped to this deps object so the many existing single-job spawn tests
|
|
151
|
+
// simply acquire, run, and release without touching a real directory.
|
|
152
|
+
writeFileExclusive: async (filePath, data) => {
|
|
153
|
+
if (exclusiveFiles.has(filePath)) {
|
|
154
|
+
throw Object.assign(new Error("EEXIST"), { code: "EEXIST" });
|
|
155
|
+
}
|
|
156
|
+
exclusiveFiles.set(filePath, data);
|
|
157
|
+
},
|
|
158
|
+
removeFile: async (filePath) => {
|
|
159
|
+
exclusiveFiles.delete(filePath);
|
|
160
|
+
},
|
|
161
|
+
stat: async (filePath) => {
|
|
162
|
+
// A blanket "every path exists" stat would make an absent credential
|
|
163
|
+
// store look unreadable instead of simply missing, so report it as
|
|
164
|
+
// absent by default. Fixtures that want a specific store state inject
|
|
165
|
+
// this seam explicitly.
|
|
166
|
+
if (typeof filePath === "string" && filePath.endsWith("credentials.json")) {
|
|
167
|
+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
168
|
+
}
|
|
169
|
+
return { mode: 0o644 };
|
|
170
|
+
},
|
|
91
171
|
statfs: async () => ({ bavail: 1_000_000, bsize: 4096 }),
|
|
92
172
|
// BAPI-731 worker config isolation boundaries. In-memory fakes: no test ever
|
|
93
173
|
// creates, permissions, or removes a real directory. Isolation is
|
|
@@ -104,14 +184,12 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
104
184
|
now: clock.now,
|
|
105
185
|
setTimer: clock.setTimer,
|
|
106
186
|
clearTimer: clock.clearTimer,
|
|
107
|
-
//
|
|
108
|
-
// (
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// `ContractError.BridgeCredentialUnavailable` and no spawn test could run.
|
|
187
|
+
// BAPI-791: there is no pre-spawn Claude authentication check any more —
|
|
188
|
+
// a worker authenticates (or fails to) at the real spawn, classified
|
|
189
|
+
// after the fact from its own captured output — so no Anthropic
|
|
190
|
+
// credential belongs in this default environment at all. `BAPI_API_KEY`
|
|
191
|
+
// remains a Bridge-only test credential unrelated to worker auth.
|
|
113
192
|
env: {
|
|
114
|
-
ANTHROPIC_API_KEY: "test-key-not-a-real-credential",
|
|
115
193
|
BAPI_API_KEY: "test-bapi-key-not-a-real-credential",
|
|
116
194
|
},
|
|
117
195
|
cwd: "/repo",
|
|
@@ -127,19 +205,19 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
127
205
|
},
|
|
128
206
|
};
|
|
129
207
|
const merged = { ...base, ...overrides };
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
208
|
+
// An override supplied for an unrelated reason must not silently turn a
|
|
209
|
+
// spawn test into a refusal test, so ambient fixture state a fail-CLOSED
|
|
210
|
+
// gate depends on is re-applied here as a FALLBACK — an override that
|
|
211
|
+
// deliberately exercises a refusal still wins, because it either supplies
|
|
212
|
+
// its own value or explicitly omits one (empty string included: presence
|
|
213
|
+
// wins over the fallback).
|
|
214
|
+
// 1. `BAPI_API_KEY` is a Bridge test credential unrelated to worker
|
|
215
|
+
// authentication (BAPI-791 removed the pre-spawn Anthropic-credential
|
|
216
|
+
// check entirely). A test overriding `env` for some other purpose keeps
|
|
217
|
+
// it unless it sets the key itself.
|
|
140
218
|
if (overrides.env) {
|
|
141
219
|
const fallbacks = {};
|
|
142
|
-
for (const key of ["
|
|
220
|
+
for (const key of ["BAPI_API_KEY"]) {
|
|
143
221
|
if (!(key in overrides.env))
|
|
144
222
|
fallbacks[key] = base.env[key];
|
|
145
223
|
}
|
|
@@ -148,9 +226,18 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
148
226
|
// 2. The required-registration check reads the worktree's `.mcp.json`. A test
|
|
149
227
|
// overriding `readFile` (usually to script command provisioning) still gets
|
|
150
228
|
// the default registration for that one path when its own read rejects.
|
|
229
|
+
//
|
|
230
|
+
// BAPI-793: the same wrapper also serves back files this deps object created
|
|
231
|
+
// exclusively (the worktree lock), BEFORE consulting the override. A test
|
|
232
|
+
// that scripts `readFile` for command provisioning has no opinion about the
|
|
233
|
+
// lock file, and without this its override would hide the lock from its own
|
|
234
|
+
// ownership-checked release.
|
|
151
235
|
if (overrides.readFile) {
|
|
152
236
|
const override = overrides.readFile;
|
|
153
237
|
merged.readFile = async (filePath) => {
|
|
238
|
+
const exclusive = exclusiveFiles.get(filePath);
|
|
239
|
+
if (exclusive !== undefined)
|
|
240
|
+
return exclusive;
|
|
154
241
|
try {
|
|
155
242
|
return await override(filePath);
|
|
156
243
|
}
|
|
@@ -186,3 +273,80 @@ export function makeTestOptions(overrides = {}) {
|
|
|
186
273
|
...overrides,
|
|
187
274
|
};
|
|
188
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* BAPI-781: hermetic executor-adapter resolution for tests.
|
|
278
|
+
*
|
|
279
|
+
* Every spawn test now needs a resolved adapter, and building one inline in each
|
|
280
|
+
* file would spread the same eight lines across four suites. These helpers live
|
|
281
|
+
* here for the same reason the rest of this module does: they are injectable
|
|
282
|
+
* fakes, not production wiring, and they keep the real `agent-registry` →
|
|
283
|
+
* `createClaudeExecutorAdapter` path under test rather than substituting a
|
|
284
|
+
* hand-written stand-in for Claude.
|
|
285
|
+
*/
|
|
286
|
+
export function claudeAdapterSeam(claudeDeps = {}) {
|
|
287
|
+
const spec = resolveAgentSpec("claude");
|
|
288
|
+
return (agentId) => {
|
|
289
|
+
const adapter = createClaudeExecutorAdapter(spec, {
|
|
290
|
+
// Hermetic defaults: an in-memory read boundary and no-op provisioning, so
|
|
291
|
+
// a test that does not care about deny/advisory behavior still resolves a
|
|
292
|
+
// COMPLETE adapter rather than tripping the fail-closed capability gate.
|
|
293
|
+
readFile: async () => "{}",
|
|
294
|
+
denyProvisioningDeps: {
|
|
295
|
+
readFile: async () => "{}",
|
|
296
|
+
writeFile: async () => { },
|
|
297
|
+
mkdir: async () => { },
|
|
298
|
+
homedir: () => "/home/test",
|
|
299
|
+
},
|
|
300
|
+
...claudeDeps,
|
|
301
|
+
});
|
|
302
|
+
const validation = validateExecutorAdapterCapabilities(adapter);
|
|
303
|
+
if (!validation.ok) {
|
|
304
|
+
return {
|
|
305
|
+
supported: false,
|
|
306
|
+
kind: "incomplete-capabilities",
|
|
307
|
+
message: `test Claude adapter incomplete for '${agentId}'`,
|
|
308
|
+
refusals: validation.refusals,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return { supported: true, adapter, spec, validation };
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* A resolver that refuses, for the fail-closed cases. Deliberately returns a
|
|
316
|
+
* REFUSAL rather than throwing: the runner is required to convert a refusal into
|
|
317
|
+
* a named contract failure, and a thrown resolver would test the generic
|
|
318
|
+
* exception path instead of the one that matters.
|
|
319
|
+
*/
|
|
320
|
+
export function refusingAdapterSeam(kind, message = "no usable executor adapter for this agent") {
|
|
321
|
+
return () => ({ supported: false, kind, message });
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Build a synthetic adapter from a complete Claude adapter with individual
|
|
325
|
+
* capabilities overridden or removed. Used to prove the runner refuses a shape
|
|
326
|
+
* inconsistent with the declaration, without inventing a whole second agent.
|
|
327
|
+
*/
|
|
328
|
+
export function syntheticAdapterSeam(mutate) {
|
|
329
|
+
const spec = resolveAgentSpec("claude");
|
|
330
|
+
return (agentId) => {
|
|
331
|
+
const base = createClaudeExecutorAdapter(spec, {
|
|
332
|
+
readFile: async () => "{}",
|
|
333
|
+
denyProvisioningDeps: {
|
|
334
|
+
readFile: async () => "{}",
|
|
335
|
+
writeFile: async () => { },
|
|
336
|
+
mkdir: async () => { },
|
|
337
|
+
homedir: () => "/home/test",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
const adapter = mutate(base);
|
|
341
|
+
const validation = validateExecutorAdapterCapabilities(adapter);
|
|
342
|
+
if (!validation.ok) {
|
|
343
|
+
return {
|
|
344
|
+
supported: false,
|
|
345
|
+
kind: "incomplete-capabilities",
|
|
346
|
+
message: `synthetic adapter incomplete for '${agentId}'`,
|
|
347
|
+
refusals: validation.refusals,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return { supported: true, adapter, spec, validation };
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -1,38 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Executor worker JOB POLICY: prompt resolution and permission posture
|
|
3
|
+
* (BAPI-534, TDD §7; BAPI-790; narrowed in BAPI-781).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* WHAT THIS MODULE IS NO LONGER. Until BAPI-781 it also built Claude's argv,
|
|
6
|
+
* resolved Claude's model alias, and named Claude's executable. All three moved
|
|
7
|
+
* into the Claude executor adapter
|
|
8
|
+
* (`agent-launchers/claude-executor-adapter.ts`), because each was a statement
|
|
9
|
+
* about one particular CLI's process shape. What remains here is genuinely
|
|
10
|
+
* agent-neutral: which prompt a job type gets, and which permission posture it
|
|
11
|
+
* runs under. Both are decisions about the JOB, and they read identically
|
|
12
|
+
* whether the worker is Claude, Codex, or cursor-agent.
|
|
13
|
+
*
|
|
14
|
+
* The permission posture stays here for a specific reason worth preserving: it
|
|
15
|
+
* is expressed in the SERVER's snake_case policy vocabulary, not in any CLI's
|
|
16
|
+
* flag spelling. The adapter translates it. Keeping the policy vocabulary
|
|
17
|
+
* distinct from the argv vocabulary is what stops a future flag rename in one
|
|
18
|
+
* agent's CLI from becoming an operator-visible policy break across all of them.
|
|
7
19
|
*/
|
|
8
|
-
import { resolveAgentSpec, resolveModelAlias, isModelTier, isValidModelAlias, } from "../agent-registry.js";
|
|
9
20
|
import { ExecutorNamedError } from "./job-errors.js";
|
|
10
21
|
import { isRecoveryJobType } from "./job-types.js";
|
|
11
|
-
/**
|
|
12
|
-
* Resolve the Claude model alias from a job payload. Accepts a direct
|
|
13
|
-
* `model_alias` only when it passes registry alias validation AND the static
|
|
14
|
-
* Claude allowlist; otherwise resolves from a valid `model_tier` through the
|
|
15
|
-
* registry. Returns `null` when no safe alias is available (so `--model` is
|
|
16
|
-
* omitted) — an unsafe/invalid string is never returned.
|
|
17
|
-
*/
|
|
18
|
-
export function resolveExecutorModelAlias(payload) {
|
|
19
|
-
const agent = resolveAgentSpec("claude");
|
|
20
|
-
if (!agent)
|
|
21
|
-
return null;
|
|
22
|
-
const p = payload && typeof payload === "object" ? payload : {};
|
|
23
|
-
const directRaw = p.model_alias;
|
|
24
|
-
if (typeof directRaw === "string" && directRaw.trim().length > 0) {
|
|
25
|
-
const direct = directRaw.trim();
|
|
26
|
-
const allowed = !agent.staticModelAliasAllowlist || agent.staticModelAliasAllowlist.includes(direct);
|
|
27
|
-
if (isValidModelAlias(direct) && allowed) {
|
|
28
|
-
return direct;
|
|
29
|
-
}
|
|
30
|
-
// An invalid/disallowed direct alias falls through to the tier path — never
|
|
31
|
-
// returned as-is.
|
|
32
|
-
}
|
|
33
|
-
const tier = isModelTier(p.model_tier) ? p.model_tier : null;
|
|
34
|
-
return resolveModelAlias(agent, tier, null);
|
|
35
|
-
}
|
|
36
22
|
/**
|
|
37
23
|
* Resolve the worker prompt for the explicit-prompt and legacy synthesis paths.
|
|
38
24
|
* Uses an explicit non-empty `payload.prompt` verbatim; otherwise, for
|
|
@@ -137,31 +123,9 @@ export function resolveWorkerPermissionPosture(payload) {
|
|
|
137
123
|
throw new ExecutorNamedError("ContractError.PermissionPosture", "job payload worker_permission_posture is present but is not a supported posture " +
|
|
138
124
|
"(expected 'skip_permissions' or 'accept_edits').");
|
|
139
125
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
152
|
-
*/
|
|
153
|
-
export function buildClaudeExecutorArgv(prompt, alias, posture = DEFAULT_WORKER_PERMISSION_POSTURE) {
|
|
154
|
-
const argv = ["-p", prompt, "--output-format", "stream-json", "--verbose"];
|
|
155
|
-
if (alias) {
|
|
156
|
-
argv.push("--model", alias);
|
|
157
|
-
}
|
|
158
|
-
if (posture === "accept_edits") {
|
|
159
|
-
argv.push("--permission-mode", "acceptEdits");
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
argv.push("--dangerously-skip-permissions");
|
|
163
|
-
}
|
|
164
|
-
return argv;
|
|
165
|
-
}
|
|
166
|
-
/** The worker executable is always exactly `claude`. */
|
|
167
|
-
export const CLAUDE_EXECUTABLE = "claude";
|
|
126
|
+
// Argv construction, model-alias resolution, and the worker executable name all
|
|
127
|
+
// live in the resolved executor adapter now (BAPI-781). `job-runner.ts` requests
|
|
128
|
+
// one complete `{ executable, argv, env }` spawn shape from the adapter rather
|
|
129
|
+
// than assembling a Claude-shaped command here. Re-adding a builder to this
|
|
130
|
+
// module would recreate the exact coupling the extraction removed: a generic
|
|
131
|
+
// job-policy module that only works for one CLI.
|
|
@@ -76,6 +76,88 @@ export async function* teeAsyncIterable(source, tee, stream = "stdout") {
|
|
|
76
76
|
yield chunk;
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Total bound on how much of a worker log the session-isolation invariant will
|
|
81
|
+
* scan. A worker log is unbounded (it is the raw stream), and the invariant is a
|
|
82
|
+
* cheap safety check rather than an analysis pass, so a multi-hundred-megabyte
|
|
83
|
+
* parse at job completion is not acceptable.
|
|
84
|
+
*
|
|
85
|
+
* The budget is split between the START and the END of the log (see
|
|
86
|
+
* {@link distinctWorkerLogSessionIds}); it is not a prefix.
|
|
87
|
+
*/
|
|
88
|
+
export const MAX_SESSION_SCAN_CHARS = 2_000_000;
|
|
89
|
+
/**
|
|
90
|
+
* The DISTINCT `session_id` values present in a worker log (BAPI-793).
|
|
91
|
+
*
|
|
92
|
+
* The incident signature this exists to catch is two Claude sessions writing
|
|
93
|
+
* into one worktree's single `.conductor/worker.log`. Every stream-json record a
|
|
94
|
+
* worker emits carries its `session_id`, so one healthy job produces MANY
|
|
95
|
+
* records and exactly ONE distinct id — which is why this counts DISTINCT ids
|
|
96
|
+
* rather than occurrences. A count-based check would flag every normal job.
|
|
97
|
+
*
|
|
98
|
+
* Parsing is per line and failure-tolerant: the log interleaves stdout and
|
|
99
|
+
* stderr and may be truncated mid-line at either end, so an unparseable line is
|
|
100
|
+
* skipped rather than treated as evidence of anything.
|
|
101
|
+
*
|
|
102
|
+
* When the log exceeds {@link MAX_SESSION_SCAN_CHARS} the budget is split
|
|
103
|
+
* between its HEAD and its TAIL rather than spent entirely on a prefix. A
|
|
104
|
+
* head-only scan goes blind exactly where the risk is highest: the second worker
|
|
105
|
+
* is the one that arrives LATE, so its records land at the end of a log whose
|
|
106
|
+
* size is itself the product of two workers writing into it. Scanning both ends
|
|
107
|
+
* costs the same bounded work and catches the intruder wherever it entered.
|
|
108
|
+
*
|
|
109
|
+
* The middle is deliberately unscanned. Detecting a second session that both
|
|
110
|
+
* starts and ends inside the gap would require an unbounded parse, and this is a
|
|
111
|
+
* diagnostic, not the enforcement — the worktree lock is.
|
|
112
|
+
*/
|
|
113
|
+
export function distinctWorkerLogSessionIds(logText) {
|
|
114
|
+
if (typeof logText !== "string" || logText.length === 0)
|
|
115
|
+
return [];
|
|
116
|
+
let scanned;
|
|
117
|
+
if (logText.length <= MAX_SESSION_SCAN_CHARS) {
|
|
118
|
+
scanned = logText;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const window = Math.floor(MAX_SESSION_SCAN_CHARS / 2);
|
|
122
|
+
// Joined with a newline so the head's trailing partial line and the tail's
|
|
123
|
+
// leading partial line stay SEPARATE records. Concatenating them directly
|
|
124
|
+
// would splice two unrelated fragments into one syntactically-plausible
|
|
125
|
+
// line, which the parser could then read as a record that never existed.
|
|
126
|
+
scanned = `${logText.slice(0, window)}\n${logText.slice(logText.length - window)}`;
|
|
127
|
+
}
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
for (const line of scanned.split(/\r?\n/)) {
|
|
130
|
+
if (line.length === 0 || !line.includes("session_id"))
|
|
131
|
+
continue;
|
|
132
|
+
let value;
|
|
133
|
+
try {
|
|
134
|
+
value = JSON.parse(line);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
140
|
+
continue;
|
|
141
|
+
const sessionId = value.session_id;
|
|
142
|
+
if (typeof sessionId === "string" && sessionId.length > 0)
|
|
143
|
+
seen.add(sessionId);
|
|
144
|
+
}
|
|
145
|
+
return [...seen].sort();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The fixed diagnostic emitted when a completed job's worker log carries more
|
|
149
|
+
* than one distinct session (BAPI-793 acceptance criterion 3).
|
|
150
|
+
*
|
|
151
|
+
* Reports the COUNT, never the ids: a session id identifies a real agent
|
|
152
|
+
* conversation, and this string goes to the executor's stderr where it may be
|
|
153
|
+
* shipped to a log aggregator. The count alone is the actionable signal.
|
|
154
|
+
*/
|
|
155
|
+
export function formatWorkerLogSessionInvariantWarning(sessionCount) {
|
|
156
|
+
return (`worker log session invariant violated: ${sessionCount} distinct Claude sessions wrote to this ` +
|
|
157
|
+
"job's .conductor/worker.log, so more than one worker was active in this worktree. The " +
|
|
158
|
+
"worktree lock should have refused the second one — treat this as evidence it was bypassed " +
|
|
159
|
+
"(a pre-existing stale lock, a worktree entered outside the executor, or a manual claude run).");
|
|
160
|
+
}
|
|
79
161
|
/**
|
|
80
162
|
* Safely finalize a tee. Tolerates a `null`/`undefined` tee and repeated calls;
|
|
81
163
|
* NEVER kills or signals the worker process.
|