@bridge_gpt/mcp-server 0.2.67 → 0.2.69

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.
Files changed (127) hide show
  1. package/README.md +272 -51
  2. package/build/agent-launchers/claude-executor-adapter.js +27 -0
  3. package/build/agents.generated.js +22 -2
  4. package/build/bitbucket-workspace-remote.js +136 -0
  5. package/build/bootstrap-project-identity.js +24 -0
  6. package/build/bridge-permissions.js +1362 -0
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-settings.js +102 -2
  9. package/build/cli-help.js +27 -0
  10. package/build/codex-home.js +9 -1
  11. package/build/codex-launcher-pin.js +598 -0
  12. package/build/command-assets-doctor.js +24 -70
  13. package/build/command-provisioning.js +29 -5
  14. package/build/commands.generated.js +9 -9
  15. package/build/conductor/bridge-api-client.js +714 -0
  16. package/build/conductor/command-drift-production.js +92 -0
  17. package/build/conductor/command-drift.js +89 -0
  18. package/build/conductor/doctor.js +69 -26
  19. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  20. package/build/conductor/epic-implementer-cli.js +76 -22
  21. package/build/conductor/gate-discovery.js +890 -0
  22. package/build/conductor/install-doctor.js +15 -3
  23. package/build/conductor/json-output.js +63 -0
  24. package/build/conductor/readiness-cli.js +95 -10
  25. package/build/conductor/readiness-sections.js +369 -3
  26. package/build/conductor/readiness.js +211 -8
  27. package/build/conductor/recovery-cli.js +110 -4
  28. package/build/conductor/recovery-operations.js +99 -1
  29. package/build/connect-bitbucket-api.js +41 -6
  30. package/build/connect-bitbucket.js +106 -33
  31. package/build/connect-github-api.js +33 -14
  32. package/build/credential-store.js +207 -47
  33. package/build/docs.generated.js +57 -3
  34. package/build/doctor.js +198 -4
  35. package/build/drive-epic.js +45 -8
  36. package/build/epic-coherence-input.test-fixtures.js +4 -3
  37. package/build/epic-implementer-bin.js +1369 -25
  38. package/build/epic-workarounds-path.js +39 -0
  39. package/build/executor/cli.js +30 -0
  40. package/build/executor/completion-contract.js +20 -5
  41. package/build/executor/deps.js +4 -1
  42. package/build/executor/gate-qualification-job.js +499 -0
  43. package/build/executor/guarded-fast-forward-push.js +129 -0
  44. package/build/executor/http-client.js +43 -4
  45. package/build/executor/integration-job.js +122 -8
  46. package/build/executor/job-errors.js +8 -1
  47. package/build/executor/job-runner.js +589 -47
  48. package/build/executor/likely-tests.js +290 -0
  49. package/build/executor/live-worker-registry.js +1 -1
  50. package/build/executor/observation.js +31 -2
  51. package/build/executor/packaged-recipe.js +106 -0
  52. package/build/executor/permissions.js +115 -28
  53. package/build/executor/platform.js +1 -1
  54. package/build/executor/preflight.js +61 -12
  55. package/build/executor/process-containment.js +36 -12
  56. package/build/executor/process.js +15 -7
  57. package/build/executor/prompt-spec.js +33 -1
  58. package/build/executor/recently-failed-tests-file.js +71 -0
  59. package/build/executor/reconcile-overlap-job.js +41 -23
  60. package/build/executor/required-verification.js +1 -1
  61. package/build/executor/required-work-ledger.js +105 -17
  62. package/build/executor/retirement.js +69 -0
  63. package/build/executor/runner.js +114 -11
  64. package/build/executor/test-clock.js +15 -0
  65. package/build/executor/types.js +32 -0
  66. package/build/executor/viewer-tabs.js +58 -8
  67. package/build/executor/worker-command.js +23 -7
  68. package/build/executor/worker-diagnostics.js +37 -0
  69. package/build/executor/worker-finalization.js +31 -5
  70. package/build/executor/worker-guard-hook.js +179 -25
  71. package/build/executor/worker-log.js +4 -3
  72. package/build/executor/worker-verification-guidance.js +37 -0
  73. package/build/executor/worktree.js +88 -11
  74. package/build/first-build-advisory.js +97 -0
  75. package/build/generated-assets-advisory.js +14 -0
  76. package/build/generated-assets-catalog.js +66 -0
  77. package/build/generated-assets-context.js +46 -0
  78. package/build/generated-assets-dependencies.js +26 -0
  79. package/build/generated-assets-doctor.js +50 -0
  80. package/build/generated-assets-health.js +65 -0
  81. package/build/generated-assets-host.js +17 -0
  82. package/build/generated-assets-inventory.js +17 -0
  83. package/build/generated-assets-paths.js +60 -0
  84. package/build/generated-assets-publisher.js +133 -0
  85. package/build/generated-assets-remedy.js +25 -0
  86. package/build/generated-assets-repair.js +72 -0
  87. package/build/generated-assets-session.js +71 -0
  88. package/build/generated-assets-test-support.js +35 -0
  89. package/build/index.js +1109 -695
  90. package/build/init.js +54 -177
  91. package/build/install-bridge-conductor-gate.js +1372 -0
  92. package/build/install-bridge-conductor.js +289 -36
  93. package/build/install-bridge.js +722 -252
  94. package/build/launcher-config-inspection.js +184 -19
  95. package/build/mcp-host-targets.js +4 -0
  96. package/build/mcp-identity.js +7 -0
  97. package/build/mcp-install-state.js +50 -4
  98. package/build/merge-workflow-install.js +3 -4
  99. package/build/pipeline-utils.js +6 -2
  100. package/build/pipelines.generated.js +22 -142
  101. package/build/plan-execution-ledger.js +2 -32
  102. package/build/plane/manifest.js +16 -1
  103. package/build/plane/preflight.js +54 -1
  104. package/build/printed-remedy-commands.js +16 -1
  105. package/build/readme.generated.js +1 -1
  106. package/build/repair-assets.js +93 -0
  107. package/build/run-unit-tests-launcher.js +130 -49
  108. package/build/secret-redaction.js +33 -0
  109. package/build/session-upgrade-notice.js +86 -0
  110. package/build/setup-epic.js +108 -3
  111. package/build/start-tickets-prereqs.js +70 -16
  112. package/build/start-tickets.js +44 -5
  113. package/build/tool-call-advisory.js +162 -0
  114. package/build/tool-surface-gating.js +32 -57
  115. package/build/update-check.js +97 -16
  116. package/build/update-status.js +61 -71
  117. package/build/upgrade-advice.js +15 -11
  118. package/build/upgrade-cli.js +398 -17
  119. package/build/version.generated.js +3 -3
  120. package/build/worker-guard-hook-bin.js +3 -2
  121. package/build/worker-guard-hook-main.js +1 -1
  122. package/build/worktree-core.js +15 -2
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
  124. package/docs/EPIC-IMPLEMENTER.md +68 -2
  125. package/package.json +3 -3
  126. package/pipelines/greenfield-setup.json +4 -128
  127. package/pipelines/implement-ticket.json +3 -2
@@ -0,0 +1,93 @@
1
+ import { access, lstat, realpath, readFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import os from "node:os";
4
+ import { WORKFLOW_HOSTS, buildPackagedAssetCatalog } from "./generated-assets-catalog.js";
5
+ import { authorizeAssetRepair, assetRootBinding } from "./generated-assets-context.js";
6
+ import { validateAssetRoot } from "./generated-assets-paths.js";
7
+ import { readMcpInstallState } from "./mcp-install-state.js";
8
+ import { repairPackagedAssets } from "./generated-assets-repair.js";
9
+ import { createConfinedAssetPublisher } from "./generated-assets-publisher.js";
10
+ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
11
+ import { VERSION } from "./version.generated.js";
12
+ export const REPAIR_ASSETS_HELP = `Usage: repair-assets [--project-root <directory>] [--host <claude|cursor|codex|copilot>]
13
+ Fill missing packaged workflow files only; preserve existing customized files.
14
+ Explicit --project-root plus --host authorizes provisioning without inventory.
15
+ Implicit selection requires root-bound inventory at exact cwd; subdirectories must
16
+ select --project-root. Never infer installed hosts from folders or selectedPlatforms.
17
+ Reports restored/preserved/unreadable/failed files; incomplete repair exits 1.
18
+ Linux procfs supports confined exclusive publication. Other platforms refuse writes
19
+ when a confined adapter is unavailable. Existing readable files remain preserved.
20
+ Excludes custom pipelines/instructions, registrations, credentials, generated outputs,
21
+ onboarding, API setup, and Windsurf. Refresh/reconnect after repair, then run doctor.
22
+ Unpinned npx -y ${MCP_PACKAGE_NAME} repair-assets intentionally selects the latest
23
+ package; use @${VERSION} to repair from this running version.\n`;
24
+ export async function runRepairAssetsCli(argv, options = {}) {
25
+ const log = options.log ?? (text => process.stdout.write(`${text}\n`));
26
+ const error = options.error ?? (text => process.stderr.write(`${text}\n`));
27
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
28
+ log(REPAIR_ASSETS_HELP);
29
+ return 0;
30
+ }
31
+ let requestedRoot;
32
+ const hosts = [];
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const flag = argv[i];
35
+ const value = argv[++i];
36
+ if (!value || value.startsWith("--")) {
37
+ error("repair-assets: missing option value; use --help.");
38
+ return 1;
39
+ }
40
+ if (flag === "--project-root" && !requestedRoot)
41
+ requestedRoot = value;
42
+ else if (flag === "--host" && WORKFLOW_HOSTS.includes(value))
43
+ hosts.push(value);
44
+ else {
45
+ error("repair-assets: unknown, duplicate, or invalid option; use --help.");
46
+ return 1;
47
+ }
48
+ }
49
+ const fs = options.fs ?? { lstat, realpath, readFile: (p) => readFile(p, "utf-8"), access: (p) => access(p, constants.R_OK) };
50
+ const root = await validateAssetRoot(requestedRoot ?? options.cwd ?? process.cwd(), options.home ?? os.homedir(), fs);
51
+ if (!root) {
52
+ error("repair-assets: select an existing canonical project directory, excluding home and filesystem roots.");
53
+ return 1;
54
+ }
55
+ const state = await readMcpInstallState(root, fs);
56
+ const inventory = state.status === "valid" ? state.state.packagedAssets : undefined;
57
+ const explicitPair = requestedRoot !== undefined && hosts.length > 0;
58
+ const selected = [...new Set(hosts.length ? hosts : inventory?.assets.map(a => a.host) ?? [])];
59
+ if (!authorizeAssetRepair(root, selected, explicitPair, inventory)) {
60
+ error("repair-assets: installation context is uncertain. Supply both --project-root and --host for explicit provisioning.");
61
+ return 1;
62
+ }
63
+ log(`Selected project root: ${root}`);
64
+ log(`Invoking package: ${VERSION}; ${explicitPair ? "explicit fill-only provisioning" : "inventory-authorized repair"}.`);
65
+ const boundInventory = inventory?.rootBinding === assetRootBinding(root) ? inventory : undefined;
66
+ if (boundInventory)
67
+ log(`Recorded package: ${boundInventory.packageVersion}; invoking bundle may differ.`);
68
+ const catalog = buildPackagedAssetCatalog();
69
+ if (boundInventory) {
70
+ const historical = new Set(boundInventory.assets.map(a => a.id));
71
+ const introduced = catalog.assets.filter(a => a.hosts.some(h => selected.includes(h)) && !historical.has(a.id));
72
+ log(`Newly introduced catalog assets: ${introduced.length} (provisioning, not historical loss).`);
73
+ }
74
+ try {
75
+ const result = await (options.repair ?? repairPackagedAssets)({ root, hosts: selected, explicitPair, inventory: boundInventory, fs,
76
+ catalog, publisher: options.publisher ?? createConfinedAssetPublisher(options.platform) });
77
+ for (const status of ["restored", "preserved", "unreadable", "failed"]) {
78
+ const items = result.findings.filter(f => f.status === status);
79
+ log(`${status}: ${items.length}${items.length ? ` (${items.slice(0, 5).map(f => `${f.destination}: ${f.reason}`).join(", ")}${items.length > 5 ? ", …" : ""})` : ""}`);
80
+ }
81
+ if (result.packageProblems.length)
82
+ error(`Package problems: ${result.packageProblems.join(", ")}`);
83
+ if (result.cleanupFailures.length)
84
+ error("Some owned temporary files could not be removed. Inspect .bridge-repair-*.tmp in the reported asset parents; do not remove other files.");
85
+ if (!result.complete)
86
+ error("repair-assets: requested availability restoration is incomplete. Resolve reported permission/path/platform problems before retrying.");
87
+ return result.complete ? 0 : 1;
88
+ }
89
+ catch {
90
+ error("repair-assets: repair failed; no unsafe fallback was attempted.");
91
+ return 1;
92
+ }
93
+ }
@@ -16,7 +16,8 @@
16
16
  * compiled module; the logic lives here (under src/, built by tsc) so it can
17
17
  * be unit-tested with node:test like every other module in this package.
18
18
  */
19
- import { readdirSync } from "fs";
19
+ import { readdirSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, existsSync } from "fs";
20
+ import { tmpdir } from "os";
20
21
  import { spawnSync } from "child_process";
21
22
  import path from "path";
22
23
  const DEFAULT_BUILD_DIR = "build";
@@ -76,6 +77,7 @@ export const MODULE_MOCK_MANIFEST = [
76
77
  "build/connect-github.test.js",
77
78
  "build/council-submission.test.js",
78
79
  "build/council-wait-recovery.test.js",
80
+ "build/generated-assets-publisher.test.js",
79
81
  "build/index-artifacts.test.js",
80
82
  "build/index-council-filenames.test.js",
81
83
  "build/index-generate-decision-page.integration.test.js",
@@ -314,27 +316,31 @@ export function selectTestGroup(mode, discovered) {
314
316
  */
315
317
  export function partitionTestBatches(files, options = {}) {
316
318
  const { execPath = process.execPath, nodeOptions = [], maxLength = MAX_COMMAND_LENGTH } = options;
317
- const prefixLength = [execPath, ...nodeOptions].join(" ").length;
319
+ const size = (batch, index) => {
320
+ const args = options.argumentBuilder?.(batch, index) ?? [...nodeOptions, ...batch];
321
+ // Quoted serialization conservatively includes spaces/backslashes/quotes on
322
+ // Windows; legacy callers without an argument builder keep their old budget.
323
+ return options.argumentBuilder
324
+ ? [execPath, ...args].map((arg) => JSON.stringify(arg)).join(" ").length
325
+ : [execPath, ...args].join(" ").length;
326
+ };
318
327
  const batches = [];
319
328
  let current = [];
320
- let currentLength = prefixLength;
321
329
  for (const file of files) {
322
- const addition = 1 + file.length; // separating space + path
323
- if (prefixLength + addition > maxLength) {
324
- throw new Error(`run-unit-tests: "${file}" alone exceeds the command-length budget ` +
325
- `(${prefixLength + addition} > ${maxLength} chars) — cannot batch it safely`);
330
+ if (size([file], batches.length) > maxLength) {
331
+ throw new Error(`run-unit-tests: single file alone exceeds the command-length budget`);
326
332
  }
327
- if (current.length > 0 && currentLength + addition > maxLength) {
333
+ if (current.length && size([...current, file], batches.length) > maxLength) {
328
334
  batches.push(current);
329
335
  current = [];
330
- currentLength = prefixLength;
336
+ if (size([file], batches.length) > maxLength) {
337
+ throw new Error(`run-unit-tests: single file alone exceeds the command-length budget`);
338
+ }
331
339
  }
332
340
  current.push(file);
333
- currentLength += addition;
334
341
  }
335
- if (current.length > 0) {
342
+ if (current.length)
336
343
  batches.push(current);
337
- }
338
344
  return batches;
339
345
  }
340
346
  /** Discovers, selects, batches, and spawns the requested test group; returns an aggregate exit code. */
@@ -355,30 +361,7 @@ export function runSelectedGroup(mode, options) {
355
361
  const batchable = selected.filter((file) => !quarantineSet.has(file));
356
362
  const quarantined = selected.filter((file) => quarantineSet.has(file));
357
363
  const extraFlags = mode === "module-mocks" ? ["--experimental-test-module-mocks"] : [];
358
- const nodeOptions = [...extraFlags, "--test"];
359
- const batches = partitionTestBatches(batchable, { execPath, nodeOptions });
360
- log(`run-unit-tests: group="${mode}" files=${selected.length} batches=${batches.length}` +
361
- (quarantined.length > 0 ? ` quarantined=${quarantined.length}` : ""));
362
- let aggregateExit = 0;
363
- const spawn = (args) => {
364
- const result = spawnFn(execPath, args, {
365
- cwd: root,
366
- stdio: "inherit",
367
- shell: false,
368
- });
369
- if ((result.status ?? 1) !== 0) {
370
- aggregateExit = 1;
371
- }
372
- };
373
- batches.forEach((batch, index) => {
374
- log(`run-unit-tests: batch ${index + 1}/${batches.length} (${batch.length} file(s))`);
375
- spawn([...nodeOptions, ...batch]);
376
- });
377
- quarantined.forEach((file, index) => {
378
- log(`run-unit-tests: quarantined ${index + 1}/${quarantined.length} ${file} (single-file, --test-force-exit)`);
379
- spawn([...nodeOptions, "--test-force-exit", file]);
380
- });
381
- return aggregateExit;
364
+ return runWithReports(mode, options, batchable, quarantined, [...extraFlags, "--test"], log);
382
365
  }
383
366
  /**
384
367
  * Returns the integration lane's file list: every compiled `*.test.js` under
@@ -420,20 +403,118 @@ export function runIntegrationLane(options) {
420
403
  throw new Error("run-integration-tests: discovered zero integration tests — refusing to run " +
421
404
  "(did you forget 'npm run build'?)");
422
405
  }
423
- const nodeOptions = ["--test"];
424
- const batches = partitionTestBatches(selected, { execPath, nodeOptions });
425
- log(`run-integration-tests: files=${selected.length} batches=${batches.length}`);
406
+ return runWithReports("integration", options, selected, [], ["--test"], log);
407
+ }
408
+ /** Shared reporter argument builder: sizing and execution cannot diverge. */
409
+ export function reporterArguments(nodeOptions, files, destination, forceExit = false) {
410
+ return [...nodeOptions, ...(forceExit ? ["--test-force-exit"] : []),
411
+ "--test-reporter=spec", "--test-reporter-destination=stdout",
412
+ "--test-reporter=junit", `--test-reporter-destination=${destination}`, ...files];
413
+ }
414
+ function runWithReports(group, options, ordinary, quarantined, nodeOptions, log) {
415
+ const { root, execPath = process.execPath, spawnFn = spawnSync } = options;
416
+ const retained = options.reportOutputDirectory !== undefined;
417
+ let directory;
426
418
  let aggregateExit = 0;
427
- batches.forEach((batch, index) => {
428
- log(`run-integration-tests: batch ${index + 1}/${batches.length} (${batch.length} file(s))`);
429
- const result = spawnFn(execPath, [...nodeOptions, ...batch], {
430
- cwd: root,
431
- stdio: "inherit",
432
- shell: false,
433
- });
434
- if ((result.status ?? 1) !== 0) {
435
- aggregateExit = 1;
419
+ try {
420
+ if (retained) {
421
+ if (!options.reportOutputDirectory?.trim())
422
+ throw new Error("empty report directory");
423
+ directory = path.resolve(options.reportOutputDirectory);
424
+ // Exclusive allocation refuses stale reports and concurrent invocations.
425
+ mkdirSync(directory, { recursive: false });
436
426
  }
437
- });
427
+ else {
428
+ directory = mkdtempSync(path.join(tmpdir(), "bridge-junit-"));
429
+ }
430
+ const outputDirectory = directory;
431
+ const reportName = (index) => `report-${index}.xml`;
432
+ const args = (files, index, forceExit = false) => reporterArguments(nodeOptions, files, path.join(outputDirectory, reportName(index)), forceExit);
433
+ // Source mapping comes from the build's actual rootDir/outDir, including
434
+ // authored scripts (which do not pass through tsc).
435
+ const config = JSON.parse(readFileSync(new URL("../tsconfig.json", import.meta.url), "utf8"));
436
+ const { rootDir, outDir } = config.compilerOptions;
437
+ if (rootDir !== "src" || outDir !== "build")
438
+ throw new Error("unsupported build source mapping");
439
+ const sourceFor = (file) => {
440
+ const posix = file.replaceAll("\\", "/");
441
+ if (posix.startsWith(`${outDir}/`) && posix.endsWith(".js")) {
442
+ return `mcp_server/${rootDir}/${posix.slice(outDir.length + 1, -3)}.ts`;
443
+ }
444
+ if (posix.startsWith("scripts/") && posix.endsWith(".js"))
445
+ return `mcp_server/${posix}`;
446
+ throw new Error("unmapped test source");
447
+ };
448
+ // Node 24.16 discovery confirmed explicit testcase file attribution.
449
+ // Older local/scheduled runtimes use one source per invocation, so the
450
+ // inventory provides an unambiguous fallback without adding concurrency.
451
+ const [major, minor] = process.versions.node.split(".").map(Number);
452
+ const explicitAttribution = major > 24 || (major === 24 && minor >= 16);
453
+ const batches = explicitAttribution
454
+ ? partitionTestBatches(ordinary, { execPath, nodeOptions, argumentBuilder: args })
455
+ : ordinary.map((file, index) => {
456
+ partitionTestBatches([file], { execPath, argumentBuilder: (files) => args(files, index) });
457
+ return [file];
458
+ });
459
+ const plans = [];
460
+ const addPlan = (files, forceExit) => {
461
+ const index = plans.length;
462
+ partitionTestBatches(files, { execPath, argumentBuilder: (values) => args(values, index, forceExit) });
463
+ const sources = Object.fromEntries(files.map((file) => [`mcp_server/${file}`, sourceFor(file)]));
464
+ // integrationSuite declares skipped suites inside the shared harness;
465
+ // Node attributes those cases to the wrapper, rather than its caller.
466
+ // Map that real declaration source using the same verified build config.
467
+ // Older runtimes still need exactly one entry for invocation attribution.
468
+ const harness = `${outDir}/integration/harness.js`;
469
+ if (group === "integration" && explicitAttribution &&
470
+ existsSync(path.join(root, harness)) &&
471
+ existsSync(path.join(root, rootDir, "integration/harness.ts"))) {
472
+ sources[`mcp_server/${harness}`] = sourceFor(harness);
473
+ }
474
+ plans.push({ report: reportName(index), files, forceExit, state: "planned", status: null,
475
+ sources });
476
+ };
477
+ batches.forEach((batch) => addPlan(batch, false));
478
+ quarantined.forEach((file) => addPlan([file], true));
479
+ const inventory = { version: 1, lane: group === "integration" ? "integration" : "unit", group,
480
+ nodeVersion: process.version, attribution: explicitAttribution ? "reporter-file" : "single-source", spawns: plans };
481
+ const publish = () => {
482
+ const temporary = path.join(outputDirectory, "inventory.json.tmp");
483
+ writeFileSync(temporary, JSON.stringify(inventory, null, 2));
484
+ renameSync(temporary, path.join(outputDirectory, "inventory.json"));
485
+ };
486
+ publish(); // Complete expected inventory is durable before the first spawn.
487
+ log(`run-unit-tests: group="${group}" files=${ordinary.length + quarantined.length} batches=${plans.length}`);
488
+ for (const [index, plan] of plans.entries()) {
489
+ plan.state = "started";
490
+ publish();
491
+ try {
492
+ const result = spawnFn(execPath, args(plan.files, index, plan.forceExit), { cwd: root, stdio: "inherit", shell: false });
493
+ plan.status = result.status;
494
+ plan.state = result.status === null || !existsSync(path.join(outputDirectory, plan.report)) ? "failed" : "completed";
495
+ }
496
+ catch {
497
+ plan.state = "failed";
498
+ }
499
+ if (plan.state !== "completed" || plan.status !== 0)
500
+ aggregateExit = 1;
501
+ publish();
502
+ }
503
+ }
504
+ catch {
505
+ // No exception/environment dump reaches CI logs or exported evidence.
506
+ log("run-unit-tests: report allocation, inventory publication, or execution failed");
507
+ aggregateExit = 1;
508
+ }
509
+ finally {
510
+ if (!retained && directory) {
511
+ try {
512
+ rmSync(directory, { recursive: true, force: true });
513
+ }
514
+ catch {
515
+ aggregateExit = 1;
516
+ }
517
+ }
518
+ }
438
519
  return aggregateExit;
439
520
  }
@@ -0,0 +1,33 @@
1
+ /** Shared leaf scrubber for durable untrusted text. */
2
+ const REDACTED = "[REDACTED]";
3
+ /**
4
+ * Patterns for values that must never reach durable state or a log line.
5
+ *
6
+ * Each rewrites only the secret itself and keeps the surrounding key or scheme, so a
7
+ * redacted record still shows *that* a credential was involved — useful for audit,
8
+ * and materially different from dropping the field.
9
+ */
10
+ const SECRET_PATTERNS = [
11
+ // Bearer / token schemes in an Authorization header or bare.
12
+ [/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${REDACTED}`],
13
+ // authorization: <anything>
14
+ [/\b(authorization)\s*[:=]\s*\S+/gi, `$1: ${REDACTED}`],
15
+ // Well-known provider key shapes.
16
+ [/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, REDACTED],
17
+ [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, REDACTED],
18
+ // key/secret/password/token assignments in prose, env, JSON, or CLI form.
19
+ [
20
+ /\b([A-Za-z0-9_-]*(?:api[_-]?key|secret|password|passwd|token|credential)[A-Za-z0-9_-]*)\s*[:=]\s*("[^"]*"|'[^']*'|\S+)/gi,
21
+ `$1=${REDACTED}`,
22
+ ],
23
+ // Credentials embedded in a URL.
24
+ [/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s@]+@/gi, `$1${REDACTED}@`],
25
+ ];
26
+ /** Redact secret-bearing substrings while leaving safe technical text intact. */
27
+ export function redactSecrets(value) {
28
+ let output = value;
29
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
30
+ output = output.replace(pattern, replacement);
31
+ }
32
+ return output;
33
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The single "should this session be told about an upgrade?" decision (BAPI-1446).
3
+ *
4
+ * Every notice surface asks here: the startup instructions, the first completed
5
+ * tool result, the Claude SessionStart hook, the `ping` advice, and the
6
+ * `start-tickets` reminder. One synchronous, non-throwing function holds every
7
+ * eligibility and silence rule, so the surfaces cannot drift apart.
8
+ *
9
+ * Nothing in this module touches the network or starts asynchronous work. The
10
+ * only I/O is the synchronous read of the local update cache.
11
+ */
12
+ import { parseDefaultOnEnvFlag } from "./env-flags.js";
13
+ import { isNewerVersion, isPlainReleaseVersion, readCachedLatestVersion, readFakeLatestOverride, } from "./update-check.js";
14
+ import { formatSessionUpgradeNotice } from "./upgrade-advice.js";
15
+ /** The kill switch for every update check and notice. Default on. */
16
+ export const UPDATE_CHECK_ENABLED_ENV = "BAPI_MCP_UPDATE_CHECK_ENABLED";
17
+ /** Worker markers: either one means no person is watching the session. */
18
+ export const UNATTENDED_MARKER_ENVS = [
19
+ "BAPI_WORKER_BRANCH",
20
+ "BAPI_EPIC_IMPLEMENTER_WORKER_ID",
21
+ ];
22
+ const SILENT = { show: false };
23
+ /** True when either worker marker holds a non-empty string. */
24
+ export function isUnattendedSession(env) {
25
+ return UNATTENDED_MARKER_ENVS.some((name) => {
26
+ const value = env[name];
27
+ return typeof value === "string" && value.trim().length > 0;
28
+ });
29
+ }
30
+ /**
31
+ * Decide whether to show the upgrade notice. Never throws and never starts
32
+ * asynchronous work.
33
+ *
34
+ * Silence comes first: a disabled kill switch, an unattended session, or an
35
+ * invalid current version returns `{ show: false }` before any latest-version
36
+ * source is read.
37
+ *
38
+ * Latest-version sources, in order:
39
+ * 1. a valid `BAPI_MCP_UPDATE_CHECK_FAKE_LATEST` override;
40
+ * 2. a reliable, known authoritative latest from a settled live check, which
41
+ * stays authoritative even when it says "current" and an older cache
42
+ * would suggest an update;
43
+ * 3. the local cache, when the authoritative source is absent, still in
44
+ * flight, or failed.
45
+ *
46
+ * The cache ignores its own age on purpose. It is a lower bound: it can
47
+ * under-claim an update but never claims one that does not exist. Unknown,
48
+ * equal, older, or invalid versions are silent.
49
+ */
50
+ export function getNoticeDecision(env, currentVersion, authoritativeLatest) {
51
+ try {
52
+ if (!parseDefaultOnEnvFlag(env[UPDATE_CHECK_ENABLED_ENV]))
53
+ return SILENT;
54
+ if (isUnattendedSession(env))
55
+ return SILENT;
56
+ if (!isPlainReleaseVersion(currentVersion))
57
+ return SILENT;
58
+ let latest = readFakeLatestOverride(env);
59
+ if (latest === null &&
60
+ authoritativeLatest &&
61
+ authoritativeLatest.known === true &&
62
+ isPlainReleaseVersion(authoritativeLatest.latest)) {
63
+ latest = authoritativeLatest.latest;
64
+ }
65
+ if (latest === null)
66
+ latest = readCachedLatestVersion(env);
67
+ if (!isPlainReleaseVersion(latest))
68
+ return SILENT;
69
+ if (!isNewerVersion(currentVersion, latest))
70
+ return SILENT;
71
+ return { show: true, current: currentVersion, latest };
72
+ }
73
+ catch {
74
+ return SILENT;
75
+ }
76
+ }
77
+ /**
78
+ * Append the session notice to the server's startup instructions when eligible.
79
+ * The base string is returned unchanged when the decision is silent.
80
+ */
81
+ export function composeServerInstructions(baseInstructions, env, currentVersion) {
82
+ const decision = getNoticeDecision(env, currentVersion);
83
+ if (!decision.show)
84
+ return baseInstructions;
85
+ return `${baseInstructions} ${formatSessionUpgradeNotice(decision.current, decision.latest)}`;
86
+ }
@@ -170,6 +170,7 @@ export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
170
170
  "verdict_protocol",
171
171
  "native_review_decision",
172
172
  "none",
173
+ "repository_policy",
173
174
  ];
174
175
  export function createDefaultSetupEpicDeps() {
175
176
  return {
@@ -260,6 +261,10 @@ export function getSetupEpicUsage() {
260
261
  " --review-policy <src> PER-RUN review policy source, one of:",
261
262
  ` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
262
263
  " Composed into policy_json.review_policy on create.",
264
+ " Omitted, setup-epic sends no review policy: the server",
265
+ " stamps repository_policy at first approval for a new",
266
+ " feature-branch run with no repository default. AI review",
267
+ " is an explicit opt-in (--review-policy verdict_protocol).",
263
268
  " This flag sets the policy for THIS run only and never",
264
269
  " changes a repository default. To persist one, pass",
265
270
  " --review-policy to `install-bridge conductor`, which",
@@ -1263,6 +1268,72 @@ export function policyDeclaresNotifyWebhook(policy) {
1263
1268
  const url = notify.webhook_url;
1264
1269
  return typeof url === "string" && url.trim() !== "";
1265
1270
  }
1271
+ /**
1272
+ * BAPI-1397 (F1) — does this policy set `notify.local_sink` together with a
1273
+ * `notify.webhook_url`?
1274
+ *
1275
+ * Mirrors the server's `NotifyPolicy._reject_local_sink_and_webhook_together`
1276
+ * exactly, and nothing more: `local_sink: true` with a PRESENT, non-null
1277
+ * `webhook_url` conflicts; `local_sink: true` with the URL absent or `null` is
1278
+ * fine; `local_sink: false` (or absent) never conflicts. Every other policy rule
1279
+ * stays the server's to judge. Checked locally so a policy the server would
1280
+ * reject is refused before any plane starts or any request is sent.
1281
+ */
1282
+ export function policyNotifyDestinationsConflict(policy) {
1283
+ if (policy === undefined || policy === null)
1284
+ return false;
1285
+ const notify = policy.notify;
1286
+ if (typeof notify !== "object" || notify === null || Array.isArray(notify))
1287
+ return false;
1288
+ const block = notify;
1289
+ return block.local_sink === true && block.webhook_url !== undefined && block.webhook_url !== null;
1290
+ }
1291
+ /**
1292
+ * Name the selected policy for a launch line or a refusal: its resolved path and
1293
+ * source, or — for a stored or default policy that has no file — a deliberate
1294
+ * no-file label. Only the path is named; the file's content never is.
1295
+ */
1296
+ export function describeSelectedPolicy(policyPath, source) {
1297
+ return policyPath !== undefined
1298
+ ? `'${policyPath}' (${source} policy file)`
1299
+ : `the ${source} run policy (no policy file)`;
1300
+ }
1301
+ /**
1302
+ * The refusal for a policy whose notify destinations conflict. Names the file and
1303
+ * the two keys only — never the webhook URL or any other policy content.
1304
+ */
1305
+ export function notifyDestinationsConflictMessage(policyPath, source) {
1306
+ return (`The run policy ${describeSelectedPolicy(policyPath, source)} sets notify.local_sink ` +
1307
+ "together with notify.webhook_url. The server permits only one delivery destination, so " +
1308
+ "it would reject this policy. Remove notify.local_sink or notify.webhook_url and re-run. " +
1309
+ "No plane was started and no request was sent.");
1310
+ }
1311
+ /** Group-read and other-read permission bits. */
1312
+ const GROUP_OR_OTHER_READ_BITS = 0o044;
1313
+ /**
1314
+ * BAPI-1397 (F1) — warn when a policy file that carries a `notify.webhook_url` is
1315
+ * readable by other users. A warning only, never a refusal: the launch continues.
1316
+ *
1317
+ * Returns the warning text, or `null` when there is nothing to say — including
1318
+ * when the file's mode cannot be read (a failed `stat` is silently skipped). The
1319
+ * text names the path only; it never carries the URL, the policy body, or any
1320
+ * filesystem error text.
1321
+ */
1322
+ export async function worldReadableWebhookPolicyWarning(policyPath, policy, stat) {
1323
+ if (!policyDeclaresNotifyWebhook(policy))
1324
+ return null;
1325
+ let mode;
1326
+ try {
1327
+ mode = (await stat(policyPath)).mode;
1328
+ }
1329
+ catch {
1330
+ return null;
1331
+ }
1332
+ if (typeof mode !== "number" || (mode & GROUP_OR_OTHER_READ_BITS) === 0)
1333
+ return null;
1334
+ return (`[warn] Policy file '${policyPath}' holds a notify webhook URL and other users on this ` +
1335
+ `machine can read it. Restrict it with: chmod 600 '${policyPath}'`);
1336
+ }
1266
1337
  export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPolicy) {
1267
1338
  const unattended = readiness?.unattended ?? null;
1268
1339
  if (unattended === null) {
@@ -1974,6 +2045,21 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
1974
2045
  return 1;
1975
2046
  }
1976
2047
  const localHash = hashPlan(plan);
2048
+ // --- Notify destination conflict, explicit file (BAPI-1397, F1) ----------
2049
+ // An explicit `--policy-file` is always the selected policy, so a conflict the
2050
+ // server would reject is refused HERE — before credentials are resolved and
2051
+ // before any request. A file that cannot be read or parsed is left to the
2052
+ // policy read below, which reports it in its own words, unchanged.
2053
+ const pinnedLaunchPolicy = handoff ? getPinnedEpicLaunchPolicy(handoff.selection) : undefined;
2054
+ if (opts.policyFile !== undefined) {
2055
+ const earlyPolicy = pinnedLaunchPolicy !== undefined
2056
+ ? { ok: true, policy: pinnedLaunchPolicy }
2057
+ : await readSetupEpicPolicyFile(opts.policyFile, deps.readFile);
2058
+ if (earlyPolicy.ok && policyNotifyDestinationsConflict(earlyPolicy.policy)) {
2059
+ deps.errorLog(notifyDestinationsConflictMessage(opts.policyFile, "explicit"));
2060
+ return 1;
2061
+ }
2062
+ }
1977
2063
  // Resolve the selected target and live-run identity before choosing a policy.
1978
2064
  // The offline local-only path keeps its no-network contract.
1979
2065
  let access;
@@ -2092,8 +2178,12 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2092
2178
  const selectedPolicyPath = opts.policyFile ?? (existingRunId === null ? selectedProfilePolicyPath : undefined);
2093
2179
  const selectedPolicySource = existingRunId !== null
2094
2180
  ? "stored" : opts.policyFile !== undefined ? "explicit" : selectedProfilePolicyPath !== undefined ? "profile" : "default";
2181
+ // BAPI-1397 (F1): the source NAMED next to a policy path in operator output. An
2182
+ // explicit file is always "explicit" here, even when the effective-policy facts
2183
+ // above report a reused run's policy as "stored".
2184
+ const policyLabelSource = opts.policyFile !== undefined ? "explicit" : selectedPolicySource;
2095
2185
  if (selectedPolicyPath !== undefined) {
2096
- const pinned = handoff ? getPinnedEpicLaunchPolicy(handoff.selection) : undefined;
2186
+ const pinned = pinnedLaunchPolicy;
2097
2187
  if (pinned !== undefined) {
2098
2188
  filePolicy = pinned;
2099
2189
  }
@@ -2105,6 +2195,17 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2105
2195
  }
2106
2196
  filePolicy = policyRead.policy;
2107
2197
  }
2198
+ // BAPI-1397 (F1): the same server rule, for a profile-selected file. Still
2199
+ // before any write or plane bring-up; the refusal names the resolved file.
2200
+ if (policyNotifyDestinationsConflict(filePolicy)) {
2201
+ deps.errorLog(notifyDestinationsConflictMessage(selectedPolicyPath, policyLabelSource));
2202
+ return 1;
2203
+ }
2204
+ // BAPI-1397 (F1): a webhook URL in a file other users can read is a warning,
2205
+ // never a refusal. Only the path is named.
2206
+ const readableWarning = await worldReadableWebhookPolicyWarning(selectedPolicyPath, filePolicy, deps.stat);
2207
+ if (readableWarning !== null)
2208
+ deps.errorLog(readableWarning);
2108
2209
  }
2109
2210
  const policyDeclaresBranch = filePolicy !== undefined &&
2110
2211
  ["base_branch", "baseBranch"].some((key) => key in filePolicy && filePolicy[key] !== undefined && filePolicy[key] !== null);
@@ -2270,7 +2371,10 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2270
2371
  "human-gated) — pass --attended to opt out"
2271
2372
  : "";
2272
2373
  const shape = inherited === "" ? "complete run policy" : "run policy";
2273
- say(`Policy: ${opts.policyFile} (${shape}, applied at creation${inherited})`);
2374
+ // BAPI-1397 (F1): the RESOLVED path and source — `opts.policyFile` is
2375
+ // undefined for a profile-selected or composed policy.
2376
+ say(`Policy: ${describeSelectedPolicy(selectedPolicyPath, policyLabelSource)} ` +
2377
+ `(${shape}, applied at creation${inherited})`);
2274
2378
  }
2275
2379
  if (effectiveFeatureBranch !== undefined && featureBranch === undefined) {
2276
2380
  say(`Feature: ${effectiveFeatureBranch} (from the policy file; cut on origin with local ` +
@@ -2858,7 +2962,8 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2858
2962
  // Re-sanitizing here would only destroy the field name that makes the
2859
2963
  // server's field-named 422 actionable. The property is pinned by the
2860
2964
  // regression test below rather than left to this comment.
2861
- deps.errorLog(`The run policy from '${opts.policyFile}' was rejected by the server.\n` +
2965
+ deps.errorLog(`The run policy ${describeSelectedPolicy(selectedPolicyPath, policyLabelSource)} ` +
2966
+ `was rejected by the server.\n` +
2862
2967
  `${errorDetail(err)}\n` +
2863
2968
  `No run was created and no automation-start charge occurred. Fix the ` +
2864
2969
  `named field in the policy file and re-run.`);