@basein/runner 0.2.12 → 0.3.1

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 (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2787 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -0,0 +1,76 @@
1
+ /**
2
+ * config/mcp-approval — would Claude Code start a `.mcp.json` server without
3
+ * asking the person first?
4
+ *
5
+ * WHY THIS EXISTS. `bir install` routes a `.mcp.json` server through the proxy
6
+ * by writing a wrapped copy into this project's LOCAL scope (bir.ts,
7
+ * installClaudeCode), so the team's file never carries this machine's paths.
8
+ * But Claude Code treats the two scopes differently: it asks before it starts
9
+ * a server from `.mcp.json` ("New MCP server found in this project"), and it
10
+ * never asks about a local-scope one — that is the person's own config. A local
11
+ * copy of a server they rejected, or were never asked about (a freshly cloned
12
+ * repository), would start silently. The runner must never change what the
13
+ * person is asked to approve, so a `.mcp.json` server is overridden only when
14
+ * Claude Code itself would already start it.
15
+ *
16
+ * The rules are Claude Code's own (read out of 2.1.280, `getProjectMcpServerStatus`):
17
+ *
18
+ * - REJECTED when the name is in `disabledMcpjsonServers` of any settings
19
+ * source — what "Continue without using this MCP server" writes;
20
+ * - APPROVED when it is in `enabledMcpjsonServers`, or
21
+ * `enableAllProjectMcpServers` is true, in a source Claude Code honours for
22
+ * that decision: every source once the folder is trusted; before that only
23
+ * the person's own (user settings, the managed policy, and a
24
+ * `settings.local.json` git does not track — a committed one could approve
25
+ * anything for whoever clones the repository);
26
+ * - PENDING otherwise: Claude Code would ask.
27
+ *
28
+ * Names match the way Claude Code matches them (anything outside
29
+ * `[A-Za-z0-9_-]` compares as `_`). The legacy copies of those three fields
30
+ * under `projects[<key>]` in `~/.claude.json` count too: Claude Code migrates
31
+ * them into `settings.local.json` on its next start, so they are still the
32
+ * person's answer. A settings file that cannot be read makes the answer
33
+ * "pending" — when in doubt, the server is left exactly as it is.
34
+ */
35
+ export type McpjsonApproval = "approved" | "rejected" | "pending";
36
+ export interface ApprovalVerdict {
37
+ state: McpjsonApproval;
38
+ /** Why, in words, for the install and doctor lines. */
39
+ why: string;
40
+ }
41
+ /**
42
+ * The person's own Claude Code settings (`~/.claude/settings.json`).
43
+ *
44
+ * `BIR_CLAUDE_SETTINGS` names it outright. A sandboxed `BIR_CLAUDE_JSON`
45
+ * (tests, a second profile) moves it beside that file, the way `~/.claude.json`
46
+ * and `~/.claude/` sit side by side in a home directory; `CLAUDE_CONFIG_DIR`
47
+ * moves it the way Claude Code itself does.
48
+ */
49
+ export declare function claudeUserSettingsPath(env?: NodeJS.ProcessEnv): string;
50
+ /**
51
+ * Where an organisation's managed settings live (Claude Code's own paths).
52
+ * `BIR_CLAUDE_MANAGED_DIR` overrides it — tests point it at an empty sandbox.
53
+ */
54
+ export declare function claudeManagedSettingsDir(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
55
+ /** A server name the way Claude Code compares them. */
56
+ export declare function mcpNameKey(name: string): string;
57
+ /**
58
+ * A reader for this directory's `.mcp.json` approvals: the files are read once,
59
+ * then each name is answered from them. `claude` is `~/.claude.json` already
60
+ * parsed, when the caller has it.
61
+ */
62
+ export declare function mcpjsonApprovals(cwd: string, claude?: {
63
+ projects?: Record<string, unknown>;
64
+ }): (name: string) => ApprovalVerdict;
65
+ /**
66
+ * A reason the managed policy forbids wrapping ANY server, or undefined.
67
+ *
68
+ * An organisation's `allowedMcpServers` / `deniedMcpServers` can match a
69
+ * server by its command or URL. A wrapped entry's command is the runner's, so
70
+ * wrapping would get an allowed server blocked (broken) or a denied one past
71
+ * the denylist. `allowManagedMcpServersOnly` means only the organisation's own
72
+ * servers run at all. In any of those cases nothing is wrapped: the policy is
73
+ * not the runner's to reinterpret.
74
+ */
75
+ export declare function managedMcpPolicyBlocker(): string | undefined;
76
+ //# sourceMappingURL=mcp-approval.d.ts.map
@@ -0,0 +1,210 @@
1
+ /**
2
+ * config/mcp-approval — would Claude Code start a `.mcp.json` server without
3
+ * asking the person first?
4
+ *
5
+ * WHY THIS EXISTS. `bir install` routes a `.mcp.json` server through the proxy
6
+ * by writing a wrapped copy into this project's LOCAL scope (bir.ts,
7
+ * installClaudeCode), so the team's file never carries this machine's paths.
8
+ * But Claude Code treats the two scopes differently: it asks before it starts
9
+ * a server from `.mcp.json` ("New MCP server found in this project"), and it
10
+ * never asks about a local-scope one — that is the person's own config. A local
11
+ * copy of a server they rejected, or were never asked about (a freshly cloned
12
+ * repository), would start silently. The runner must never change what the
13
+ * person is asked to approve, so a `.mcp.json` server is overridden only when
14
+ * Claude Code itself would already start it.
15
+ *
16
+ * The rules are Claude Code's own (read out of 2.1.280, `getProjectMcpServerStatus`):
17
+ *
18
+ * - REJECTED when the name is in `disabledMcpjsonServers` of any settings
19
+ * source — what "Continue without using this MCP server" writes;
20
+ * - APPROVED when it is in `enabledMcpjsonServers`, or
21
+ * `enableAllProjectMcpServers` is true, in a source Claude Code honours for
22
+ * that decision: every source once the folder is trusted; before that only
23
+ * the person's own (user settings, the managed policy, and a
24
+ * `settings.local.json` git does not track — a committed one could approve
25
+ * anything for whoever clones the repository);
26
+ * - PENDING otherwise: Claude Code would ask.
27
+ *
28
+ * Names match the way Claude Code matches them (anything outside
29
+ * `[A-Za-z0-9_-]` compares as `_`). The legacy copies of those three fields
30
+ * under `projects[<key>]` in `~/.claude.json` count too: Claude Code migrates
31
+ * them into `settings.local.json` on its next start, so they are still the
32
+ * person's answer. A settings file that cannot be read makes the answer
33
+ * "pending" — when in doubt, the server is left exactly as it is.
34
+ */
35
+ import { spawnSync } from "node:child_process";
36
+ import { readdirSync } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { dirname, join } from "node:path";
39
+ import { claudeConfigHome, claudeJsonPath, parseJsonFile, projectKeysFor } from "./resolve.js";
40
+ /**
41
+ * The person's own Claude Code settings (`~/.claude/settings.json`).
42
+ *
43
+ * `BIR_CLAUDE_SETTINGS` names it outright. A sandboxed `BIR_CLAUDE_JSON`
44
+ * (tests, a second profile) moves it beside that file, the way `~/.claude.json`
45
+ * and `~/.claude/` sit side by side in a home directory; `CLAUDE_CONFIG_DIR`
46
+ * moves it the way Claude Code itself does.
47
+ */
48
+ export function claudeUserSettingsPath(env = process.env) {
49
+ if (env.BIR_CLAUDE_SETTINGS)
50
+ return env.BIR_CLAUDE_SETTINGS;
51
+ if (env.BIR_CLAUDE_JSON)
52
+ return join(dirname(env.BIR_CLAUDE_JSON), ".claude", "settings.json");
53
+ return join(claudeConfigHome(env) ?? join(homedir(), ".claude"), "settings.json");
54
+ }
55
+ /**
56
+ * Where an organisation's managed settings live (Claude Code's own paths).
57
+ * `BIR_CLAUDE_MANAGED_DIR` overrides it — tests point it at an empty sandbox.
58
+ */
59
+ export function claudeManagedSettingsDir(env = process.env, platform = process.platform) {
60
+ if (env.BIR_CLAUDE_MANAGED_DIR !== undefined)
61
+ return env.BIR_CLAUDE_MANAGED_DIR;
62
+ if (platform === "darwin")
63
+ return "/Library/Application Support/ClaudeCode";
64
+ if (platform === "win32")
65
+ return "C:\\Program Files\\ClaudeCode";
66
+ return "/etc/claude-code";
67
+ }
68
+ /** `managed-settings.json` and its drop-in directory, parsed; unreadable ones reported. */
69
+ function readManaged() {
70
+ const dir = claudeManagedSettingsDir();
71
+ const settings = [];
72
+ const unreadable = [];
73
+ if (!dir)
74
+ return { settings, unreadable };
75
+ const files = [join(dir, "managed-settings.json")];
76
+ try {
77
+ const dropIn = join(dir, "managed-settings.d");
78
+ for (const f of readdirSync(dropIn).filter((n) => n.endsWith(".json")).sort())
79
+ files.push(join(dropIn, f));
80
+ }
81
+ catch {
82
+ /* no drop-in directory */
83
+ }
84
+ for (const file of files) {
85
+ try {
86
+ const parsed = parseJsonFile(file);
87
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
88
+ settings.push(parsed);
89
+ }
90
+ catch {
91
+ unreadable.push(file);
92
+ }
93
+ }
94
+ return { settings, unreadable };
95
+ }
96
+ /** A server name the way Claude Code compares them. */
97
+ export function mcpNameKey(name) {
98
+ return name.startsWith("plugin:") ? name : name.replace(/[^a-zA-Z0-9_-]/g, "_");
99
+ }
100
+ function lists(fields, key, name) {
101
+ const value = fields?.[key];
102
+ if (!Array.isArray(value))
103
+ return false;
104
+ const want = mcpNameKey(name);
105
+ return value.some((n) => typeof n === "string" && mcpNameKey(n) === want);
106
+ }
107
+ /**
108
+ * True when git says `.claude/settings.local.json` is NOT tracked here. Claude
109
+ * Code ignores the approvals in a tracked one (and outside a repository, or
110
+ * when git cannot answer) until the folder is trusted; so does this. Asked
111
+ * at most once per reader, and only when that file is the one thing approving —
112
+ * never on a hook's path unless a caller puts it there.
113
+ */
114
+ function localSettingsUntracked(cwd) {
115
+ try {
116
+ // Forward slashes: a pathspec git reads the same way on every platform.
117
+ const r = spawnSync("git", ["ls-files", "--error-unmatch", "--", ".claude/settings.local.json"], {
118
+ cwd,
119
+ timeout: 2_000,
120
+ windowsHide: true,
121
+ stdio: "ignore",
122
+ });
123
+ return r.status === 1;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * A reader for this directory's `.mcp.json` approvals: the files are read once,
131
+ * then each name is answered from them. `claude` is `~/.claude.json` already
132
+ * parsed, when the caller has it.
133
+ */
134
+ export function mcpjsonApprovals(cwd, claude) {
135
+ const unreadable = [];
136
+ const read = (file) => {
137
+ try {
138
+ return parseJsonFile(file);
139
+ }
140
+ catch {
141
+ unreadable.push(file);
142
+ return undefined;
143
+ }
144
+ };
145
+ let parsedClaude = claude;
146
+ if (!parsedClaude) {
147
+ try {
148
+ parsedClaude = parseJsonFile(claudeJsonPath()) ?? {};
149
+ }
150
+ catch {
151
+ unreadable.push(claudeJsonPath());
152
+ parsedClaude = {};
153
+ }
154
+ }
155
+ const user = read(claudeUserSettingsPath());
156
+ const project = read(join(cwd, ".claude", "settings.json"));
157
+ const local = read(join(cwd, ".claude", "settings.local.json"));
158
+ const managed = readManaged();
159
+ unreadable.push(...managed.unreadable);
160
+ const projects = (parsedClaude.projects ?? {});
161
+ const legacy = projectKeysFor(projects, cwd).map((key) => projects[key]).filter((p) => p !== undefined);
162
+ const trusted = legacy.some((p) => p?.hasTrustDialogAccepted === true);
163
+ let untracked;
164
+ const localHonoured = () => trusted || (untracked ??= localSettingsUntracked(cwd));
165
+ return (name) => {
166
+ if (unreadable.length > 0) {
167
+ return { state: "pending", why: `whether Claude Code approved it could not be read (${unreadable[0]} is not plain JSON)` };
168
+ }
169
+ const everySource = [user, project, local, ...managed.settings, ...legacy];
170
+ if (everySource.some((s) => lists(s, "disabledMcpjsonServers", name))) {
171
+ return { state: "rejected", why: "you told Claude Code not to use it (disabledMcpjsonServers)" };
172
+ }
173
+ const approves = (s) => s?.enableAllProjectMcpServers === true || lists(s, "enabledMcpjsonServers", name);
174
+ // git is asked only when settings.local.json is the one thing approving.
175
+ const approved = trusted
176
+ ? everySource.some(approves)
177
+ : [user, ...managed.settings, ...legacy].some(approves) || (approves(local) && localHonoured());
178
+ if (approved)
179
+ return { state: "approved", why: "approved in Claude Code" };
180
+ return { state: "pending", why: "Claude Code has not been told to use it yet (it asks first)" };
181
+ };
182
+ }
183
+ /**
184
+ * A reason the managed policy forbids wrapping ANY server, or undefined.
185
+ *
186
+ * An organisation's `allowedMcpServers` / `deniedMcpServers` can match a
187
+ * server by its command or URL. A wrapped entry's command is the runner's, so
188
+ * wrapping would get an allowed server blocked (broken) or a denied one past
189
+ * the denylist. `allowManagedMcpServersOnly` means only the organisation's own
190
+ * servers run at all. In any of those cases nothing is wrapped: the policy is
191
+ * not the runner's to reinterpret.
192
+ */
193
+ export function managedMcpPolicyBlocker() {
194
+ const { settings } = readManaged();
195
+ for (const s of settings) {
196
+ if (s.allowManagedMcpServersOnly === true) {
197
+ return "your organisation's managed settings allow only its own MCP servers";
198
+ }
199
+ for (const key of ["allowedMcpServers", "deniedMcpServers"]) {
200
+ const list = s[key];
201
+ if (!Array.isArray(list))
202
+ continue;
203
+ if (list.some((e) => e && typeof e === "object" && ("serverCommand" in e || "serverUrl" in e))) {
204
+ return `your organisation's managed settings match MCP servers by command or URL (${key}), which wrapping would change`;
205
+ }
206
+ }
207
+ }
208
+ return undefined;
209
+ }
210
+ //# sourceMappingURL=mcp-approval.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * node-path — which `node` the generated entries and the SessionStart hook name.
3
+ *
4
+ * Every wrapped MCP server, the scenario server and the self-starting hook
5
+ * spawn an interpreter by ABSOLUTE path. If that path disappears, every one of
6
+ * them fails to start — the user's own MCP servers included, because the proxy
7
+ * is now what the host spawns. So the path written must outlive the things
8
+ * that routinely move a Node:
9
+ *
10
+ * - Homebrew: `process.execPath` is already the versioned keg
11
+ * (`/opt/homebrew/Cellar/node/24.1.0/bin/node`), and the next
12
+ * `brew upgrade` deletes it. Homebrew keeps a stable symlink to the current
13
+ * keg at `<prefix>/opt/<formula>/bin/node`; that is the one to name.
14
+ * - Windows: `process.execPath` is the path Node was launched through, which
15
+ * for nvm-windows (`C:\nvm4w\nodejs`) and Scoop (`…\apps\nodejs\current`)
16
+ * is the stable link that follows the active version, while its realpath is
17
+ * the versioned directory the next switch abandons. Keep it — except for
18
+ * fnm's per-shell `fnm_multishells` directory, which is gone with the shell.
19
+ * - Version managers with no stable link (nvm, fnm, Volta, asdf, mise,
20
+ * nodenv, n): nothing better exists, so the path is kept and the install
21
+ * says, once, what will break it. The install scripts avoid all of this by
22
+ * installing a private Node under ~/.baseinstrunner/node.
23
+ *
24
+ * Proxies, the scenario server and the hook all use the SAME choice, so a
25
+ * re-run compares like with like and does not re-write entries for nothing.
26
+ */
27
+ export interface NodeChoice {
28
+ /** The interpreter path to write. */
29
+ path: string;
30
+ /** Set when that path belongs to something that moves it; printed once. */
31
+ warning?: string;
32
+ }
33
+ export interface NodeChoiceDeps {
34
+ execPath?: string;
35
+ platform?: NodeJS.Platform;
36
+ realpath?: (p: string) => string;
37
+ exists?: (p: string) => boolean;
38
+ }
39
+ /** A version manager (or per-version package directory) that owns this path, if any. */
40
+ export declare function versionManagerOf(path: string): string | undefined;
41
+ export declare function chooseNodePath(input?: NodeChoiceDeps): NodeChoice;
42
+ //# sourceMappingURL=node-path.d.ts.map
@@ -0,0 +1,102 @@
1
+ /**
2
+ * node-path — which `node` the generated entries and the SessionStart hook name.
3
+ *
4
+ * Every wrapped MCP server, the scenario server and the self-starting hook
5
+ * spawn an interpreter by ABSOLUTE path. If that path disappears, every one of
6
+ * them fails to start — the user's own MCP servers included, because the proxy
7
+ * is now what the host spawns. So the path written must outlive the things
8
+ * that routinely move a Node:
9
+ *
10
+ * - Homebrew: `process.execPath` is already the versioned keg
11
+ * (`/opt/homebrew/Cellar/node/24.1.0/bin/node`), and the next
12
+ * `brew upgrade` deletes it. Homebrew keeps a stable symlink to the current
13
+ * keg at `<prefix>/opt/<formula>/bin/node`; that is the one to name.
14
+ * - Windows: `process.execPath` is the path Node was launched through, which
15
+ * for nvm-windows (`C:\nvm4w\nodejs`) and Scoop (`…\apps\nodejs\current`)
16
+ * is the stable link that follows the active version, while its realpath is
17
+ * the versioned directory the next switch abandons. Keep it — except for
18
+ * fnm's per-shell `fnm_multishells` directory, which is gone with the shell.
19
+ * - Version managers with no stable link (nvm, fnm, Volta, asdf, mise,
20
+ * nodenv, n): nothing better exists, so the path is kept and the install
21
+ * says, once, what will break it. The install scripts avoid all of this by
22
+ * installing a private Node under ~/.baseinstrunner/node.
23
+ *
24
+ * Proxies, the scenario server and the hook all use the SAME choice, so a
25
+ * re-run compares like with like and does not re-write entries for nothing.
26
+ */
27
+ import { existsSync, realpathSync } from "node:fs";
28
+ /** A version manager (or per-version package directory) that owns this path, if any. */
29
+ export function versionManagerOf(path) {
30
+ const p = path.replace(/\\/g, "/");
31
+ const rules = [
32
+ [/fnm_multishells/i, "fnm"],
33
+ [/\/(\.?fnm)\//i, "fnm"],
34
+ [/\/\.nvm\/|\/nvm\/versions\//i, "nvm"],
35
+ [/\/(AppData\/Roaming\/)?nvm\/v\d/i, "nvm-windows"],
36
+ [/\/\.?volta\//i, "Volta"],
37
+ [/\/\.?nodenv\//i, "nodenv"],
38
+ [/\/\.?asdf\//i, "asdf"],
39
+ [/\/\.?mise\//i, "mise"],
40
+ [/\/n\/versions\//i, "n"],
41
+ [/\/scoop\/apps\/nodejs[^/]*\/(?!current\/)[^/]+\//i, "Scoop"],
42
+ [/\/Cellar\//, "Homebrew"],
43
+ [/^\/snap\/node\//, "snap"],
44
+ ];
45
+ for (const [re, name] of rules)
46
+ if (re.test(p))
47
+ return name;
48
+ return undefined;
49
+ }
50
+ /**
51
+ * Homebrew's stable `opt` link for a Cellar path, when there is one and it
52
+ * points at this very binary (not at a newer keg the running process is not).
53
+ */
54
+ function homebrewOptPath(real, deps) {
55
+ const m = /^(.*)\/Cellar\/(node(?:@\d+)?)\/[^/]+\/bin\/node$/.exec(real);
56
+ if (!m)
57
+ return undefined;
58
+ const opt = `${m[1]}/opt/${m[2]}/bin/node`;
59
+ try {
60
+ if (deps.exists(opt) && deps.realpath(opt) === real)
61
+ return opt;
62
+ }
63
+ catch {
64
+ /* no link: fall through */
65
+ }
66
+ return undefined;
67
+ }
68
+ export function chooseNodePath(input = {}) {
69
+ const deps = {
70
+ execPath: input.execPath ?? process.execPath,
71
+ platform: input.platform ?? process.platform,
72
+ realpath: input.realpath ?? ((p) => realpathSync(p)),
73
+ exists: input.exists ?? existsSync,
74
+ };
75
+ const real = (() => {
76
+ try {
77
+ return deps.realpath(deps.execPath);
78
+ }
79
+ catch {
80
+ return deps.execPath;
81
+ }
82
+ })();
83
+ let path;
84
+ if (deps.platform === "win32") {
85
+ // The launch path, unless it is a per-shell directory that dies with the shell.
86
+ path = /fnm_multishells/i.test(deps.execPath) ? real : deps.execPath;
87
+ }
88
+ else {
89
+ path = homebrewOptPath(real, deps) ?? real;
90
+ }
91
+ const manager = versionManagerOf(path);
92
+ if (!manager)
93
+ return { path };
94
+ return {
95
+ path,
96
+ warning: `node is ${path}, a ${manager}-managed version: when ${manager} removes or moves it, ` +
97
+ "the wrapped MCP servers and the SessionStart hook stop starting. Run `bir setup` here " +
98
+ "again after switching Node, or install with the install line, which keeps a private Node " +
99
+ "under ~/.baseinstrunner/node",
100
+ };
101
+ }
102
+ //# sourceMappingURL=node-path.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * config/overrides — is each local-scope copy `bir install` wrote still a copy
3
+ * of the entry it overrides?
4
+ *
5
+ * An override (bir.ts, installClaudeCode) is a FROZEN copy of a `.mcp.json` or
6
+ * user-scope server, wrapped, in this project's local scope — which outranks
7
+ * both. When the team changes `.mcp.json` (a new version, a new host) or the
8
+ * person rotates a token in the user scope, Claude Code keeps starting the old
9
+ * copy here, and the server is broken because of the runner. Nothing about the
10
+ * copy itself says so; the record's {@link InstalledRecord.sourceSha} does.
11
+ *
12
+ * {@link checkOverrides} is read-only (doctor, status). {@link resyncOverrides}
13
+ * repairs: a changed source is copied again with the same runner, a removed or
14
+ * no-longer-approved one loses its copy. `bir setup` does the same through a
15
+ * full install; this is the cheap path for a process that runs every session.
16
+ */
17
+ import { type InstalledRecord, type InstalledSidecar } from "./generate.js";
18
+ import { type McpServerConfig } from "./resolve.js";
19
+ /** The server name a sidecar key names, given the record's own cwd (either key shape, generate.ts `recordKey`). */
20
+ export declare function recordServerName(key: string, record: InstalledRecord): string;
21
+ export type OverrideState = "current" | "changed" | "removed" | "unapproved";
22
+ export interface OverrideCheck {
23
+ name: string;
24
+ /** Its record's key in installed.json. */
25
+ key: string;
26
+ record: InstalledRecord;
27
+ state: OverrideState;
28
+ /** Where the entry it should copy comes from now. */
29
+ from: "project" | "user";
30
+ /** That entry, unwrapped — absent when it was removed. */
31
+ source?: McpServerConfig;
32
+ /** The local copy as it is now. */
33
+ local: McpServerConfig;
34
+ /** The `~/.claude.json` project keys holding the copy. */
35
+ keys: string[];
36
+ /** What is wrong, in words (for everything but "current"). */
37
+ why?: string;
38
+ }
39
+ /**
40
+ * Every override this directory's install wrote, and whether it still copies
41
+ * what it overrides. A copy the person has since removed or replaced is not
42
+ * listed: it is theirs now. Files that cannot be read yield no verdicts —
43
+ * Claude Code cannot read them either, and that is reported elsewhere.
44
+ */
45
+ export declare function checkOverrides(cwd: string, sidecar?: InstalledSidecar): OverrideCheck[];
46
+ /**
47
+ * Bring every stale override of `cwd` back in line with what it copies, in
48
+ * `~/.claude.json` and installed.json. A changed source is wrapped again with
49
+ * the runner the old copy named (same node, flags, script, control URL); a
50
+ * removed, rejected or never-approved one, a remote Claude Code signs in
51
+ * itself, or anything under a managed policy that matches by command, loses
52
+ * its copy, which lets the entry under it run as Claude Code would run it.
53
+ *
54
+ * Touches only wrapped local entries this directory's records name, and never
55
+ * throws: a failure leaves things as they were, for doctor to report.
56
+ */
57
+ export declare function resyncOverrides(cwd: string): Array<{
58
+ name: string;
59
+ action: "rewrapped" | "removed";
60
+ why: string;
61
+ }>;
62
+ //# sourceMappingURL=overrides.d.ts.map
@@ -0,0 +1,136 @@
1
+ /**
2
+ * config/overrides — is each local-scope copy `bir install` wrote still a copy
3
+ * of the entry it overrides?
4
+ *
5
+ * An override (bir.ts, installClaudeCode) is a FROZEN copy of a `.mcp.json` or
6
+ * user-scope server, wrapped, in this project's local scope — which outranks
7
+ * both. When the team changes `.mcp.json` (a new version, a new host) or the
8
+ * person rotates a token in the user scope, Claude Code keeps starting the old
9
+ * copy here, and the server is broken because of the runner. Nothing about the
10
+ * copy itself says so; the record's {@link InstalledRecord.sourceSha} does.
11
+ *
12
+ * {@link checkOverrides} is read-only (doctor, status). {@link resyncOverrides}
13
+ * repairs: a changed source is copied again with the same runner, a removed or
14
+ * no-longer-approved one loses its copy. `bir setup` does the same through a
15
+ * full install; this is the cheap path for a process that runs every session.
16
+ */
17
+ import { join } from "node:path";
18
+ import { normalizePath } from "../control/paths.js";
19
+ import { setLocalEntries } from "./adapters/claude-code.js";
20
+ import { configSha, isWrapped, readSidecar, REDACTED_KEY_PREFIX, remoteWrapBlocker, sameServerConfig, unwrapEntry, wrapEntry, wrapOptionsOf, writeSidecar, } from "./generate.js";
21
+ import { managedMcpPolicyBlocker, mcpjsonApprovals } from "./mcp-approval.js";
22
+ import { claudeJsonPath, isRemote, parseJsonFile, projectKeysFor } from "./resolve.js";
23
+ /** The server name a sidecar key names, given the record's own cwd (either key shape, generate.ts `recordKey`). */
24
+ export function recordServerName(key, record) {
25
+ const bare = key.startsWith(REDACTED_KEY_PREFIX) ? key.slice(REDACTED_KEY_PREFIX.length) : key;
26
+ const prefix = `${record.cwd}::`;
27
+ return bare.startsWith(prefix) ? bare.slice(prefix.length) : bare.slice(bare.lastIndexOf("::") + 2);
28
+ }
29
+ /**
30
+ * Every override this directory's install wrote, and whether it still copies
31
+ * what it overrides. A copy the person has since removed or replaced is not
32
+ * listed: it is theirs now. Files that cannot be read yield no verdicts —
33
+ * Claude Code cannot read them either, and that is reported elsewhere.
34
+ */
35
+ export function checkOverrides(cwd, sidecar = readSidecar()) {
36
+ const here = normalizePath(cwd);
37
+ const mine = Object.entries(sidecar.servers).filter(([, r]) => r.override && normalizePath(r.cwd) === here);
38
+ if (mine.length === 0)
39
+ return [];
40
+ let claude;
41
+ let mcp;
42
+ try {
43
+ claude = parseJsonFile(claudeJsonPath()) ?? {};
44
+ mcp = parseJsonFile(join(cwd, ".mcp.json"))?.mcpServers ?? {};
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ let approval;
50
+ const unwrapped = (c) => c !== undefined && isWrapped(c) ? unwrapEntry(c) : c;
51
+ const checks = [];
52
+ for (const [key, record] of mine) {
53
+ const name = recordServerName(key, record);
54
+ const keys = (record.keys ?? projectKeysFor(claude.projects, cwd)).filter((k) => claude.projects?.[k]?.mcpServers?.[name] !== undefined);
55
+ const local = keys.map((k) => claude.projects?.[k]?.mcpServers?.[name]).find((e) => e !== undefined);
56
+ if (local === undefined || !isWrapped(local))
57
+ continue;
58
+ const P = mcp[name];
59
+ const from = P !== undefined ? "project" : "user";
60
+ const source = unwrapped(P ?? claude.mcpServers?.[name]);
61
+ const was = record.override?.source === "project" ? ".mcp.json" : "your user scope";
62
+ const now = from === "project" ? ".mcp.json" : "your user scope";
63
+ const check = { name, key, record, state: "current", from, source, local, keys };
64
+ if (source === undefined) {
65
+ check.state = "removed";
66
+ check.why = `it was removed from ${was}, but this project still runs the copy`;
67
+ }
68
+ else if (from === "project") {
69
+ // Claude Code asks before it starts a `.mcp.json` server and never about
70
+ // the copy: one it would not start unasked must not have one. `--server`
71
+ // (optedIn) answers "pending" for the person; nothing answers "rejected".
72
+ approval ??= mcpjsonApprovals(cwd, claude);
73
+ const verdict = approval(name);
74
+ if (verdict.state === "rejected" || (verdict.state === "pending" && !record.optedIn)) {
75
+ check.state = "unapproved";
76
+ check.why = verdict.why;
77
+ }
78
+ }
79
+ if (check.state === "current" && source !== undefined) {
80
+ const same = record.sourceSha ? configSha(source) === record.sourceSha : sameServerConfig(unwrapEntry(local), source);
81
+ if (!same) {
82
+ check.state = "changed";
83
+ check.why =
84
+ record.override?.source === "project" && from === "user"
85
+ ? "it is gone from .mcp.json, so your user-scope entry of that name would run instead"
86
+ : `${now} changed since the copy was made, and this project still runs the old one`;
87
+ }
88
+ }
89
+ checks.push(check);
90
+ }
91
+ return checks;
92
+ }
93
+ /**
94
+ * Bring every stale override of `cwd` back in line with what it copies, in
95
+ * `~/.claude.json` and installed.json. A changed source is wrapped again with
96
+ * the runner the old copy named (same node, flags, script, control URL); a
97
+ * removed, rejected or never-approved one, a remote Claude Code signs in
98
+ * itself, or anything under a managed policy that matches by command, loses
99
+ * its copy, which lets the entry under it run as Claude Code would run it.
100
+ *
101
+ * Touches only wrapped local entries this directory's records name, and never
102
+ * throws: a failure leaves things as they were, for doctor to report.
103
+ */
104
+ export function resyncOverrides(cwd) {
105
+ const done = [];
106
+ try {
107
+ const sidecar = readSidecar();
108
+ const stale = checkOverrides(cwd, sidecar).filter((c) => c.state !== "current");
109
+ if (stale.length === 0)
110
+ return done;
111
+ const claudeJson = claudeJsonPath();
112
+ const policy = managedMcpPolicyBlocker();
113
+ const ours = (cur) => cur !== undefined && isWrapped(cur);
114
+ for (const c of stale) {
115
+ const opts = wrapOptionsOf(c.local);
116
+ const blocked = policy ?? (c.source && isRemote(c.source) && !c.record.optedIn ? remoteWrapBlocker(c.source) : undefined);
117
+ if (c.state === "changed" && c.source && opts && !blocked) {
118
+ setLocalEntries(claudeJson, c.keys, c.name, wrapEntry(c.source, { ...opts, serverName: c.name }), ours);
119
+ c.record.sourceSha = configSha(c.source);
120
+ c.record.override = { source: c.from };
121
+ done.push({ name: c.name, action: "rewrapped", why: c.why ?? "" });
122
+ }
123
+ else {
124
+ setLocalEntries(claudeJson, c.keys, c.name, null, ours);
125
+ delete sidecar.servers[c.key];
126
+ done.push({ name: c.name, action: "removed", why: blocked ?? c.why ?? "" });
127
+ }
128
+ }
129
+ writeSidecar(sidecar);
130
+ }
131
+ catch {
132
+ /* left as it was; `bir doctor` names what is stale */
133
+ }
134
+ return done;
135
+ }
136
+ //# sourceMappingURL=overrides.js.map