@bridge_gpt/mcp-server 0.2.68 → 0.2.70
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 +226 -320
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +650 -98
- package/build/check-update-cli.js +74 -0
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/claude-settings.js +84 -2
- package/build/cli-help.js +7 -1
- package/build/codex-bridge-profile.js +1 -1
- package/build/codex-home.js +9 -1
- package/build/codex-launcher-pin.js +598 -0
- package/build/commands.generated.js +8 -8
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/connect-github-api.js +33 -14
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +27 -14
- package/build/doctor.js +203 -38
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +131 -120
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +68 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +115 -4
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1214 -465
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +629 -350
- package/build/launcher-config-inspection.js +184 -19
- package/build/mcp-host-targets.js +4 -0
- package/build/mcp-identity.js +7 -0
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/session-upgrade-notice.js +86 -0
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +43 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/tool-call-advisory.js +162 -0
- package/build/tool-surface-gating.js +28 -89
- package/build/update-check.js +97 -16
- package/build/update-status.js +61 -71
- package/build/upgrade-advice.js +15 -11
- package/build/upgrade-cli.js +360 -20
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bridge-managed Claude Code permission rules for the `/drive-epic-run`
|
|
3
|
-
* supervisor (BAPI-1398, AC-8)
|
|
3
|
+
* supervisor (BAPI-1398, AC-8), and the one pinned SessionStart upgrade-notice
|
|
4
|
+
* hook (BAPI-1446).
|
|
4
5
|
*
|
|
5
6
|
* The supervisor runs in auto mode. Every in-scope action it takes — a
|
|
6
7
|
* sanctioned CLI verb, a Bridge MCP tool, a read, a tally write — must resolve
|
|
@@ -18,7 +19,10 @@
|
|
|
18
19
|
* names Claude Code really sees. The auto-mode
|
|
19
20
|
* classifier ignores `autoMode` in project settings, so the prose
|
|
20
21
|
* `autoMode.allow` / `autoMode.environment` entries go into the USER's
|
|
21
|
-
* `~/.claude/settings.json` — and only with the user's explicit consent.
|
|
22
|
+
* `~/.claude/settings.json` — and only with the user's explicit consent. Install
|
|
23
|
+
* never asks for it (BAPI-1448): the `bridge-permissions` subcommand
|
|
24
|
+
* ({@link runBridgePermissionsCli}) owns that write, and `/drive-epic-run` offers
|
|
25
|
+
* it in-session only when the entries are missing.
|
|
22
26
|
*
|
|
23
27
|
* Ownership. A JSON string in an allow list cannot carry a marker, and Claude
|
|
24
28
|
* Code's settings loader is not given unknown keys. Bridge therefore records the
|
|
@@ -38,9 +42,19 @@
|
|
|
38
42
|
* Never broad: no `Bash(*)`, no wildcard launcher, and no rule that covers a
|
|
39
43
|
* credential file. A launcher override that cannot be written as an exact,
|
|
40
44
|
* secret-free command prefix is refused rather than approximated.
|
|
45
|
+
*
|
|
46
|
+
* The SessionStart hook (BAPI-1446) lives in the same local settings file and is
|
|
47
|
+
* owned the same way: the manifest's `hooks.SessionStart` records the one exact
|
|
48
|
+
* command Bridge wrote, removal deletes exactly that command, and permission and
|
|
49
|
+
* hook changes share one settings snapshot so neither overwrites the other.
|
|
41
50
|
*/
|
|
51
|
+
import fs from "node:fs/promises";
|
|
52
|
+
import os from "node:os";
|
|
42
53
|
import path from "node:path";
|
|
54
|
+
import readline from "node:readline";
|
|
55
|
+
import { inspectClaudeHookContainers, mergeClaudeSettingsWithCommandHookChecked, removeClaudeSettingsCommandHook, } from "./claude-settings.js";
|
|
43
56
|
import { MCP_PACKAGE_NAME, MCP_SERVER_NAME, resolveRegistrationKey } from "./mcp-identity.js";
|
|
57
|
+
import { isPlainReleaseVersion } from "./update-check.js";
|
|
44
58
|
// ---------------------------------------------------------------------------
|
|
45
59
|
// Constants
|
|
46
60
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +72,14 @@ export const BRIDGE_PERMISSIONS_MANIFEST_RELPATH = ".bridge/bridge-permissions.j
|
|
|
58
72
|
export const BRIDGE_PROJECT_SETTINGS_RELPATH = ".claude/settings.local.json";
|
|
59
73
|
/** Current manifest schema version. */
|
|
60
74
|
export const BRIDGE_PERMISSIONS_MANIFEST_VERSION = 1;
|
|
75
|
+
/** The Claude Code hook event the upgrade notice runs on. */
|
|
76
|
+
export const SESSION_START_HOOK_EVENT = "SessionStart";
|
|
77
|
+
/** The exact arguments after the launcher: the internal offline check command. */
|
|
78
|
+
export const SESSION_START_HOOK_SUFFIX = "check-update --hook claude-session-start";
|
|
79
|
+
/** Run only on a fresh session start, not on resume, clear, or compact. */
|
|
80
|
+
export const SESSION_START_HOOK_MATCHER = "startup";
|
|
81
|
+
/** Seconds Claude Code waits for the hook. A cold `npx` launch can exceed it. */
|
|
82
|
+
export const SESSION_START_HOOK_TIMEOUT_SECONDS = 5;
|
|
61
83
|
/** The auto-mode sentinel that keeps Claude Code's built-in entries in a list. */
|
|
62
84
|
export const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
|
|
63
85
|
/**
|
|
@@ -80,6 +102,9 @@ export const BRIDGE_SANCTIONED_VERB_SUBCOMMANDS = [
|
|
|
80
102
|
"resume-run",
|
|
81
103
|
"resume-ci",
|
|
82
104
|
"rerun-ci",
|
|
105
|
+
"resolve-handoff",
|
|
106
|
+
// BAPI-1532: answer a named plan failure with one forced regeneration.
|
|
107
|
+
"regenerate-plan",
|
|
83
108
|
];
|
|
84
109
|
/** The Bridge MCP tools the supervisor calls. */
|
|
85
110
|
export const BRIDGE_SUPERVISOR_MCP_TOOLS = [
|
|
@@ -158,8 +183,14 @@ export function buildProjectPermissionRules(launcherPrefix, serverNames = [MCP_S
|
|
|
158
183
|
rules.push(`Bash(${launcherPrefix} epic-implementer readiness *)`);
|
|
159
184
|
// Read-only supporting commands.
|
|
160
185
|
rules.push(`Bash(${launcherPrefix} epic-implementer continuation-receipt *)`);
|
|
186
|
+
// BAPI-1469: the bounded, read-only wait the Monitoring loop runs between passes.
|
|
187
|
+
rules.push(`Bash(${launcherPrefix} epic-implementer wait-run *)`);
|
|
161
188
|
rules.push(`Bash(${launcherPrefix} epic-implementer doctor)`);
|
|
162
189
|
rules.push(`Bash(${launcherPrefix} plane status)`);
|
|
190
|
+
// BAPI-1448: the read-only user-scope check `/drive-epic-run` runs first. There is
|
|
191
|
+
// deliberately NO rule for `--user-consented`: Claude Code's own permission prompt
|
|
192
|
+
// for that write is the second consent layer.
|
|
193
|
+
rules.push(`Bash(${launcherPrefix} bridge-permissions --check --json)`);
|
|
163
194
|
rules.push("Bash(pmset -g log)");
|
|
164
195
|
rules.push("Bash(git ls-tree *)");
|
|
165
196
|
rules.push("Bash(gh auth status)");
|
|
@@ -193,10 +224,24 @@ export function buildProjectPermissionRules(launcherPrefix, serverNames = [MCP_S
|
|
|
193
224
|
rules.push("Bash(git show '*:.bridge/epic-workarounds/*.md')");
|
|
194
225
|
rules.push("Bash(gh pr view 'epic/*' --json number,isDraft,state,headRefOid,baseRefName,comments)");
|
|
195
226
|
rules.push(`Bash(gh pr comment * --body-file ${BRIDGE_RUN_STATE_DIR}/*/workarounds.md)`);
|
|
196
|
-
// The notes-log commit on docs/<KEY>-conductor-run-notes
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
227
|
+
// The notes-log commit on docs/<KEY>-conductor-run-notes, made in a private
|
|
228
|
+
// worktree under the run-state directory so the shared checkout is never
|
|
229
|
+
// switched. The notes files themselves are written under the run-state
|
|
230
|
+
// Read/Edit/Write rules above.
|
|
231
|
+
rules.push(`Bash(git worktree add -b 'docs/*-conductor-run-notes' ${BRIDGE_RUN_STATE_DIR}/*/notes-worktree HEAD)`);
|
|
232
|
+
rules.push(`Bash(git -C ${BRIDGE_RUN_STATE_DIR}/*/notes-worktree add docs/epic-implementer/run-notes/)`);
|
|
233
|
+
rules.push(`Bash(git -C ${BRIDGE_RUN_STATE_DIR}/*/notes-worktree commit -m * -- docs/epic-implementer/run-notes/)`);
|
|
234
|
+
rules.push(`Bash(git worktree remove ${BRIDGE_RUN_STATE_DIR}/*/notes-worktree)`);
|
|
235
|
+
// BAPI-1496: the owner-tagged worker-supervisor pause before a local launch,
|
|
236
|
+
// the ownership-guarded post-completion teardown of this run's own plane, and
|
|
237
|
+
// the paired owner-tagged resume. Only the quoted owner tag or run id fills
|
|
238
|
+
// the `*`. The bare `plane down` and bare `resume` are operator overrides the
|
|
239
|
+
// supervisor never runs, so they have no rule.
|
|
240
|
+
rules.push("Bash(python scripts/worker_supervisor.py pause --owner '*')");
|
|
241
|
+
rules.push(`Bash(${launcherPrefix} plane down --expect-epic-run-id '*')`);
|
|
242
|
+
rules.push("Bash(python scripts/worker_supervisor.py resume --owner '*')");
|
|
243
|
+
// The planned-hand-off comparison's one read of each dependent child PR.
|
|
244
|
+
rules.push("Bash(gh pr view * --json number,body)");
|
|
200
245
|
// The Bridge MCP tools the supervisor uses, under each installed server key.
|
|
201
246
|
for (const serverName of serverNames) {
|
|
202
247
|
for (const tool of BRIDGE_SUPERVISOR_MCP_TOOLS) {
|
|
@@ -269,14 +314,19 @@ export function userSettingsPath(homeDir) {
|
|
|
269
314
|
export function bridgePermissionsManifestPath(cwd) {
|
|
270
315
|
return path.join(cwd, ...BRIDGE_PERMISSIONS_MANIFEST_RELPATH.split("/"));
|
|
271
316
|
}
|
|
272
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* Read a settings file: missing is `{}`-equivalent, malformed is never used. Only
|
|
319
|
+
* `ENOENT` means missing. Any other read error (`EACCES` included) is refused like
|
|
320
|
+
* malformed JSON, so no writer replaces a file it could not read.
|
|
321
|
+
*/
|
|
273
322
|
export async function readSettingsFile(filePath, deps) {
|
|
274
323
|
let raw;
|
|
275
324
|
try {
|
|
276
325
|
raw = await deps.readFile(filePath);
|
|
277
326
|
}
|
|
278
|
-
catch {
|
|
279
|
-
|
|
327
|
+
catch (error) {
|
|
328
|
+
const code = error?.code;
|
|
329
|
+
return code === "ENOENT" ? { status: "missing" } : { status: "malformed" };
|
|
280
330
|
}
|
|
281
331
|
try {
|
|
282
332
|
const parsed = JSON.parse(raw);
|
|
@@ -329,10 +379,23 @@ export async function readBridgePermissionsManifest(cwd, deps) {
|
|
|
329
379
|
return { status: "invalid" };
|
|
330
380
|
if (launcher !== null && typeof launcher !== "string")
|
|
331
381
|
return { status: "invalid" };
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
382
|
+
const manifest = {
|
|
383
|
+
version: BRIDGE_PERMISSIONS_MANIFEST_VERSION,
|
|
384
|
+
launcher,
|
|
385
|
+
project,
|
|
386
|
+
userAllow,
|
|
387
|
+
userEnvironment,
|
|
335
388
|
};
|
|
389
|
+
if (doc.hooks !== undefined) {
|
|
390
|
+
const hooks = doc.hooks;
|
|
391
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks))
|
|
392
|
+
return { status: "invalid" };
|
|
393
|
+
const sessionStart = stringList(hooks[SESSION_START_HOOK_EVENT]);
|
|
394
|
+
if (!sessionStart)
|
|
395
|
+
return { status: "invalid" };
|
|
396
|
+
manifest.hooks = { SessionStart: sessionStart };
|
|
397
|
+
}
|
|
398
|
+
return { status: "valid", manifest };
|
|
336
399
|
}
|
|
337
400
|
catch {
|
|
338
401
|
return { status: "invalid" };
|
|
@@ -340,13 +403,19 @@ export async function readBridgePermissionsManifest(cwd, deps) {
|
|
|
340
403
|
}
|
|
341
404
|
/** Serialize the manifest with a stable key order. */
|
|
342
405
|
export function serializeBridgePermissionsManifest(manifest) {
|
|
343
|
-
|
|
406
|
+
const doc = {
|
|
344
407
|
version: manifest.version,
|
|
345
408
|
launcher: manifest.launcher,
|
|
346
409
|
project: manifest.project,
|
|
347
410
|
userAllow: manifest.userAllow,
|
|
348
411
|
userEnvironment: manifest.userEnvironment,
|
|
349
|
-
}
|
|
412
|
+
};
|
|
413
|
+
// Written last and only when something is owned, so a manifest without hooks
|
|
414
|
+
// serializes byte-for-byte as it did before hooks existed.
|
|
415
|
+
if (manifest.hooks && manifest.hooks.SessionStart.length > 0) {
|
|
416
|
+
doc.hooks = { SessionStart: manifest.hooks.SessionStart };
|
|
417
|
+
}
|
|
418
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
350
419
|
}
|
|
351
420
|
async function writeManifestIfChanged(cwd, next, deps) {
|
|
352
421
|
const target = bridgePermissionsManifestPath(cwd);
|
|
@@ -422,56 +491,303 @@ function unique(values) {
|
|
|
422
491
|
return [...new Set(values)];
|
|
423
492
|
}
|
|
424
493
|
/**
|
|
425
|
-
*
|
|
494
|
+
* Build the SessionStart hook command for a resolved launcher and a validated pin.
|
|
495
|
+
* The default launcher is pinned by appending `@<pin>` to its package token; a
|
|
496
|
+
* valid `BAPI_MCP_CLI` override already names its own build and gets the exact
|
|
497
|
+
* hook suffix appended unchanged.
|
|
498
|
+
*/
|
|
499
|
+
export function buildSessionStartHookCommand(launcher, pinnedVersion) {
|
|
500
|
+
const prefix = launcher.source === "default" ? `${launcher.prefix}@${pinnedVersion}` : launcher.prefix;
|
|
501
|
+
return `${prefix} ${SESSION_START_HOOK_SUFFIX}`;
|
|
502
|
+
}
|
|
503
|
+
/** Every command leaf registered for an event, in order. */
|
|
504
|
+
function sessionStartCommands(settings) {
|
|
505
|
+
const hooks = settings.hooks;
|
|
506
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks))
|
|
507
|
+
return [];
|
|
508
|
+
const entries = hooks[SESSION_START_HOOK_EVENT];
|
|
509
|
+
if (!Array.isArray(entries))
|
|
510
|
+
return [];
|
|
511
|
+
const commands = [];
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
const leaves = entry?.hooks;
|
|
514
|
+
if (!Array.isArray(leaves))
|
|
515
|
+
continue;
|
|
516
|
+
for (const leaf of leaves) {
|
|
517
|
+
const found = leaf;
|
|
518
|
+
if (found && found.type === "command" && typeof found.command === "string") {
|
|
519
|
+
commands.push(found.command);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return commands;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* True when `command` is registered exactly once, in an entry that carries the
|
|
527
|
+
* startup matcher, on a leaf that carries the five-second timeout.
|
|
528
|
+
*/
|
|
529
|
+
function isHookConfiguredCorrectly(settings, command) {
|
|
530
|
+
const hooks = settings.hooks;
|
|
531
|
+
const entries = hooks[SESSION_START_HOOK_EVENT];
|
|
532
|
+
let found = 0;
|
|
533
|
+
let correct = false;
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
const leaves = entry?.hooks;
|
|
536
|
+
if (!Array.isArray(leaves))
|
|
537
|
+
continue;
|
|
538
|
+
for (const leaf of leaves) {
|
|
539
|
+
const candidate = leaf;
|
|
540
|
+
if (candidate && candidate.type === "command" && candidate.command === command) {
|
|
541
|
+
found += 1;
|
|
542
|
+
correct =
|
|
543
|
+
entry.matcher === SESSION_START_HOOK_MATCHER &&
|
|
544
|
+
candidate.timeout === SESSION_START_HOOK_TIMEOUT_SECONDS;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return found === 1 && correct;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Is an UNRECORDED command narrowly recognizable as Bridge's own hook? Only on
|
|
552
|
+
* install or upgrade, and only when the whole string is the default exact-pinned
|
|
553
|
+
* launcher or the current override prefix, followed by the exact hook suffix.
|
|
554
|
+
* `@latest`, an unpinned launcher, extra flags, another executable, and another
|
|
555
|
+
* package path are all NOT recognized.
|
|
556
|
+
*/
|
|
557
|
+
function isRecognizedBridgeHookCommand(command, launcher) {
|
|
558
|
+
if (launcher.source === "override" && command === `${launcher.prefix} ${SESSION_START_HOOK_SUFFIX}`) {
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
const lead = `${BRIDGE_DEFAULT_LAUNCHER}@`;
|
|
562
|
+
const tail = ` ${SESSION_START_HOOK_SUFFIX}`;
|
|
563
|
+
if (!command.startsWith(lead) || !command.endsWith(tail))
|
|
564
|
+
return false;
|
|
565
|
+
return isPlainReleaseVersion(command.slice(lead.length, command.length - tail.length));
|
|
566
|
+
}
|
|
567
|
+
/** A command that looks like a Bridge hook without being recognized as one. */
|
|
568
|
+
function looksLikeBridgeHook(command) {
|
|
569
|
+
return command.includes("check-update") && command.includes("claude-session-start");
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Compute the settings that leave exactly one correctly configured Bridge hook,
|
|
573
|
+
* in memory. Owned commands are the manifest-recorded ones plus, on install or
|
|
574
|
+
* upgrade only, narrowly recognized unrecorded ones. Every owned command is
|
|
575
|
+
* removed before the desired one is merged, so a changed pin is replaced rather
|
|
576
|
+
* than duplicated; commands that are not owned are never touched.
|
|
577
|
+
*/
|
|
578
|
+
function planSessionStartHook(settings, command, recorded, launcher) {
|
|
579
|
+
// A present-but-wrong-typed `hooks` or `SessionStart` is refused, never replaced.
|
|
580
|
+
if (inspectClaudeHookContainers(settings, [SESSION_START_HOOK_EVENT]) === "malformed") {
|
|
581
|
+
return "malformed";
|
|
582
|
+
}
|
|
583
|
+
const present = sessionStartCommands(settings);
|
|
584
|
+
const recordedSet = new Set(recorded);
|
|
585
|
+
const owned = unique(present.filter((c) => recordedSet.has(c) || isRecognizedBridgeHookCommand(c, launcher)));
|
|
586
|
+
const unrecognized = present.filter((c) => !owned.includes(c) && looksLikeBridgeHook(c)).length;
|
|
587
|
+
const others = owned.filter((c) => c !== command);
|
|
588
|
+
if (others.length === 0 && present.includes(command) && isHookConfiguredCorrectly(settings, command)) {
|
|
589
|
+
return { settings, changed: false, command, replaced: 0, unrecognized };
|
|
590
|
+
}
|
|
591
|
+
let working = settings;
|
|
592
|
+
for (const stale of owned) {
|
|
593
|
+
working = removeClaudeSettingsCommandHook(working, SESSION_START_HOOK_EVENT, stale);
|
|
594
|
+
}
|
|
595
|
+
const rebuilt = mergeClaudeSettingsWithCommandHookChecked(working, command, [SESSION_START_HOOK_EVENT], {
|
|
596
|
+
eventMatchers: { [SESSION_START_HOOK_EVENT]: SESSION_START_HOOK_MATCHER },
|
|
597
|
+
eventTimeouts: { [SESSION_START_HOOK_EVENT]: SESSION_START_HOOK_TIMEOUT_SECONDS },
|
|
598
|
+
});
|
|
599
|
+
if (!rebuilt.ok)
|
|
600
|
+
return "malformed";
|
|
601
|
+
return { settings: rebuilt.settings, changed: true, command, replaced: others.length, unrecognized };
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Write the project `permissions.allow` catalog for the resolved launcher, and
|
|
605
|
+
* optionally the pinned SessionStart hook.
|
|
426
606
|
*
|
|
427
607
|
* Only missing strings are appended; every other entry and key is kept. Strings
|
|
428
608
|
* Bridge recorded earlier that are no longer desired (for example after the
|
|
429
609
|
* launcher changed) are removed. The manifest gains exactly the strings this run
|
|
430
610
|
* inserted. Nothing is written when nothing changes.
|
|
611
|
+
*
|
|
612
|
+
* Permissions and the hook are applied to ONE settings snapshot and written once,
|
|
613
|
+
* and the manifest is written only after the settings write succeeds. A scope that
|
|
614
|
+
* cannot be changed safely (malformed structure, failed write) keeps its prior
|
|
615
|
+
* ownership record, and a settings success followed by a manifest failure is
|
|
616
|
+
* reported as a `partial` hook outcome rather than as full provisioning.
|
|
431
617
|
*/
|
|
432
618
|
export async function applyProjectPermissions(params, deps) {
|
|
433
619
|
const launcher = resolvePermissionLauncher(params.env);
|
|
620
|
+
const request = params.sessionStartHook;
|
|
434
621
|
if (!launcher.ok) {
|
|
435
|
-
return {
|
|
622
|
+
return {
|
|
623
|
+
launcher,
|
|
624
|
+
project: { status: "launcher-refused", skipped: [] },
|
|
625
|
+
manifestRecorded: false,
|
|
626
|
+
...(request ? { hook: { status: "refused", reason: launcher.reason } } : {}),
|
|
627
|
+
};
|
|
436
628
|
}
|
|
437
629
|
const desired = buildProjectPermissionRules(launcher.prefix, await resolveSupervisorServerNames(params.cwd, deps));
|
|
438
630
|
const settingsPath = projectSettingsPath(params.cwd);
|
|
439
631
|
const manifestRead = await readBridgePermissionsManifest(params.cwd, deps);
|
|
440
632
|
const manifest = manifestRead.status === "valid" ? manifestRead.manifest : emptyManifest();
|
|
441
633
|
const read = await readSettingsFile(settingsPath, deps);
|
|
442
|
-
|
|
443
|
-
const allow = read.status === "malformed" ? null : projectAllowList(current);
|
|
444
|
-
if (allow === null) {
|
|
634
|
+
if (read.status === "malformed") {
|
|
445
635
|
return {
|
|
446
636
|
launcher,
|
|
447
637
|
project: { status: "malformed", path: settingsPath, skipped: desired },
|
|
448
638
|
manifestRecorded: false,
|
|
639
|
+
...(request ? { hook: invalidPinOr(request, { status: "malformed", path: settingsPath }) } : {}),
|
|
449
640
|
};
|
|
450
641
|
}
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
642
|
+
const current = read.status === "valid" ? read.settings : {};
|
|
643
|
+
let working = current;
|
|
644
|
+
// ---- Permission rules ----
|
|
645
|
+
const allow = projectAllowList(current);
|
|
646
|
+
let project;
|
|
647
|
+
let permissionsChanged = false;
|
|
648
|
+
let permissionsApplied = false;
|
|
649
|
+
let inserted = [];
|
|
650
|
+
let ownedStill = manifest.project;
|
|
651
|
+
if (allow === null) {
|
|
652
|
+
project = { status: "malformed", path: settingsPath, skipped: desired };
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
const desiredSet = new Set(desired);
|
|
656
|
+
const stale = manifest.project.filter((rule) => !desiredSet.has(rule));
|
|
657
|
+
const staleSet = new Set(stale);
|
|
658
|
+
const removedStale = allow.filter((rule) => staleSet.has(rule));
|
|
659
|
+
const kept = allow.filter((rule) => !staleSet.has(rule));
|
|
660
|
+
const keptSet = new Set(kept);
|
|
661
|
+
inserted = desired.filter((rule) => !keptSet.has(rule));
|
|
662
|
+
const alreadyPresent = desired.filter((rule) => keptSet.has(rule));
|
|
663
|
+
if (inserted.length > 0 || removedStale.length > 0) {
|
|
664
|
+
working = withProjectAllowList(working, [...kept, ...inserted]);
|
|
665
|
+
permissionsChanged = true;
|
|
666
|
+
}
|
|
667
|
+
ownedStill = manifest.project.filter((rule) => desiredSet.has(rule));
|
|
668
|
+
permissionsApplied = true;
|
|
669
|
+
project = {
|
|
670
|
+
status: "unchanged",
|
|
671
|
+
path: settingsPath,
|
|
672
|
+
inserted,
|
|
673
|
+
alreadyPresent,
|
|
674
|
+
removedStale,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// ---- SessionStart hook ----
|
|
678
|
+
let hookPlan = null;
|
|
679
|
+
let hook;
|
|
680
|
+
if (request) {
|
|
681
|
+
if (!isPlainReleaseVersion(request.pinnedVersion)) {
|
|
682
|
+
hook = { status: "skipped", reason: "invalid-pin" };
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
const command = buildSessionStartHookCommand(launcher, request.pinnedVersion);
|
|
686
|
+
const planned = planSessionStartHook(working, command, manifest.hooks?.SessionStart ?? [], launcher);
|
|
687
|
+
if (planned === "malformed") {
|
|
688
|
+
hook = { status: "malformed", path: settingsPath };
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
hookPlan = planned;
|
|
692
|
+
working = planned.settings;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const hookChanged = hookPlan?.changed === true;
|
|
697
|
+
// ---- One settings write ----
|
|
698
|
+
if (permissionsChanged || hookChanged) {
|
|
699
|
+
const ok = await writeSettings(settingsPath, working, deps);
|
|
463
700
|
if (!ok) {
|
|
464
|
-
return {
|
|
701
|
+
return {
|
|
702
|
+
launcher,
|
|
703
|
+
project: permissionsApplied
|
|
704
|
+
? { status: "io-error", path: settingsPath, skipped: inserted }
|
|
705
|
+
: project,
|
|
706
|
+
manifestRecorded: false,
|
|
707
|
+
...(request ? { hook: hookPlan ? { status: "io-error", path: settingsPath } : hook } : {}),
|
|
708
|
+
};
|
|
465
709
|
}
|
|
466
|
-
status
|
|
710
|
+
if (project.status === "unchanged" && permissionsChanged)
|
|
711
|
+
project = { ...project, status: "written" };
|
|
467
712
|
}
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
launcher
|
|
472
|
-
project
|
|
473
|
-
|
|
474
|
-
|
|
713
|
+
// ---- One manifest write, after the settings are durable ----
|
|
714
|
+
const nextManifest = { ...manifest };
|
|
715
|
+
if (permissionsApplied) {
|
|
716
|
+
nextManifest.launcher = launcher.prefix;
|
|
717
|
+
nextManifest.project = unique([...ownedStill, ...inserted]);
|
|
718
|
+
}
|
|
719
|
+
if (hookPlan)
|
|
720
|
+
nextManifest.hooks = { SessionStart: [hookPlan.command] };
|
|
721
|
+
const needsManifest = permissionsApplied || hookPlan !== null;
|
|
722
|
+
const manifestRecorded = needsManifest ? await writeManifestIfChanged(params.cwd, nextManifest, deps) : false;
|
|
723
|
+
if (hookPlan) {
|
|
724
|
+
const base = {
|
|
725
|
+
path: settingsPath,
|
|
726
|
+
command: hookPlan.command,
|
|
727
|
+
replaced: hookPlan.replaced,
|
|
728
|
+
unrecognized: hookPlan.unrecognized,
|
|
729
|
+
};
|
|
730
|
+
hook = manifestRecorded
|
|
731
|
+
? { status: hookChanged ? "written" : "unchanged", ...base }
|
|
732
|
+
: { status: "partial", ...base };
|
|
733
|
+
}
|
|
734
|
+
return { launcher, project, manifestRecorded, ...(request && hook ? { hook } : {}) };
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Plain-language report lines for a hook outcome, shared by install and upgrade so
|
|
738
|
+
* the two cannot drift. `lines` are progress; `warnings` need attention but never
|
|
739
|
+
* change a primary exit code. Nothing here echoes a similar-looking user command:
|
|
740
|
+
* unrecognized candidates are counted only.
|
|
741
|
+
*/
|
|
742
|
+
export function describeSessionStartHookOutcome(hook) {
|
|
743
|
+
const lines = [];
|
|
744
|
+
const warnings = [];
|
|
745
|
+
switch (hook.status) {
|
|
746
|
+
case "written":
|
|
747
|
+
case "unchanged":
|
|
748
|
+
case "partial": {
|
|
749
|
+
if (hook.status === "written") {
|
|
750
|
+
lines.push(`SessionStart upgrade-notice hook (Claude Code only, per developer) in ${hook.path}:`);
|
|
751
|
+
lines.push(` wrote ${hook.command}`);
|
|
752
|
+
if (hook.replaced > 0) {
|
|
753
|
+
lines.push(` replaced ${hook.replaced} earlier Bridge hook command(s) with this one`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
else if (hook.status === "unchanged") {
|
|
757
|
+
lines.push(`SessionStart upgrade-notice hook already present in ${hook.path}.`);
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
warnings.push(`the SessionStart upgrade-notice hook was written to ${hook.path}, but ` +
|
|
761
|
+
`${BRIDGE_PERMISSIONS_MANIFEST_RELPATH} could not be updated; doctor and removal may not see it`);
|
|
762
|
+
}
|
|
763
|
+
if (hook.unrecognized > 0) {
|
|
764
|
+
warnings.push(`${hook.unrecognized} existing SessionStart command(s) resemble Bridge's hook but were not ` +
|
|
765
|
+
"recognized as Bridge's, so they were left in place");
|
|
766
|
+
}
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
case "skipped":
|
|
770
|
+
warnings.push("the SessionStart upgrade-notice hook was not written: no valid pinned Bridge version could be " +
|
|
771
|
+
"determined for this project");
|
|
772
|
+
break;
|
|
773
|
+
case "refused":
|
|
774
|
+
warnings.push(`the SessionStart upgrade-notice hook was not written: ${hook.reason}`);
|
|
775
|
+
break;
|
|
776
|
+
case "malformed":
|
|
777
|
+
warnings.push(`${hook.path} has a malformed hooks structure and was left unchanged; ` +
|
|
778
|
+
"the SessionStart upgrade-notice hook was NOT written");
|
|
779
|
+
break;
|
|
780
|
+
case "io-error":
|
|
781
|
+
warnings.push(`${hook.path} could not be written; the SessionStart upgrade-notice hook was NOT written`);
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
return { lines, warnings };
|
|
785
|
+
}
|
|
786
|
+
/** A malformed settings file cannot take the hook, but an invalid pin is reported first. */
|
|
787
|
+
function invalidPinOr(request, fallback) {
|
|
788
|
+
return isPlainReleaseVersion(request.pinnedVersion)
|
|
789
|
+
? fallback
|
|
790
|
+
: { status: "skipped", reason: "invalid-pin" };
|
|
475
791
|
}
|
|
476
792
|
/**
|
|
477
793
|
* Write Bridge's `autoMode.allow` and `autoMode.environment` prose into the
|
|
@@ -622,32 +938,96 @@ export async function removeBridgePermissions(params, deps) {
|
|
|
622
938
|
const manifestRead = await readBridgePermissionsManifest(params.cwd, deps);
|
|
623
939
|
const manifest = manifestRead.status === "valid" ? manifestRead.manifest : emptyManifest();
|
|
624
940
|
const next = { ...manifest };
|
|
625
|
-
// Project scope
|
|
941
|
+
// Project scope: the permission rules and the SessionStart hook share ONE
|
|
942
|
+
// settings snapshot and ONE write, so neither removal can overwrite the other.
|
|
626
943
|
const projectPath = projectSettingsPath(params.cwd);
|
|
944
|
+
const recordedHooks = manifest.hooks?.SessionStart ?? [];
|
|
627
945
|
let project;
|
|
628
|
-
|
|
946
|
+
let hook;
|
|
947
|
+
if (manifest.project.length === 0 && recordedHooks.length === 0) {
|
|
629
948
|
project = { status: "nothing-recorded", path: projectPath, removed: [] };
|
|
949
|
+
hook = { status: "nothing-recorded", path: projectPath, removed: [] };
|
|
630
950
|
}
|
|
631
951
|
else {
|
|
632
952
|
const read = await readSettingsFile(projectPath, deps);
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
953
|
+
if (read.status === "malformed") {
|
|
954
|
+
project =
|
|
955
|
+
manifest.project.length === 0
|
|
956
|
+
? { status: "nothing-recorded", path: projectPath, removed: [] }
|
|
957
|
+
: { status: "malformed", path: projectPath, unremovable: manifest.project };
|
|
958
|
+
hook =
|
|
959
|
+
recordedHooks.length === 0
|
|
960
|
+
? { status: "nothing-recorded", path: projectPath, removed: [] }
|
|
961
|
+
: { status: "malformed", path: projectPath, unremovable: recordedHooks };
|
|
636
962
|
}
|
|
637
963
|
else {
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
964
|
+
const settings = read.status === "valid" ? read.settings : {};
|
|
965
|
+
let working = settings;
|
|
966
|
+
// Rules.
|
|
967
|
+
const allow = projectAllowList(settings);
|
|
968
|
+
let rulesRemoved = [];
|
|
969
|
+
let rulesMalformed = false;
|
|
970
|
+
if (manifest.project.length > 0) {
|
|
971
|
+
if (allow === null) {
|
|
972
|
+
rulesMalformed = true;
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
const owned = new Set(manifest.project);
|
|
976
|
+
rulesRemoved = allow.filter((rule) => owned.has(rule));
|
|
977
|
+
if (rulesRemoved.length > 0) {
|
|
978
|
+
working = withProjectAllowList(working, allow.filter((rule) => !owned.has(rule)));
|
|
979
|
+
}
|
|
980
|
+
}
|
|
643
981
|
}
|
|
644
|
-
|
|
645
|
-
|
|
982
|
+
// Hook: only the exact recorded commands, never a recognizable lookalike.
|
|
983
|
+
let hooksRemoved = [];
|
|
984
|
+
let hooksMalformed = false;
|
|
985
|
+
if (recordedHooks.length > 0) {
|
|
986
|
+
if (inspectClaudeHookContainers(working, [SESSION_START_HOOK_EVENT]) === "malformed") {
|
|
987
|
+
hooksMalformed = true;
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
const present = new Set(sessionStartCommands(working));
|
|
991
|
+
for (const command of recordedHooks) {
|
|
992
|
+
if (present.has(command))
|
|
993
|
+
hooksRemoved.push(command);
|
|
994
|
+
working = removeClaudeSettingsCommandHook(working, SESSION_START_HOOK_EVENT, command);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
// One write, only when something changed and the file exists.
|
|
999
|
+
let writeOk = true;
|
|
1000
|
+
if ((rulesRemoved.length > 0 || hooksRemoved.length > 0) && read.status === "valid") {
|
|
1001
|
+
writeOk = await writeSettings(projectPath, working, deps);
|
|
1002
|
+
}
|
|
1003
|
+
if (manifest.project.length === 0) {
|
|
1004
|
+
project = { status: "nothing-recorded", path: projectPath, removed: [] };
|
|
1005
|
+
}
|
|
1006
|
+
else if (rulesMalformed) {
|
|
1007
|
+
project = { status: "malformed", path: projectPath, unremovable: manifest.project };
|
|
1008
|
+
}
|
|
1009
|
+
else if (writeOk || rulesRemoved.length === 0) {
|
|
1010
|
+
project = { status: "removed", path: projectPath, removed: rulesRemoved };
|
|
646
1011
|
next.project = [];
|
|
647
1012
|
next.launcher = null;
|
|
648
1013
|
}
|
|
649
1014
|
else {
|
|
650
|
-
project = { status: "io-error", path: projectPath, unremovable:
|
|
1015
|
+
project = { status: "io-error", path: projectPath, unremovable: rulesRemoved };
|
|
1016
|
+
}
|
|
1017
|
+
if (recordedHooks.length === 0) {
|
|
1018
|
+
hook = { status: "nothing-recorded", path: projectPath, removed: [] };
|
|
1019
|
+
}
|
|
1020
|
+
else if (hooksMalformed) {
|
|
1021
|
+
hook = { status: "malformed", path: projectPath, unremovable: recordedHooks };
|
|
1022
|
+
}
|
|
1023
|
+
else if (writeOk || hooksRemoved.length === 0) {
|
|
1024
|
+
// Ownership clears only once the settings write succeeded (or there was
|
|
1025
|
+
// nothing left in the file to remove).
|
|
1026
|
+
hook = { status: "removed", path: projectPath, removed: hooksRemoved };
|
|
1027
|
+
next.hooks = undefined;
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
hook = { status: "io-error", path: projectPath, unremovable: hooksRemoved };
|
|
651
1031
|
}
|
|
652
1032
|
}
|
|
653
1033
|
}
|
|
@@ -693,23 +1073,40 @@ export async function removeBridgePermissions(params, deps) {
|
|
|
693
1073
|
}
|
|
694
1074
|
const changed = next.project.length !== manifest.project.length ||
|
|
695
1075
|
next.userAllow.length !== manifest.userAllow.length ||
|
|
696
|
-
next.userEnvironment.length !== manifest.userEnvironment.length
|
|
1076
|
+
next.userEnvironment.length !== manifest.userEnvironment.length ||
|
|
1077
|
+
(next.hooks?.SessionStart.length ?? 0) !== (manifest.hooks?.SessionStart.length ?? 0);
|
|
697
1078
|
const manifestUpdated = manifestRead.status === "valid" && changed ? await writeManifestIfChanged(params.cwd, next, deps) : true;
|
|
698
|
-
return { project, user, manifestUpdated };
|
|
1079
|
+
return { project, hook, user, manifestUpdated };
|
|
699
1080
|
}
|
|
700
|
-
/**
|
|
701
|
-
|
|
702
|
-
|
|
1081
|
+
/**
|
|
1082
|
+
* The consent text for the user-settings write (BAPI-1448). The CLI's interactive
|
|
1083
|
+
* mode prints everything up to the last line and reads the answer at that line;
|
|
1084
|
+
* `/drive-epic-run` step 3a reproduces the same text byte for byte, and a test pins
|
|
1085
|
+
* the two together.
|
|
1086
|
+
*/
|
|
1087
|
+
export const USER_AUTO_MODE_CONSENT_PROMPT = "Epic runs work best in Claude Code's auto mode, where Claude acts without asking you each\n" +
|
|
1088
|
+
"time. Auto mode has a safety check that can pause routine run steps, like creating the epic's\n" +
|
|
1089
|
+
"branches or updating the run's own records, because it doesn't know they belong to the run.\n" +
|
|
1090
|
+
"\n" +
|
|
1091
|
+
"Bridge can add a short description of the run's sandbox to ~/.claude/settings.json so the\n" +
|
|
1092
|
+
"check recognizes this work. It describes:\n" +
|
|
1093
|
+
" • the run's own branches (epic/<KEY> and each child ticket's branch) and their worktrees\n" +
|
|
1094
|
+
" • the Bridge server\n" +
|
|
1095
|
+
" • any sandbox accounts your project names\n" +
|
|
1096
|
+
"It grants no other access, and you can remove it any time with --remove-bridge-permissions.\n" +
|
|
1097
|
+
"\n" +
|
|
1098
|
+
"Add it? (y/N) ";
|
|
703
1099
|
function logList(log, items) {
|
|
704
1100
|
for (const item of items)
|
|
705
1101
|
log(` ${item}`);
|
|
706
1102
|
}
|
|
707
1103
|
/**
|
|
708
|
-
* The installer's permission step. Writes the project rules
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
1104
|
+
* The installer's permission step. Writes the project rules and the pinned
|
|
1105
|
+
* SessionStart hook to `.claude/settings.local.json` and reports them. It never
|
|
1106
|
+
* reads, writes, or mentions the user's `~/.claude/settings.json`: that write is
|
|
1107
|
+
* the `bridge-permissions` subcommand's job (BAPI-1448). Every rule written is
|
|
1108
|
+
* listed, with the launcher prefix it covers. A malformed file is reported on
|
|
1109
|
+
* stderr and skipped; the rest of the install continues.
|
|
713
1110
|
*/
|
|
714
1111
|
export async function runBridgePermissionsInstallStep(deps) {
|
|
715
1112
|
const { log, errorLog } = deps;
|
|
@@ -725,7 +1122,7 @@ export async function runBridgePermissionsInstallStep(deps) {
|
|
|
725
1122
|
}
|
|
726
1123
|
}
|
|
727
1124
|
}
|
|
728
|
-
const project = await applyProjectPermissions({ cwd: deps.cwd, env: deps.env }, deps);
|
|
1125
|
+
const project = await applyProjectPermissions({ cwd: deps.cwd, env: deps.env, sessionStartHook: deps.sessionStartHook }, deps);
|
|
729
1126
|
if (!project.launcher.ok) {
|
|
730
1127
|
errorLog(` warning: project permission rules were not written: ${project.launcher.reason}.`);
|
|
731
1128
|
errorLog(` Unset ${BRIDGE_LAUNCHER_ENV}, or set it to a plain command prefix, then re-run install.`);
|
|
@@ -757,41 +1154,16 @@ export async function runBridgePermissionsInstallStep(deps) {
|
|
|
757
1154
|
errorLog(` warning: could not update ${BRIDGE_PERMISSIONS_MANIFEST_RELPATH}; doctor and removal may not see these rules.`);
|
|
758
1155
|
}
|
|
759
1156
|
}
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
return { project, userConsent: "non-interactive", user: null };
|
|
769
|
-
}
|
|
770
|
-
const answer = (await deps.promptLine(USER_AUTO_MODE_CONSENT_PROMPT)).trim().toLowerCase();
|
|
771
|
-
if (answer !== "y" && answer !== "yes") {
|
|
772
|
-
log(` ${userPath} was not changed. These entries were not added:`);
|
|
773
|
-
logList(log, proposed);
|
|
774
|
-
return { project, userConsent: "declined", user: null };
|
|
775
|
-
}
|
|
776
|
-
const user = await applyUserAutoModeEntries({ cwd: deps.cwd, homeDir: deps.homeDir }, deps);
|
|
777
|
-
const scope = user.user;
|
|
778
|
-
if (scope.status === "written" || scope.status === "unchanged") {
|
|
779
|
-
log(` ${scope.path}:`);
|
|
780
|
-
if (scope.inserted.length > 0) {
|
|
781
|
-
log(` Wrote ${scope.inserted.length} autoMode entr${scope.inserted.length === 1 ? "y" : "ies"} ("$defaults" kept):`);
|
|
782
|
-
logList(log, scope.inserted);
|
|
783
|
-
}
|
|
784
|
-
if (scope.alreadyPresent.length > 0) {
|
|
785
|
-
log(` Already present (${scope.alreadyPresent.length}):`);
|
|
786
|
-
logList(log, scope.alreadyPresent);
|
|
787
|
-
}
|
|
1157
|
+
// The hook is reported on its own: a refused launcher or a malformed hooks
|
|
1158
|
+
// structure never hides, or is hidden by, the permission-rule result above.
|
|
1159
|
+
if (project.hook) {
|
|
1160
|
+
const described = describeSessionStartHookOutcome(project.hook);
|
|
1161
|
+
for (const line of described.lines)
|
|
1162
|
+
log(` ${line}`);
|
|
1163
|
+
for (const warning of described.warnings)
|
|
1164
|
+
errorLog(` warning: ${warning}.`);
|
|
788
1165
|
}
|
|
789
|
-
|
|
790
|
-
errorLog(` warning: ${scope.path} ${scope.status === "malformed" ? "is not valid JSON settings and was left unchanged" : "could not be written"}; ` +
|
|
791
|
-
"these autoMode entries were NOT written:");
|
|
792
|
-
logList(errorLog, scope.skipped);
|
|
793
|
-
}
|
|
794
|
-
return { project, userConsent: "granted", user };
|
|
1166
|
+
return { project };
|
|
795
1167
|
}
|
|
796
1168
|
/**
|
|
797
1169
|
* The removal flag's step. Removes only Bridge-recorded strings and reports what
|
|
@@ -804,9 +1176,14 @@ export async function runBridgePermissionsRemoval(deps) {
|
|
|
804
1176
|
let failed = false;
|
|
805
1177
|
for (const [label, scope] of [
|
|
806
1178
|
["project permissions.allow", result.project],
|
|
1179
|
+
["project SessionStart hook", result.hook],
|
|
807
1180
|
["user autoMode", result.user],
|
|
808
1181
|
]) {
|
|
809
1182
|
if (scope.status === "nothing-recorded") {
|
|
1183
|
+
// The hook is a newer scope: say nothing when it never owned anything, so an
|
|
1184
|
+
// install that never wrote one keeps its original removal output.
|
|
1185
|
+
if (label === "project SessionStart hook")
|
|
1186
|
+
continue;
|
|
810
1187
|
log(`${label}: nothing Bridge wrote is recorded (${scope.path}).`);
|
|
811
1188
|
}
|
|
812
1189
|
else if (scope.status === "removed") {
|
|
@@ -827,3 +1204,178 @@ export async function runBridgePermissionsRemoval(deps) {
|
|
|
827
1204
|
log("Entries you added yourself were left in place.");
|
|
828
1205
|
return failed ? 1 : 0;
|
|
829
1206
|
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Parse the subcommand's argv. Only no flags, `--check --json` (either order),
|
|
1209
|
+
* `--user-consented`, and `--help` are accepted; every other form — an unknown flag,
|
|
1210
|
+
* a repeated flag, `--check` without `--json`, `--check` with `--user-consented` — is
|
|
1211
|
+
* refused here, before any file is read.
|
|
1212
|
+
*/
|
|
1213
|
+
export function parseBridgePermissionsArgv(argv) {
|
|
1214
|
+
if (argv.length === 0)
|
|
1215
|
+
return { ok: true, mode: "interactive" };
|
|
1216
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h"))
|
|
1217
|
+
return { ok: true, mode: "help" };
|
|
1218
|
+
if (argv.length === 1 && argv[0] === "--user-consented")
|
|
1219
|
+
return { ok: true, mode: "user-consented" };
|
|
1220
|
+
if (argv.length === 2 && argv.includes("--check") && argv.includes("--json")) {
|
|
1221
|
+
return { ok: true, mode: "check-json" };
|
|
1222
|
+
}
|
|
1223
|
+
return { ok: false, error: `Unsupported arguments: ${BRIDGE_PERMISSIONS_USAGE_HINT}` };
|
|
1224
|
+
}
|
|
1225
|
+
const BRIDGE_PERMISSIONS_USAGE_HINT = "use no flags, `--check --json`, `--user-consented`, or `--help`.";
|
|
1226
|
+
/** Static help text for the subcommand. */
|
|
1227
|
+
export function getBridgePermissionsUsage() {
|
|
1228
|
+
return [
|
|
1229
|
+
"Usage:",
|
|
1230
|
+
` npx -y ${MCP_PACKAGE_NAME} bridge-permissions Ask, then add Bridge's auto-mode`,
|
|
1231
|
+
" sandbox description to ~/.claude/settings.json",
|
|
1232
|
+
` npx -y ${MCP_PACKAGE_NAME} bridge-permissions --check --json Read-only: print the user-scope state`,
|
|
1233
|
+
" (present, missing, or malformed) as JSON",
|
|
1234
|
+
` npx -y ${MCP_PACKAGE_NAME} bridge-permissions --user-consented`,
|
|
1235
|
+
" Write without asking. Reserved for /drive-epic-run,",
|
|
1236
|
+
" after you said yes to its question in the current",
|
|
1237
|
+
" session. Do not script it.",
|
|
1238
|
+
"",
|
|
1239
|
+
"Run from the project root: the ownership manifest is .bridge/bridge-permissions.json in the",
|
|
1240
|
+
"current directory. Without a terminal, no-flag mode writes nothing and prints what it would add.",
|
|
1241
|
+
`Remove the entries later with: npx -y ${MCP_PACKAGE_NAME} install --remove-bridge-permissions`,
|
|
1242
|
+
].join("\n");
|
|
1243
|
+
}
|
|
1244
|
+
/** Report one user-settings write: wording shared by the interactive and consented modes. */
|
|
1245
|
+
function reportUserAutoModeWrite(result, log, errorLog) {
|
|
1246
|
+
const scope = result.user;
|
|
1247
|
+
let exitCode = 0;
|
|
1248
|
+
if (scope.status === "written" || scope.status === "unchanged") {
|
|
1249
|
+
log(`${scope.path}:`);
|
|
1250
|
+
if (scope.inserted.length > 0) {
|
|
1251
|
+
log(`Wrote ${scope.inserted.length} autoMode entr${scope.inserted.length === 1 ? "y" : "ies"} ("$defaults" kept):`);
|
|
1252
|
+
logList(log, scope.inserted);
|
|
1253
|
+
}
|
|
1254
|
+
if (scope.alreadyPresent.length > 0) {
|
|
1255
|
+
log(`Already present (${scope.alreadyPresent.length}):`);
|
|
1256
|
+
logList(log, scope.alreadyPresent);
|
|
1257
|
+
}
|
|
1258
|
+
if (!result.manifestRecorded) {
|
|
1259
|
+
errorLog(`warning: could not update ${BRIDGE_PERMISSIONS_MANIFEST_RELPATH}; doctor and removal may not see these entries.`);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
else if (scope.status === "malformed" || scope.status === "io-error") {
|
|
1263
|
+
exitCode = 1;
|
|
1264
|
+
errorLog(`warning: ${scope.path} ${scope.status === "malformed" ? "is not valid JSON settings and was left unchanged" : "could not be written"}; ` +
|
|
1265
|
+
"these autoMode entries were NOT written:");
|
|
1266
|
+
logList(errorLog, scope.skipped);
|
|
1267
|
+
}
|
|
1268
|
+
return exitCode;
|
|
1269
|
+
}
|
|
1270
|
+
/** Split the consent text into its printed body and its final prompt line. */
|
|
1271
|
+
function splitConsentPrompt() {
|
|
1272
|
+
const at = USER_AUTO_MODE_CONSENT_PROMPT.lastIndexOf("\n");
|
|
1273
|
+
return {
|
|
1274
|
+
body: USER_AUTO_MODE_CONSENT_PROMPT.slice(0, at),
|
|
1275
|
+
prompt: USER_AUTO_MODE_CONSENT_PROMPT.slice(at + 1),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Run the subcommand and return the process exit code. It never throws: an
|
|
1280
|
+
* unexpected failure is reported (without detail) on stderr and returned as `1`.
|
|
1281
|
+
*
|
|
1282
|
+
* - no flags, interactive: print the consent text, read one answer; only `y` or
|
|
1283
|
+
* `yes` writes. Blank, EOF, and a read failure write nothing and exit 0.
|
|
1284
|
+
* - no flags, no terminal: write nothing, print the entries it would add, exit 0.
|
|
1285
|
+
* - `--check --json`: read-only; one JSON object for the user scope, exit 0 even
|
|
1286
|
+
* when the scope is `malformed`.
|
|
1287
|
+
* - `--user-consented`: write without asking (reserved for `/drive-epic-run`).
|
|
1288
|
+
*
|
|
1289
|
+
* Only the user-settings step runs here; project rules and hooks are never touched.
|
|
1290
|
+
*/
|
|
1291
|
+
export async function runBridgePermissionsCli(argv, deps) {
|
|
1292
|
+
const parsed = parseBridgePermissionsArgv(argv);
|
|
1293
|
+
if (!parsed.ok) {
|
|
1294
|
+
deps.errorLog(parsed.error);
|
|
1295
|
+
deps.errorLog(getBridgePermissionsUsage());
|
|
1296
|
+
return 1;
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
if (parsed.mode === "help") {
|
|
1300
|
+
deps.log(getBridgePermissionsUsage());
|
|
1301
|
+
return 0;
|
|
1302
|
+
}
|
|
1303
|
+
if (parsed.mode === "check-json") {
|
|
1304
|
+
const inspection = await inspectBridgePermissions({ cwd: deps.cwd, homeDir: deps.homeDir, env: deps.env }, deps);
|
|
1305
|
+
const user = inspection.user;
|
|
1306
|
+
const payload = {
|
|
1307
|
+
state: user.state,
|
|
1308
|
+
path: user.path,
|
|
1309
|
+
};
|
|
1310
|
+
if ("missing" in user)
|
|
1311
|
+
payload.missing = user.missing;
|
|
1312
|
+
deps.log(JSON.stringify(payload));
|
|
1313
|
+
return 0;
|
|
1314
|
+
}
|
|
1315
|
+
if (parsed.mode === "user-consented") {
|
|
1316
|
+
const result = await applyUserAutoModeEntries({ cwd: deps.cwd, homeDir: deps.homeDir }, deps);
|
|
1317
|
+
return reportUserAutoModeWrite(result, deps.log, deps.errorLog);
|
|
1318
|
+
}
|
|
1319
|
+
// No flags.
|
|
1320
|
+
if (!deps.isTTY || !deps.promptLine) {
|
|
1321
|
+
const entries = buildUserAutoModeEntries();
|
|
1322
|
+
deps.log(`${userSettingsPath(deps.homeDir)} was not changed (no interactive terminal). It would add, to autoMode.allow and autoMode.environment:`);
|
|
1323
|
+
logList(deps.log, [...entries.allow, ...entries.environment]);
|
|
1324
|
+
deps.log("Run this command in a terminal to be asked, or let /drive-epic-run ask in your session.");
|
|
1325
|
+
return 0;
|
|
1326
|
+
}
|
|
1327
|
+
const { body, prompt } = splitConsentPrompt();
|
|
1328
|
+
deps.log(body);
|
|
1329
|
+
let answer = "";
|
|
1330
|
+
try {
|
|
1331
|
+
answer = String((await deps.promptLine(prompt)) ?? "").trim().toLowerCase();
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
answer = "";
|
|
1335
|
+
}
|
|
1336
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1337
|
+
deps.log(`${userSettingsPath(deps.homeDir)} was not changed.`);
|
|
1338
|
+
return 0;
|
|
1339
|
+
}
|
|
1340
|
+
const result = await applyUserAutoModeEntries({ cwd: deps.cwd, homeDir: deps.homeDir }, deps);
|
|
1341
|
+
return reportUserAutoModeWrite(result, deps.log, deps.errorLog);
|
|
1342
|
+
}
|
|
1343
|
+
catch {
|
|
1344
|
+
deps.errorLog("bridge-permissions: unexpected error; nothing further was changed.");
|
|
1345
|
+
return 1;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/** One-line prompt on stderr; EOF resolves as an empty answer so it can never hang. */
|
|
1349
|
+
function promptLineOnStderr(promptText) {
|
|
1350
|
+
return new Promise((resolve) => {
|
|
1351
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
1352
|
+
let answered = false;
|
|
1353
|
+
rl.on("close", () => {
|
|
1354
|
+
if (!answered)
|
|
1355
|
+
resolve("");
|
|
1356
|
+
});
|
|
1357
|
+
rl.question(promptText, (answer) => {
|
|
1358
|
+
answered = true;
|
|
1359
|
+
rl.close();
|
|
1360
|
+
resolve(answer);
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
/** Production entry for the `bridge-permissions` subcommand: real filesystem, real home. */
|
|
1365
|
+
export async function runBridgePermissionsCliFromProcess(argv) {
|
|
1366
|
+
const isTTY = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
1367
|
+
return runBridgePermissionsCli(argv, {
|
|
1368
|
+
// The manifest belongs to the project in the current directory, so there is no
|
|
1369
|
+
// Git-root lookup: help says "run from the project root".
|
|
1370
|
+
cwd: process.cwd(),
|
|
1371
|
+
homeDir: os.homedir(),
|
|
1372
|
+
env: process.env,
|
|
1373
|
+
isTTY,
|
|
1374
|
+
promptLine: isTTY ? promptLineOnStderr : undefined,
|
|
1375
|
+
readFile: (filePath) => fs.readFile(filePath, "utf-8"),
|
|
1376
|
+
writeFile: (filePath, data) => fs.writeFile(filePath, data, { encoding: "utf-8" }),
|
|
1377
|
+
mkdir: (dirPath, options) => fs.mkdir(dirPath, options),
|
|
1378
|
+
log: (message) => console.log(message),
|
|
1379
|
+
errorLog: (message) => console.error(message),
|
|
1380
|
+
});
|
|
1381
|
+
}
|