@bridge_gpt/mcp-server 0.2.38 → 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 +189 -14
- 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/permissions.js +25 -6
- package/build/sfcc/reads-site-preference.js +6 -0
- 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/writes-custom-object-def.js +6 -2
- 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
|
@@ -192,6 +192,171 @@ export function parseClaudeStreamJsonLine(line) {
|
|
|
192
192
|
function pickString(value) {
|
|
193
193
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
194
194
|
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// MCP surface assertion (BAPI-790)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
/**
|
|
199
|
+
* Normalize a list of MCP server names to the canonical comparison form: trimmed,
|
|
200
|
+
* non-empty, deduplicated, and sorted.
|
|
201
|
+
*
|
|
202
|
+
* Shared by BOTH sides of the assertion — the expected set read from the
|
|
203
|
+
* worktree's `.mcp.json` and the observed set read from the worker's init event —
|
|
204
|
+
* so ordering and duplicate registrations can never produce a false mismatch. Two
|
|
205
|
+
* separate normalizations would be exactly the kind of drift that turns a safety
|
|
206
|
+
* assertion into a flake.
|
|
207
|
+
*/
|
|
208
|
+
export function normalizeMcpServerNames(names) {
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
for (const value of names) {
|
|
211
|
+
if (typeof value !== "string")
|
|
212
|
+
continue;
|
|
213
|
+
const trimmed = value.trim();
|
|
214
|
+
if (trimmed.length === 0)
|
|
215
|
+
continue;
|
|
216
|
+
seen.add(trimmed);
|
|
217
|
+
}
|
|
218
|
+
return [...seen].sort();
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Cap on the partial line the surface observer will buffer, in characters.
|
|
222
|
+
*
|
|
223
|
+
* DELIBERATELY MUCH LARGER than the 8 KB stdout excerpt in `process.ts`, and for
|
|
224
|
+
* the opposite reason. That excerpt is a diagnostic that gets POSTED to the
|
|
225
|
+
* server, so it is kept small on purpose. This buffer has to hold one complete
|
|
226
|
+
* `system`/`init` line intact or the assertion cannot read it at all — a
|
|
227
|
+
* registration with many servers and long names is legitimately several KB — so
|
|
228
|
+
* the bound exists only to stop unbounded growth, not to trim content. A real
|
|
229
|
+
* init event is orders of magnitude under this.
|
|
230
|
+
*
|
|
231
|
+
* The cap is on ONE un-terminated line, not on total throughput: every newline
|
|
232
|
+
* clears the buffer, so a normal newline-delimited stream never approaches it.
|
|
233
|
+
*/
|
|
234
|
+
export const MAX_BUFFERED_SURFACE_LINE_CHARS = 1_048_576;
|
|
235
|
+
function sameNameSet(a, b) {
|
|
236
|
+
return a.length === b.length && a.every((name, index) => name === b[index]);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Create an observer that asserts the worker's loaded MCP surface against
|
|
240
|
+
* `expectedServerNames` (which must come from the same `.mcp.json` passed to the
|
|
241
|
+
* worker as `--mcp-config`).
|
|
242
|
+
*
|
|
243
|
+
* Line handling is buffered because stdout arrives in arbitrary chunks: a JSON
|
|
244
|
+
* event can be split across two reads, and parsing a half-line would classify a
|
|
245
|
+
* perfectly good init event as malformed. Malformed lines are simply skipped —
|
|
246
|
+
* the advisory parser's own posture — because the assertion's failure mode must
|
|
247
|
+
* be "no valid init event was seen" (a warning), never "unparseable text implied
|
|
248
|
+
* a surface" (a fabrication).
|
|
249
|
+
*/
|
|
250
|
+
export function createMcpSurfaceObserver(expectedServerNames, parseInitEvent) {
|
|
251
|
+
const expected = normalizeMcpServerNames(expectedServerNames);
|
|
252
|
+
let settled = null;
|
|
253
|
+
let buffer = "";
|
|
254
|
+
/** True while skipping the remainder of a line that blew the size cap. */
|
|
255
|
+
let discardingOversizedLine = false;
|
|
256
|
+
/** Sticky: at least one line was dropped for exceeding the cap. */
|
|
257
|
+
let droppedOversizedLine = false;
|
|
258
|
+
const consumeLine = (line) => {
|
|
259
|
+
if (settled !== null)
|
|
260
|
+
return;
|
|
261
|
+
const trimmed = line.trim();
|
|
262
|
+
if (trimmed.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
// The ADAPTER reads its own runtime's event shape; the executor reads none.
|
|
265
|
+
// A parser that throws is treated exactly like a line that said nothing —
|
|
266
|
+
// an adapter defect must not be able to settle the verdict, in either
|
|
267
|
+
// direction.
|
|
268
|
+
let result;
|
|
269
|
+
try {
|
|
270
|
+
result = parseInitEvent(trimmed);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!result || result.kind === "not-init")
|
|
276
|
+
return;
|
|
277
|
+
if (result.kind === "malformed-init") {
|
|
278
|
+
// A valid init event we cannot read is unverifiable, and unverifiable
|
|
279
|
+
// containment is not containment.
|
|
280
|
+
settled = { kind: "mismatch", expected, observed: [] };
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Normalize the ADAPTER-SUPPLIED names through the same function that
|
|
284
|
+
// normalized the expected set. Comparing two differently-normalized lists is
|
|
285
|
+
// exactly the drift that turns a safety assertion into a flake.
|
|
286
|
+
const observed = normalizeMcpServerNames(result.serverNames);
|
|
287
|
+
settled = sameNameSet(expected, observed)
|
|
288
|
+
? { kind: "match", observed }
|
|
289
|
+
: { kind: "mismatch", expected, observed };
|
|
290
|
+
};
|
|
291
|
+
return {
|
|
292
|
+
observeStdout(chunk) {
|
|
293
|
+
if (settled !== null)
|
|
294
|
+
return;
|
|
295
|
+
if (typeof chunk !== "string" || chunk.length === 0)
|
|
296
|
+
return;
|
|
297
|
+
let input = chunk;
|
|
298
|
+
// Still skipping past a line that already blew the cap. Everything up to
|
|
299
|
+
// the next newline belongs to that dead line — RESYNCING on the newline
|
|
300
|
+
// rather than just clearing the buffer is what stops the line's tail from
|
|
301
|
+
// being parsed as if it were a fresh event.
|
|
302
|
+
if (discardingOversizedLine) {
|
|
303
|
+
const resyncAt = input.indexOf("\n");
|
|
304
|
+
if (resyncAt === -1)
|
|
305
|
+
return;
|
|
306
|
+
discardingOversizedLine = false;
|
|
307
|
+
input = input.slice(resyncAt + 1);
|
|
308
|
+
}
|
|
309
|
+
buffer += input;
|
|
310
|
+
let newlineAt = buffer.indexOf("\n");
|
|
311
|
+
while (newlineAt !== -1) {
|
|
312
|
+
consumeLine(buffer.slice(0, newlineAt));
|
|
313
|
+
buffer = buffer.slice(newlineAt + 1);
|
|
314
|
+
if (settled !== null) {
|
|
315
|
+
buffer = "";
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
newlineAt = buffer.indexOf("\n");
|
|
319
|
+
}
|
|
320
|
+
// A residual partial line past the cap is not a line we could ever parse.
|
|
321
|
+
// Drop it and keep scanning the stream: a LATER valid init event still
|
|
322
|
+
// settles the verdict normally, so one pathological line cannot disable
|
|
323
|
+
// the assertion for the rest of the run.
|
|
324
|
+
if (buffer.length > MAX_BUFFERED_SURFACE_LINE_CHARS) {
|
|
325
|
+
buffer = "";
|
|
326
|
+
discardingOversizedLine = true;
|
|
327
|
+
droppedOversizedLine = true;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
shouldTerminate() {
|
|
331
|
+
return settled !== null && settled.kind === "mismatch";
|
|
332
|
+
},
|
|
333
|
+
verdict() {
|
|
334
|
+
// Try the residual partial line. A stream that ends without a trailing
|
|
335
|
+
// newline would otherwise strand its final event in the buffer and report
|
|
336
|
+
// `absent` for a surface we actually observed.
|
|
337
|
+
//
|
|
338
|
+
// The buffer is deliberately NOT cleared here. `verdict()` may legitimately
|
|
339
|
+
// be called mid-stream, and consuming the partial line destructively would
|
|
340
|
+
// drop the first half of an event that was merely split across two stdout
|
|
341
|
+
// chunks — turning a real init event into `absent`. Re-parsing the same
|
|
342
|
+
// residual on a later call is harmless: `consumeLine` only settles on a
|
|
343
|
+
// valid init event, and a settled verdict is never revisited.
|
|
344
|
+
if (settled === null && buffer.length > 0) {
|
|
345
|
+
consumeLine(buffer);
|
|
346
|
+
}
|
|
347
|
+
if (settled !== null)
|
|
348
|
+
return settled;
|
|
349
|
+
// An oversized drop is reported as its own reason rather than folded into
|
|
350
|
+
// the ordinary no-init case. Both warn and neither fails the job, but they
|
|
351
|
+
// are different facts: one says the worker never told us, the other says we
|
|
352
|
+
// could not keep what it told us.
|
|
353
|
+
return {
|
|
354
|
+
kind: "absent",
|
|
355
|
+
reason: droppedOversizedLine ? "oversized-line" : "no-init-event",
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
195
360
|
/**
|
|
196
361
|
* Create the observation state. When `advisoryParserEnabled` is false, stdout is
|
|
197
362
|
* still timestamped (`last_stdout_at`) but never parsed — proving the advisory
|
|
@@ -15,13 +15,21 @@
|
|
|
15
15
|
* through the separately launched `mcp-invoke` shim process, which resolves the
|
|
16
16
|
* credential itself — the model process never reads it directly.
|
|
17
17
|
*
|
|
18
|
-
* BAPI-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
* BAPI-790: the two isolation-directory credential rules that BAPI-740/F2 added
|
|
19
|
+
* are gone, because the per-job isolation directory they guarded no longer
|
|
20
|
+
* exists. `~/.config/bridge/**` is once again the single Bridge-credential deny,
|
|
21
|
+
* and it is sufficient: the shim resolves the operator's real store in its own
|
|
22
|
+
* process, and the model cannot read it.
|
|
23
|
+
*
|
|
24
|
+
* Architecture Miss 23 (2026-08-14) is worth keeping in view even though its
|
|
25
|
+
* specific rules are retired, because the lesson generalizes: a deny glob must
|
|
26
|
+
* target the ASSET, never a directory that also holds the worker's own working
|
|
27
|
+
* state. The isolation-directory deny was originally written as a
|
|
28
|
+
* whole-directory glob, and because `CLAUDE_CONFIG_DIR` pointed at that same
|
|
29
|
+
* directory, it silently truncated every oversized tool result the CLI persisted
|
|
30
|
+
* there for read-back (a worker lost 96.5% of its `get_pipeline_recipe`). No
|
|
31
|
+
* permission posture bypasses a deny rule, so the damage was invisible. Any
|
|
32
|
+
* future rule added here inherits that constraint.
|
|
25
33
|
*
|
|
26
34
|
* The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
|
|
27
35
|
* (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
|
|
@@ -29,16 +37,21 @@
|
|
|
29
37
|
*/
|
|
30
38
|
import path from "node:path";
|
|
31
39
|
import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
|
|
32
|
-
import { ISOLATION_DIR_PREFIX } from "./worker-config-isolation.js";
|
|
33
40
|
/**
|
|
34
41
|
* The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
|
|
35
42
|
* `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
|
|
36
43
|
* ~/.ssh, ~/.aws, and keychain paths; the user-scoped Bridge credential store
|
|
37
44
|
* (BAPI-724 — the model is denied direct reads, while the separately launched
|
|
38
|
-
* `mcp-invoke` shim process still resolves it); the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
45
|
+
* `mcp-invoke` shim process still resolves it); force-push to the base branch;
|
|
46
|
+
* and destructive `rm -rf` outside the worktree.
|
|
47
|
+
*
|
|
48
|
+
* BAPI-790 removed the two per-job isolation-directory credential rules. They
|
|
49
|
+
* guarded a repo-scoped `credentials.json` that the executor seeded into a
|
|
50
|
+
* per-job Claude configuration directory; no such directory or seeded file
|
|
51
|
+
* exists any more, so the rules matched nothing. `Read(~/.config/bridge/**)`
|
|
52
|
+
* carries the whole Bridge-credential guarantee again, exactly as it did before
|
|
53
|
+
* BAPI-731: the shim resolves the operator's real store in its own process, and
|
|
54
|
+
* the model cannot read it.
|
|
42
55
|
*/
|
|
43
56
|
export function executorDenyRules(inputs) {
|
|
44
57
|
return [
|
|
@@ -47,10 +60,6 @@ export function executorDenyRules(inputs) {
|
|
|
47
60
|
"Read(~/Library/Keychains/**)",
|
|
48
61
|
"Read(/etc/shadow)",
|
|
49
62
|
"Read(~/.config/bridge/**)",
|
|
50
|
-
// Any path under any per-job isolation directory, wherever the executor's
|
|
51
|
-
// tmp root lives (`//` anchors at the filesystem root; the prefix constant
|
|
52
|
-
// is shared with `worker-config-isolation.ts` so they cannot drift).
|
|
53
|
-
`Read(//**/${ISOLATION_DIR_PREFIX}*/**)`,
|
|
54
63
|
`Bash(git push:*--force*${inputs.baseBranch}*)`,
|
|
55
64
|
"Bash(git push:*--force*)",
|
|
56
65
|
"Bash(rm:*-rf /*)",
|
|
@@ -84,8 +93,45 @@ export function mergeExecutorDenySettings(existing, inputs) {
|
|
|
84
93
|
*/
|
|
85
94
|
export const EXECUTOR_SETTINGS_EXCLUDE_LINE = ".claude/settings.local.json";
|
|
86
95
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
96
|
+
* The executor-owned conductor artifact directory (BAPI-814/D2).
|
|
97
|
+
*
|
|
98
|
+
* Excluded through the SAME per-worktree mechanism rather than through a
|
|
99
|
+
* `.gitignore` edit, because that mechanism is the only one that travels. This
|
|
100
|
+
* repository's committed `.gitignore` already carries `.conductor/`, but the
|
|
101
|
+
* executor also drives foreign repositories that never will, and branches cut
|
|
102
|
+
* before that entry landed do not have it either. A `.git/info/exclude` line is
|
|
103
|
+
* invisible to the diff, belongs to the checkout rather than to the tree, and
|
|
104
|
+
* therefore protects all three cases without the executor ever editing a
|
|
105
|
+
* repository's tracked ignore file.
|
|
106
|
+
*/
|
|
107
|
+
export const EXECUTOR_CONDUCTOR_EXCLUDE_LINE = ".conductor/";
|
|
108
|
+
/**
|
|
109
|
+
* Every line the executor provisions into a worktree's `.git/info/exclude`, in
|
|
110
|
+
* the order they are appended. Named once so the writer and its tests cannot
|
|
111
|
+
* disagree about the set.
|
|
112
|
+
*/
|
|
113
|
+
export const EXECUTOR_EXCLUDE_LINES = [
|
|
114
|
+
EXECUTOR_SETTINGS_EXCLUDE_LINE,
|
|
115
|
+
EXECUTOR_CONDUCTOR_EXCLUDE_LINE,
|
|
116
|
+
];
|
|
117
|
+
/**
|
|
118
|
+
* Build the one bounded shape every exclude-provisioning warning takes: a
|
|
119
|
+
* failure CATEGORY plus the affected worktree path, and nothing else.
|
|
120
|
+
*
|
|
121
|
+
* `category` is always a fixed string chosen by this module — never an error
|
|
122
|
+
* message, a `.git` line, a filesystem path from an exception, or anything a
|
|
123
|
+
* worker or an operator's environment could influence. Callers log this text
|
|
124
|
+
* verbatim, so the bound is enforced here rather than trusted at each site.
|
|
125
|
+
*/
|
|
126
|
+
function excludeWarning(worktreePath, category) {
|
|
127
|
+
return (`worktree git-exclude provisioning did not complete for ${worktreePath}: ${category}. ` +
|
|
128
|
+
`${EXECUTOR_EXCLUDE_LINES.join(" and ")} are not protected from worker commits on this ` +
|
|
129
|
+
"worktree; inspect it before trusting the worktree's staged changes; continuing fail-open.");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Keep the executor-owned `.claude/settings.local.json` and the conductor's
|
|
133
|
+
* `.conductor/` artifacts out of worker commits (BAPI-740/F2, BAPI-814/D2).
|
|
134
|
+
* Worker-config isolation redirects `XDG_CONFIG_HOME`, so the
|
|
89
135
|
* operator's global git excludes (`~/.config/git/ignore`) no longer apply inside
|
|
90
136
|
* a worker — empirically, a worker `git add -A` commits the deny-layer file this
|
|
91
137
|
* module just wrote. A per-worktree `.git/info/exclude` entry is invisible to
|
|
@@ -95,9 +141,29 @@ export const EXECUTOR_SETTINGS_EXCLUDE_LINE = ".claude/settings.local.json";
|
|
|
95
141
|
* A linked worktree's `.git` is a FILE containing `gitdir: <path>` pointing at
|
|
96
142
|
* the main repository's per-worktree gitdir; that indirection is resolved rather
|
|
97
143
|
* than assuming a directory (`EISDIR` on the read identifies a real main-checkout
|
|
98
|
-
* `.git` directory).
|
|
99
|
-
*
|
|
100
|
-
*
|
|
144
|
+
* `.git` directory). The per-worktree gitdir is then resolved onward to the
|
|
145
|
+
* COMMON dir, because that — not the per-worktree dir — is where git reads
|
|
146
|
+
* `info/exclude` from; see the comment at the resolution site. Idempotent: the
|
|
147
|
+
* line is appended once, never duplicated.
|
|
148
|
+
*
|
|
149
|
+
* Fail-open at every step, matching the rest of this module — but NOT silent
|
|
150
|
+
* (BAPI-815/R2). Two outcomes that used to be the same `{ ok: true }` are now
|
|
151
|
+
* told apart:
|
|
152
|
+
*
|
|
153
|
+
* - **No checkout at all** (`ENOENT` on `.git`) — a benign no-op. There is
|
|
154
|
+
* nothing to protect, so there is nothing to warn about.
|
|
155
|
+
* - **Present but unresolvable** (`.git` exists but carries no `gitdir:`
|
|
156
|
+
* pointer, or cannot be read) — non-blocking, but WARNED. Git metadata is
|
|
157
|
+
* there and the executor could not use it, which means the settings file is
|
|
158
|
+
* unprotected on this worktree and a human should look.
|
|
159
|
+
*
|
|
160
|
+
* That distinction is the whole fix: a total failure to ever write was invisible
|
|
161
|
+
* precisely because "could not resolve" and "nothing to do" returned the same
|
|
162
|
+
* silent success.
|
|
163
|
+
*
|
|
164
|
+
* Warnings are bounded to a failure category plus the affected worktree path.
|
|
165
|
+
* They never carry `.git` contents, exception text, environment values, or
|
|
166
|
+
* settings-file content.
|
|
101
167
|
*/
|
|
102
168
|
async function provisionWorktreeSettingsExclude(worktreePath, deps) {
|
|
103
169
|
const gitPath = path.join(worktreePath, ".git");
|
|
@@ -107,8 +173,12 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
|
|
|
107
173
|
const match = /^gitdir:\s*(.+?)\s*$/m.exec(raw);
|
|
108
174
|
if (!match) {
|
|
109
175
|
// A readable `.git` file with no gitdir pointer is not git metadata this
|
|
110
|
-
// function understands; do not guess at a location to write into.
|
|
111
|
-
|
|
176
|
+
// function understands; do not guess at a location to write into. Present
|
|
177
|
+
// but unusable — degrade loudly rather than reporting a clean success.
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
warning: excludeWarning(worktreePath, "git metadata carries no gitdir pointer"),
|
|
181
|
+
};
|
|
112
182
|
}
|
|
113
183
|
const target = match[1];
|
|
114
184
|
gitDir = path.isAbsolute(target) ? target : path.resolve(worktreePath, target);
|
|
@@ -119,13 +189,48 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
|
|
|
119
189
|
// Main-checkout shape: `.git` is a real directory.
|
|
120
190
|
gitDir = gitPath;
|
|
121
191
|
}
|
|
122
|
-
else {
|
|
123
|
-
// No
|
|
192
|
+
else if (code === "ENOENT") {
|
|
193
|
+
// No git checkout here at all — nothing to protect, nothing to report.
|
|
124
194
|
return { ok: true };
|
|
125
195
|
}
|
|
196
|
+
else {
|
|
197
|
+
// Git metadata is present but could not be read (EACCES, EIO, …). The
|
|
198
|
+
// error CODE is a bounded category; the exception itself never escapes.
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
warning: excludeWarning(worktreePath, "git metadata could not be read"),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
126
204
|
}
|
|
205
|
+
// Resolve the COMMON directory before choosing where to write (BAPI-815/R2).
|
|
206
|
+
//
|
|
207
|
+
// THIS IS THE DEFECT THAT MADE THE WHOLE MECHANISM A NO-OP. `info/` is one of
|
|
208
|
+
// git's COMMON-dir files, not a per-worktree one: git reads exclusions from
|
|
209
|
+
// `<common>/info/exclude`, and for a linked worktree the common dir is the
|
|
210
|
+
// MAIN repository's `.git`, not `<main>/.git/worktrees/<name>`. Writing to
|
|
211
|
+
// the per-worktree gitdir put the rule somewhere git never looks, so
|
|
212
|
+
// `.claude/settings.local.json` stayed stageable in every linked worktree
|
|
213
|
+
// regardless of this function succeeding. Trees looked clean only because the
|
|
214
|
+
// operator's `~/.config/git/ignore` carried the same rule — the protection
|
|
215
|
+
// built to be machine-independent was supplied entirely by machine-specific
|
|
216
|
+
// config, and the day that global ignore is absent a worker commits the file.
|
|
217
|
+
//
|
|
218
|
+
// `<gitdir>/commondir` holds the pointer (usually the relative `../..`), and
|
|
219
|
+
// it is a plain file, so this needs no new command-runner dependency. Its
|
|
220
|
+
// absence means this IS the common dir (a main checkout), which is why the
|
|
221
|
+
// fallback is `gitDir` itself rather than an error.
|
|
222
|
+
let commonDir = gitDir;
|
|
127
223
|
try {
|
|
128
|
-
const
|
|
224
|
+
const pointer = (await deps.readFile(path.join(gitDir, "commondir"))).trim();
|
|
225
|
+
if (pointer.length > 0) {
|
|
226
|
+
commonDir = path.isAbsolute(pointer) ? pointer : path.resolve(gitDir, pointer);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
/* no commondir pointer: `gitDir` is already the common dir */
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const infoDir = path.join(commonDir, "info");
|
|
129
234
|
await deps.mkdir(infoDir, { recursive: true });
|
|
130
235
|
const excludePath = path.join(infoDir, "exclude");
|
|
131
236
|
let existing = "";
|
|
@@ -135,18 +240,30 @@ async function provisionWorktreeSettingsExclude(worktreePath, deps) {
|
|
|
135
240
|
catch {
|
|
136
241
|
existing = "";
|
|
137
242
|
}
|
|
138
|
-
|
|
243
|
+
// Idempotent per LINE, not per file: the settings entry predates the
|
|
244
|
+
// `.conductor/` one, so a worktree provisioned by an earlier executor build
|
|
245
|
+
// already contains the first and still needs the second. A single
|
|
246
|
+
// "does the file mention anything of ours?" check would skip it forever.
|
|
247
|
+
const present = new Set(existing.split(/\r?\n/));
|
|
248
|
+
const missing = EXECUTOR_EXCLUDE_LINES.filter((line) => !present.has(line));
|
|
249
|
+
if (missing.length === 0) {
|
|
139
250
|
return { ok: true };
|
|
140
251
|
}
|
|
252
|
+
// One write. Unrelated user-authored entries are preserved verbatim, and a
|
|
253
|
+
// file that did not end in a newline gets one first so the appended lines
|
|
254
|
+
// cannot fuse onto the operator's last entry.
|
|
141
255
|
const prefix = existing.length === 0 || existing.endsWith("\n") ? existing : `${existing}\n`;
|
|
142
|
-
await deps.writeFile(excludePath, `${prefix}${
|
|
256
|
+
await deps.writeFile(excludePath, `${prefix}${missing.map((line) => `${line}\n`).join("")}`);
|
|
143
257
|
return { ok: true };
|
|
144
258
|
}
|
|
145
259
|
catch {
|
|
260
|
+
// The gitdir resolved but the exclude could not be written. Unlike the
|
|
261
|
+
// resolution failures above this keeps `ok: false`, because the caller's
|
|
262
|
+
// established contract treats a failed WRITE as the degraded case worth
|
|
263
|
+
// aggregating into the deny layer's own result.
|
|
146
264
|
return {
|
|
147
265
|
ok: false,
|
|
148
|
-
warning: "
|
|
149
|
-
`(${EXECUTOR_SETTINGS_EXCLUDE_LINE} may appear in worker commits); continuing fail-open`,
|
|
266
|
+
warning: excludeWarning(worktreePath, "the exclude file could not be written"),
|
|
150
267
|
};
|
|
151
268
|
}
|
|
152
269
|
}
|
|
@@ -173,13 +290,23 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
173
290
|
// may already exist from an earlier provisioning, so it runs even when the
|
|
174
291
|
// merge-write was refused or failed.
|
|
175
292
|
const exclude = await provisionWorktreeSettingsExclude(worktreePath, deps);
|
|
176
|
-
|
|
293
|
+
// `blocking` and `advisory` are kept apart on purpose. A blocking warning
|
|
294
|
+
// flips `ok` (the established contract, which existing callers and tests
|
|
295
|
+
// read); an advisory one rides along on `ok: true` so a degraded-but-
|
|
296
|
+
// non-failing outcome is still logged instead of vanishing (BAPI-815/R2).
|
|
297
|
+
const blocking = [];
|
|
298
|
+
const advisory = [];
|
|
177
299
|
if (!result.ok) {
|
|
178
|
-
|
|
300
|
+
blocking.push(`deny-layer provisioning did not complete (${result.reason}); continuing fail-open`);
|
|
179
301
|
}
|
|
180
302
|
if (!exclude.ok)
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
303
|
+
blocking.push(exclude.warning);
|
|
304
|
+
else if (exclude.warning)
|
|
305
|
+
advisory.push(exclude.warning);
|
|
306
|
+
if (blocking.length > 0) {
|
|
307
|
+
return { ok: false, warning: [...blocking, ...advisory].join("; ") };
|
|
308
|
+
}
|
|
309
|
+
if (advisory.length > 0)
|
|
310
|
+
return { ok: true, warning: advisory.join("; ") };
|
|
311
|
+
return { ok: true };
|
|
185
312
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor platform policy (BAPI-790).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS SEPARATELY. The darwin/linux gate used to live inside
|
|
5
|
+
* `worker-config-isolation.ts`, where it was one clause of an
|
|
6
|
+
* "is isolation establishable here?" question. BAPI-790 retires isolation, and
|
|
7
|
+
* the platform restriction must NOT be retired with it: Windows worker spawning
|
|
8
|
+
* was never measured, and enabling it is explicit follow-on scope rather than
|
|
9
|
+
* something a containment change unlocks as a side effect. Hoisting the gate
|
|
10
|
+
* into its own module is what lets the isolation module shrink without silently
|
|
11
|
+
* widening the supported-platform set.
|
|
12
|
+
*
|
|
13
|
+
* The gate is a PLATFORM check only. It deliberately does not consider
|
|
14
|
+
* credentials: a worker now authenticates through the operator's interactive
|
|
15
|
+
* Claude login (inventory Finding 6), so requiring an `ANTHROPIC_API_KEY` or a
|
|
16
|
+
* `CLAUDE_CODE_OAUTH_TOKEN` before an otherwise-runnable host may claim work
|
|
17
|
+
* would reintroduce exactly the onboarding barrier this epic removes.
|
|
18
|
+
*
|
|
19
|
+
* PURE. Nothing here reads the filesystem, the environment, or a credential.
|
|
20
|
+
* The returned reason is a fixed sentence plus the platform name — never a host
|
|
21
|
+
* path, an environment value, or raw exception text.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Platforms on which a conductor worker may be spawned.
|
|
25
|
+
*
|
|
26
|
+
* `darwin` is measured (inventory findings 1–6). `linux` is inferred from the
|
|
27
|
+
* same CLI and the same argv/env handling and has not been measured directly.
|
|
28
|
+
* Everything else — `win32` included — is unsupported and fails closed.
|
|
29
|
+
*/
|
|
30
|
+
export const SUPPORTED_EXECUTOR_PLATFORMS = ["darwin", "linux"];
|
|
31
|
+
/**
|
|
32
|
+
* Render the fixed, secret-free unsupported-platform sentence.
|
|
33
|
+
*
|
|
34
|
+
* Exported so the preflight refusal and any diagnostic render byte-identical
|
|
35
|
+
* text; two copies of this sentence would drift.
|
|
36
|
+
*/
|
|
37
|
+
export function formatUnsupportedExecutorPlatform(platform) {
|
|
38
|
+
return (`the conductor executor does not support platform '${platform}': worker spawning is ` +
|
|
39
|
+
"supported on darwin and linux only (Windows support is explicit follow-on scope, not " +
|
|
40
|
+
"implied by the current worker containment mechanism)");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether the executor may run on `platform`.
|
|
44
|
+
*
|
|
45
|
+
* Fail-closed on anything unrecognized: an unmeasured platform is never
|
|
46
|
+
* optimistically assumed to work.
|
|
47
|
+
*/
|
|
48
|
+
export function evaluateExecutorPlatform(platform) {
|
|
49
|
+
const name = String(platform);
|
|
50
|
+
if (SUPPORTED_EXECUTOR_PLATFORMS.includes(platform)) {
|
|
51
|
+
return { supported: true, platform: name };
|
|
52
|
+
}
|
|
53
|
+
return { supported: false, platform: name, message: formatUnsupportedExecutorPlatform(name) };
|
|
54
|
+
}
|