@basein/runner 0.1.0

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 (100) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +276 -0
  3. package/dist/auth/client.d.ts +85 -0
  4. package/dist/auth/client.js +284 -0
  5. package/dist/bin/bir-hooks.d.ts +48 -0
  6. package/dist/bin/bir-hooks.js +201 -0
  7. package/dist/bin/bir-proxy.d.ts +45 -0
  8. package/dist/bin/bir-proxy.js +207 -0
  9. package/dist/bin/bir-scenario.d.ts +24 -0
  10. package/dist/bin/bir-scenario.js +177 -0
  11. package/dist/bin/bir.d.ts +21 -0
  12. package/dist/bin/bir.js +876 -0
  13. package/dist/config/adapters/claude-code.d.ts +76 -0
  14. package/dist/config/adapters/claude-code.js +181 -0
  15. package/dist/config/adapters/generic.d.ts +17 -0
  16. package/dist/config/adapters/generic.js +36 -0
  17. package/dist/config/generate.d.ts +127 -0
  18. package/dist/config/generate.js +114 -0
  19. package/dist/config/resolve.d.ts +68 -0
  20. package/dist/config/resolve.js +132 -0
  21. package/dist/control/client.d.ts +56 -0
  22. package/dist/control/client.js +86 -0
  23. package/dist/control/correlation.d.ts +86 -0
  24. package/dist/control/correlation.js +0 -0
  25. package/dist/control/discovery.d.ts +50 -0
  26. package/dist/control/discovery.js +123 -0
  27. package/dist/control/ordering.d.ts +38 -0
  28. package/dist/control/ordering.js +44 -0
  29. package/dist/control/paths.d.ts +32 -0
  30. package/dist/control/paths.js +56 -0
  31. package/dist/control/server.d.ts +272 -0
  32. package/dist/control/server.js +1131 -0
  33. package/dist/control/transcript.d.ts +75 -0
  34. package/dist/control/transcript.js +241 -0
  35. package/dist/index.d.ts +37 -0
  36. package/dist/index.js +32 -0
  37. package/dist/jsonrpc/framing.d.ts +49 -0
  38. package/dist/jsonrpc/framing.js +143 -0
  39. package/dist/jsonrpc/types.d.ts +52 -0
  40. package/dist/jsonrpc/types.js +46 -0
  41. package/dist/proxy/intercept.d.ts +55 -0
  42. package/dist/proxy/intercept.js +147 -0
  43. package/dist/proxy/relay.d.ts +97 -0
  44. package/dist/proxy/relay.js +166 -0
  45. package/dist/proxy/session.d.ts +116 -0
  46. package/dist/proxy/session.js +319 -0
  47. package/dist/record/housekeeping.d.ts +34 -0
  48. package/dist/record/housekeeping.js +39 -0
  49. package/dist/record/queue.d.ts +48 -0
  50. package/dist/record/queue.js +96 -0
  51. package/dist/record/recorder.d.ts +111 -0
  52. package/dist/record/recorder.js +39 -0
  53. package/dist/record/redact.d.ts +37 -0
  54. package/dist/record/redact.js +119 -0
  55. package/dist/record/remote-recorder.d.ts +110 -0
  56. package/dist/record/remote-recorder.js +301 -0
  57. package/dist/record/truncate.d.ts +36 -0
  58. package/dist/record/truncate.js +85 -0
  59. package/dist/replay/bundle.d.ts +36 -0
  60. package/dist/replay/bundle.js +89 -0
  61. package/dist/replay/controller.d.ts +300 -0
  62. package/dist/replay/controller.js +807 -0
  63. package/dist/replay/coverage.d.ts +41 -0
  64. package/dist/replay/coverage.js +56 -0
  65. package/dist/replay/derive.d.ts +58 -0
  66. package/dist/replay/derive.js +166 -0
  67. package/dist/replay/executor.d.ts +78 -0
  68. package/dist/replay/executor.js +233 -0
  69. package/dist/replay/logic.d.ts +31 -0
  70. package/dist/replay/logic.js +50 -0
  71. package/dist/replay/plan.d.ts +181 -0
  72. package/dist/replay/plan.js +397 -0
  73. package/dist/replay/pricing.d.ts +41 -0
  74. package/dist/replay/pricing.js +76 -0
  75. package/dist/replay/source-run.d.ts +50 -0
  76. package/dist/replay/source-run.js +98 -0
  77. package/dist/replay/tool-error.d.ts +22 -0
  78. package/dist/replay/tool-error.js +60 -0
  79. package/dist/replay/types.d.ts +116 -0
  80. package/dist/replay/types.js +35 -0
  81. package/dist/upstream/client.d.ts +78 -0
  82. package/dist/upstream/client.js +114 -0
  83. package/dist/upstream/http-client.d.ts +78 -0
  84. package/dist/upstream/http-client.js +261 -0
  85. package/dist/upstream/lazy-client.d.ts +31 -0
  86. package/dist/upstream/lazy-client.js +53 -0
  87. package/dist/upstream/stdio-client.d.ts +57 -0
  88. package/dist/upstream/stdio-client.js +203 -0
  89. package/dist/util/log.d.ts +27 -0
  90. package/dist/util/log.js +51 -0
  91. package/dist/util/version.d.ts +2 -0
  92. package/dist/util/version.js +40 -0
  93. package/docs/BaseInstRunner.md +621 -0
  94. package/docs/calculatedReplay.md +1185 -0
  95. package/docs/calculatedReplayGuide.md +448 -0
  96. package/docs/installRun.md +413 -0
  97. package/docs/mcpmark.md +752 -0
  98. package/docs/quickstart.md +201 -0
  99. package/docs/t-bench.md +394 -0
  100. package/package.json +56 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * auth client — ported from RRepeat's `src/auth/client.ts` (Phase 4.1).
3
+ *
4
+ * Authenticates the CLI user against the BaseIn auth-service and returns a JWT
5
+ * session. Every recording call carries that access token; the recording's owner
6
+ * is the JWT subject on the server side, which is what makes tenancy work
7
+ * without BaseInstRunner knowing anything about it.
8
+ *
9
+ * Flow:
10
+ * 1. Load the cached session from ~/.baseinstrunner/credentials.json.
11
+ * 2. If the access token is still valid, reuse it.
12
+ * 3. Else if a refresh token is present, POST /auth/refresh (silent).
13
+ * 4. Else prompt for email + password and POST /auth/login.
14
+ * 5. Persist the result (mode 0600) and return it.
15
+ *
16
+ * WHY IT NEVER `process.exit`s: `bir-proxy` runs inside the host's process tree.
17
+ * Failing to authenticate must degrade to not-recording (§10), not kill a server
18
+ * the host is waiting on. Callers get `undefined` and decide.
19
+ *
20
+ * Config:
21
+ * BIR_AUTH_URL base URL of the BaseIn auth-service (required).
22
+ * BIR_AUTH_DISABLE "1" to skip auth entirely (local dev / CI).
23
+ */
24
+ import { createInterface } from "node:readline";
25
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
26
+ import { configDir } from "../control/paths.js";
27
+ import { join } from "node:path";
28
+ import { logLine, errText } from "../util/log.js";
29
+ /**
30
+ * Where the cached session lives.
31
+ *
32
+ * A **function**, not a constant: `configDir()` reads `BIR_HOME`, and freezing
33
+ * this at import time meant the directory we created and the file we wrote could
34
+ * disagree the moment anything set that variable after load — which surfaced as
35
+ * an ENOENT deep inside a token refresh.
36
+ */
37
+ export function credentialsPath() {
38
+ return join(configDir(), "credentials.json");
39
+ }
40
+ /** Refresh a little early so a request never races token expiry. */
41
+ const EXPIRY_SKEW_MS = 30_000;
42
+ // Control bytes for the muted password prompt.
43
+ const LF = 0x0a;
44
+ const CR = 0x0d;
45
+ const EOT = 0x04; // Ctrl-D
46
+ const ETX = 0x03; // Ctrl-C
47
+ const DEL = 0x7f;
48
+ const BS = 0x08;
49
+ const SPACE = 0x20;
50
+ /** Load the cached session, or undefined if missing/unreadable/malformed. */
51
+ export function loadCredentials() {
52
+ try {
53
+ if (!existsSync(credentialsPath()))
54
+ return undefined;
55
+ const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
56
+ if (typeof parsed.accessToken === "string" &&
57
+ typeof parsed.refreshToken === "string" &&
58
+ typeof parsed.accessExpiresAt === "number" &&
59
+ parsed.user &&
60
+ typeof parsed.user.id === "string") {
61
+ return parsed;
62
+ }
63
+ }
64
+ catch {
65
+ // A corrupt cache is not fatal — fall through to a fresh login.
66
+ }
67
+ return undefined;
68
+ }
69
+ /** Persist the session with mode 0600. */
70
+ export function saveCredentials(session) {
71
+ const dir = configDir();
72
+ if (!existsSync(dir))
73
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ writeFileSync(credentialsPath(), JSON.stringify(session, null, 2), { mode: 0o600 });
75
+ }
76
+ /** Remove the cached session (`bir logout`). No-op if absent. */
77
+ export function clearCredentials() {
78
+ try {
79
+ if (existsSync(credentialsPath()))
80
+ unlinkSync(credentialsPath());
81
+ }
82
+ catch {
83
+ /* best effort */
84
+ }
85
+ }
86
+ function isAccessValid(session) {
87
+ return session.accessExpiresAt - EXPIRY_SKEW_MS > Date.now();
88
+ }
89
+ function toSession(res) {
90
+ return {
91
+ accessToken: res.accessToken,
92
+ refreshToken: res.refreshToken,
93
+ accessExpiresAt: Date.now() + res.expiresIn * 1000,
94
+ user: res.user,
95
+ };
96
+ }
97
+ export function resolveAuthUrl(opts) {
98
+ return (opts?.authUrl ?? process.env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
99
+ }
100
+ /**
101
+ * Follow a redirect on the base URL *once*, so a bearer token is never sent to
102
+ * a URL that will bounce it.
103
+ *
104
+ * WHY THIS EXISTS. A service behind a TLS-terminating proxy answers plain HTTP
105
+ * with `308 → https://…`. Node's `fetch` follows that, and because `http:` and
106
+ * `https:` are different origins the spec requires it to **strip the
107
+ * `Authorization` header**. The request arrives unauthenticated.
108
+ *
109
+ * The failure is maddening because sign-in works: login sends credentials in the
110
+ * BODY, which a 308 preserves. So `auth.ok` is printed, and then every
111
+ * subsequent call fails `401 {"error":"missing_token"}` — the server saying it
112
+ * saw no token at all, which reads like a bug in the client rather than a
113
+ * redirect. Observed against a Caddy-fronted deployment; the whole session
114
+ * recorded nothing.
115
+ *
116
+ * A same-host upgrade to https is adopted silently — it is what the operator
117
+ * meant. Anything else (a different host, a downgrade to http) is reported and
118
+ * NOT followed: quietly re-sending someone's bearer token to a host they did not
119
+ * configure is worse than failing.
120
+ */
121
+ export async function normalizeAuthUrl(baseUrl, fetchImpl = fetch) {
122
+ const base = baseUrl.replace(/\/+$/, "");
123
+ if (!base)
124
+ return base;
125
+ let res;
126
+ try {
127
+ res = await fetchImpl(`${base}/health`, { method: "GET", redirect: "manual" });
128
+ }
129
+ catch {
130
+ return base; // unreachable is the caller's problem, not ours to diagnose here
131
+ }
132
+ if (res.status < 300 || res.status >= 400)
133
+ return base;
134
+ const location = res.headers.get("location");
135
+ if (!location)
136
+ return base;
137
+ let target;
138
+ let current;
139
+ try {
140
+ current = new URL(base);
141
+ target = new URL(location, base);
142
+ }
143
+ catch {
144
+ return base;
145
+ }
146
+ if (target.host === current.host && target.protocol === "https:" && current.protocol === "http:") {
147
+ const upgraded = `${target.protocol}//${target.host}`;
148
+ logLine("auth.url_upgraded", {
149
+ from: base,
150
+ to: upgraded,
151
+ why: "the service redirects to https, and a cross-origin redirect drops the Authorization header",
152
+ });
153
+ return upgraded;
154
+ }
155
+ logLine("auth.url_redirects", {
156
+ from: base,
157
+ to: `${target.protocol}//${target.host}`,
158
+ why: "not following it — a redirect across origins strips the token, and this one is not a same-host https upgrade. Set BIR_AUTH_URL to the final URL",
159
+ });
160
+ return base;
161
+ }
162
+ function prompt(question) {
163
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
164
+ return new Promise((resolve) => {
165
+ rl.question(question, (answer) => {
166
+ rl.close();
167
+ resolve(answer.trim());
168
+ });
169
+ });
170
+ }
171
+ /** Prompt for a password without echoing keystrokes. */
172
+ function promptPassword(question) {
173
+ return new Promise((resolve) => {
174
+ const stdin = process.stdin;
175
+ process.stderr.write(question);
176
+ const wasRaw = stdin.isRaw ?? false;
177
+ if (stdin.isTTY)
178
+ stdin.setRawMode(true);
179
+ stdin.resume();
180
+ let value = "";
181
+ const finish = () => {
182
+ if (stdin.isTTY)
183
+ stdin.setRawMode(wasRaw);
184
+ stdin.removeListener("data", onData);
185
+ stdin.pause();
186
+ process.stderr.write("\n");
187
+ };
188
+ const onData = (chunk) => {
189
+ for (const byte of chunk) {
190
+ if (byte === LF || byte === CR || byte === EOT) {
191
+ finish();
192
+ resolve(value);
193
+ return;
194
+ }
195
+ if (byte === ETX) {
196
+ finish();
197
+ process.exit(130);
198
+ }
199
+ else if (byte === DEL || byte === BS) {
200
+ value = value.slice(0, -1);
201
+ }
202
+ else if (byte >= SPACE) {
203
+ value += String.fromCharCode(byte);
204
+ }
205
+ }
206
+ };
207
+ stdin.on("data", onData);
208
+ });
209
+ }
210
+ async function postJson(url, body) {
211
+ const res = await fetch(url, {
212
+ method: "POST",
213
+ headers: { "content-type": "application/json" },
214
+ body: JSON.stringify(body),
215
+ });
216
+ if (!res.ok) {
217
+ const text = await res.text().catch(() => "");
218
+ throw new Error(`HTTP ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
219
+ }
220
+ return (await res.json());
221
+ }
222
+ /** Exchange a refresh token for a new session. Undefined on failure. */
223
+ async function tryRefresh(authUrl, refreshToken) {
224
+ try {
225
+ return toSession(await postJson(`${authUrl}/auth/refresh`, { refreshToken }));
226
+ }
227
+ catch {
228
+ return undefined;
229
+ }
230
+ }
231
+ /**
232
+ * Authenticate and return a valid session, or `undefined` when none can be
233
+ * obtained. Callers degrade to not-recording rather than failing the session.
234
+ */
235
+ export async function authenticate(opts) {
236
+ if (process.env.BIR_AUTH_DISABLE === "1") {
237
+ return {
238
+ accessToken: "local-dev",
239
+ refreshToken: "local-dev",
240
+ accessExpiresAt: Number.MAX_SAFE_INTEGER,
241
+ user: { id: "local", email: "local@baseinstrunner.dev", name: "Local Dev" },
242
+ };
243
+ }
244
+ const authUrl = resolveAuthUrl(opts);
245
+ if (!authUrl) {
246
+ logLine("auth.unconfigured", {
247
+ why: "BIR_AUTH_URL is not set — nothing will be recorded",
248
+ });
249
+ return undefined;
250
+ }
251
+ const cached = loadCredentials();
252
+ if (cached && isAccessValid(cached))
253
+ return cached;
254
+ if (cached?.refreshToken) {
255
+ const refreshed = await tryRefresh(authUrl, cached.refreshToken);
256
+ if (refreshed) {
257
+ saveCredentials(refreshed);
258
+ return refreshed;
259
+ }
260
+ }
261
+ if (opts?.nonInteractive || !process.stdin.isTTY) {
262
+ logLine("auth.no_session", {
263
+ why: "no valid session and no terminal to log in on — run `bir login`",
264
+ });
265
+ return undefined;
266
+ }
267
+ process.stderr.write(`[bir] Sign in to ${authUrl}\n`);
268
+ const email = await prompt("Email: ");
269
+ const password = await promptPassword("Password: ");
270
+ if (!email || !password) {
271
+ logLine("auth.aborted", { why: "email and password are required" });
272
+ return undefined;
273
+ }
274
+ try {
275
+ const session = toSession(await postJson(`${authUrl}/auth/login`, { email, password }));
276
+ saveCredentials(session);
277
+ return session;
278
+ }
279
+ catch (err) {
280
+ logLine("auth.login_failed", { error: errText(err) });
281
+ return undefined;
282
+ }
283
+ }
284
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bir-hooks — the Claude Code hook receiver and the control server (Tier 1).
4
+ *
5
+ * Run it in the project you will launch `claude` from, in its own terminal:
6
+ *
7
+ * bir-hooks # start; Ctrl-C to stop
8
+ * bir-hooks 2>&1 | tee -a ~/.baseinstrunner/audit.log
9
+ *
10
+ * It does three things, and they must share a process because they share the run:
11
+ * 1. receives Claude Code's HTTP hook events (`bir install` wires them up);
12
+ * 2. accepts step reports from every `bir-proxy` that finds it;
13
+ * 3. owns run identity, the monotonic `stepIndex`, and the recorder chain (D5).
14
+ *
15
+ * Environment:
16
+ * BIR_AUTH_URL BaseIn auth-service (required to record anything)
17
+ * BIR_CONTROL_PORT preferred port (default 53411; falls back if busy)
18
+ * BIR_CORRELATION_DECISION `allow` (default) or `ask` — see below
19
+ * BIR_NO_CORRELATION=1 never inject a call id; join on fingerprints
20
+ * BIR_VERBOSE=1 per-step detail lines
21
+ *
22
+ * BIR_REPLAY=1 run a calculated scenario on a similar-meaning
23
+ * match (docs/calculatedReplay.md). OFF by default,
24
+ * and read §13.2 before turning it on: a replayed
25
+ * step is auto-approved, and a directly executed one
26
+ * never reaches the permission system at all.
27
+ * BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
28
+ * execution. Unset means every wrapped server
29
+ * BIR_MIN_STEER_SIMILARITY minimum match similarity to replay (default 0.92)
30
+ * ANTHROPIC_API_KEY enables parameter derivation. Without it, replay
31
+ * uses the scenario's recorded sample values — free,
32
+ * and often still correct
33
+ * BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
34
+ * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
35
+ * BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
36
+ * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (default 120000)
37
+ * BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
38
+ *
39
+ * ABOUT `BIR_CORRELATION_DECISION`. Correlating a wrapped MCP call means
40
+ * answering `PreToolUse` with `permissionDecision: "allow"` plus an extra
41
+ * argument (Phase 6). That is what the design specifies — and it also
42
+ * **auto-approves the call**, so a tool the user would have been asked about is
43
+ * no longer asked about. Set `BIR_CORRELATION_DECISION=ask` to keep the prompt;
44
+ * hosts that ignore `updatedInput` on a non-allow decision then fall back to
45
+ * fingerprint matching, which is lossy under identical concurrent calls.
46
+ */
47
+ export {};
48
+ //# sourceMappingURL=bir-hooks.d.ts.map
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bir-hooks — the Claude Code hook receiver and the control server (Tier 1).
4
+ *
5
+ * Run it in the project you will launch `claude` from, in its own terminal:
6
+ *
7
+ * bir-hooks # start; Ctrl-C to stop
8
+ * bir-hooks 2>&1 | tee -a ~/.baseinstrunner/audit.log
9
+ *
10
+ * It does three things, and they must share a process because they share the run:
11
+ * 1. receives Claude Code's HTTP hook events (`bir install` wires them up);
12
+ * 2. accepts step reports from every `bir-proxy` that finds it;
13
+ * 3. owns run identity, the monotonic `stepIndex`, and the recorder chain (D5).
14
+ *
15
+ * Environment:
16
+ * BIR_AUTH_URL BaseIn auth-service (required to record anything)
17
+ * BIR_CONTROL_PORT preferred port (default 53411; falls back if busy)
18
+ * BIR_CORRELATION_DECISION `allow` (default) or `ask` — see below
19
+ * BIR_NO_CORRELATION=1 never inject a call id; join on fingerprints
20
+ * BIR_VERBOSE=1 per-step detail lines
21
+ *
22
+ * BIR_REPLAY=1 run a calculated scenario on a similar-meaning
23
+ * match (docs/calculatedReplay.md). OFF by default,
24
+ * and read §13.2 before turning it on: a replayed
25
+ * step is auto-approved, and a directly executed one
26
+ * never reaches the permission system at all.
27
+ * BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
28
+ * execution. Unset means every wrapped server
29
+ * BIR_MIN_STEER_SIMILARITY minimum match similarity to replay (default 0.92)
30
+ * ANTHROPIC_API_KEY enables parameter derivation. Without it, replay
31
+ * uses the scenario's recorded sample values — free,
32
+ * and often still correct
33
+ * BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
34
+ * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
35
+ * BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
36
+ * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (default 120000)
37
+ * BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
38
+ *
39
+ * ABOUT `BIR_CORRELATION_DECISION`. Correlating a wrapped MCP call means
40
+ * answering `PreToolUse` with `permissionDecision: "allow"` plus an extra
41
+ * argument (Phase 6). That is what the design specifies — and it also
42
+ * **auto-approves the call**, so a tool the user would have been asked about is
43
+ * no longer asked about. Set `BIR_CORRELATION_DECISION=ask` to keep the prompt;
44
+ * hosts that ignore `updatedInput` on a non-allow decision then fall back to
45
+ * fingerprint matching, which is lossy under identical concurrent calls.
46
+ */
47
+ import { hostname } from "node:os";
48
+ import { ControlServer, DEFAULT_CONTROL_PORT } from "../control/server.js";
49
+ import { removeDiscovery, writeDiscovery } from "../control/discovery.js";
50
+ import { authenticate, clearCredentials, normalizeAuthUrl } from "../auth/client.js";
51
+ import { NullRecorder } from "../record/recorder.js";
52
+ import { RemoteRecorder } from "../record/remote-recorder.js";
53
+ import { parseAllowList } from "../replay/coverage.js";
54
+ import { readSidecar } from "../config/generate.js";
55
+ import { resolveServers } from "../config/resolve.js";
56
+ import { isWrapped } from "../config/generate.js";
57
+ import { logLine, errText } from "../util/log.js";
58
+ function parsePort(value) {
59
+ const n = Number(value);
60
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : DEFAULT_CONTROL_PORT;
61
+ }
62
+ /** Config keys already wrapped for this cwd, so `/tool/pre` knows what to correlate. */
63
+ function wrappedServersFor(cwd) {
64
+ try {
65
+ return resolveServers(cwd)
66
+ .filter((s) => isWrapped(s.config))
67
+ .map((s) => s.name);
68
+ }
69
+ catch (err) {
70
+ logLine("config.unreadable", { error: errText(err) });
71
+ return [];
72
+ }
73
+ }
74
+ async function buildRecorder() {
75
+ const configured = (process.env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
76
+ if (!configured) {
77
+ logLine("recorder.disabled", { why: "BIR_AUTH_URL is not set — running without recording" });
78
+ return { recorder: new NullRecorder() };
79
+ }
80
+ // A plain-http URL fronted by a TLS proxy answers `308 → https`, and following
81
+ // that strips the bearer token: sign-in succeeds and every later call fails
82
+ // `401 missing_token`. Settle the base URL before anything authenticates.
83
+ const baseUrl = await normalizeAuthUrl(configured);
84
+ const session = await authenticate({ authUrl: baseUrl });
85
+ if (!session) {
86
+ logLine("recorder.disabled", { why: "no BaseIn session — run `bir login`" });
87
+ return { recorder: new NullRecorder() };
88
+ }
89
+ logLine("auth.ok", { user: session.user.email });
90
+ return { recorder: new RemoteRecorder({ baseUrl, session }), session, baseUrl };
91
+ }
92
+ function positiveInt(value, fallback) {
93
+ const n = Number(value);
94
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
95
+ }
96
+ /**
97
+ * Assemble the replay configuration (docs/calculatedReplay.md §5.3 of the guide).
98
+ *
99
+ * The steer threshold defaults **above** the service's own detection threshold
100
+ * (0.9). Detection decides "do not record this again", which is cheap — the worst
101
+ * case is a lost recording. Steering decides "do not think about this again",
102
+ * which is a much stronger claim, and a 0.90-similar prompt usually covers only
103
+ * part of the live task.
104
+ */
105
+ function buildReplayOptions(auth) {
106
+ const enabled = process.env.BIR_REPLAY === "1";
107
+ const allowServers = parseAllowList(process.env.BIR_REPLAY_ALLOW_SERVERS);
108
+ const minSimilarity = Number(process.env.BIR_MIN_STEER_SIMILARITY);
109
+ const opts = {
110
+ enabled,
111
+ minSimilarity: Number.isFinite(minSimilarity) ? minSimilarity : 0.92,
112
+ allowServers,
113
+ budgets: {
114
+ matchMs: positiveInt(process.env.BIR_MATCH_BUDGET_MS, 2_500),
115
+ deriveMs: positiveInt(process.env.BIR_DERIVE_BUDGET_MS, 8_000),
116
+ planMs: positiveInt(process.env.BIR_REPLAY_BUDGET_MS, 120_000),
117
+ stepMs: positiveInt(process.env.BIR_STEP_TIMEOUT_MS, 60_000),
118
+ },
119
+ authUrl: auth.baseUrl,
120
+ // Read late, not captured: `RemoteRecorder` refreshes the access token as the
121
+ // session outlives it, and a snapshot taken here would go stale mid-run.
122
+ authToken: auth.session ? () => auth.session.accessToken : undefined,
123
+ };
124
+ if (enabled) {
125
+ // Loud, at startup, on the operator's own terminal. A capability that
126
+ // auto-approves tool calls must never be discoverable only from a log file.
127
+ logLine("replay.enabled", {
128
+ minSimilarity: opts.minSimilarity,
129
+ allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
130
+ derive: process.env.ANTHROPIC_API_KEY ? "anthropic" : "recorded sample values",
131
+ why: "matched prompts will run their calculated scenario — steered steps are auto-approved",
132
+ });
133
+ }
134
+ return opts;
135
+ }
136
+ async function main() {
137
+ const argv = process.argv.slice(2);
138
+ if (argv[0] === "logout") {
139
+ clearCredentials();
140
+ process.stderr.write("[bir] logged out — cached credentials cleared.\n");
141
+ return;
142
+ }
143
+ const cwd = process.cwd();
144
+ const sidecar = readSidecar();
145
+ const auth = await buildRecorder();
146
+ const server = new ControlServer({
147
+ recorder: auth.recorder,
148
+ cwd,
149
+ port: parsePort(process.env.BIR_CONTROL_PORT ?? String(sidecar.controlPort ?? "")),
150
+ token: sidecar.token,
151
+ wrappedServers: wrappedServersFor(cwd),
152
+ host: { app: "claude-code" },
153
+ correlationDecision: process.env.BIR_CORRELATION_DECISION === "ask" ? "ask" : "allow",
154
+ noCorrelation: process.env.BIR_NO_CORRELATION === "1",
155
+ replay: buildReplayOptions(auth),
156
+ });
157
+ const address = await server.listen();
158
+ const discoveryFile = writeDiscovery({
159
+ url: address.url,
160
+ token: address.token,
161
+ sessionId: address.sessionId,
162
+ pid: process.pid,
163
+ startedAt: Date.now(),
164
+ cwd,
165
+ });
166
+ logLine("control.listening", {
167
+ url: address.url,
168
+ cwd,
169
+ discovery: discoveryFile,
170
+ machine: hostname(),
171
+ wrapped: wrappedServersFor(cwd).join(",") || "(none)",
172
+ });
173
+ if (sidecar.controlPort && sidecar.controlPort !== address.port) {
174
+ // The hook URLs in settings.json name a port; if we could not take it, the
175
+ // hooks will POST into the void. Say so rather than looking healthy.
176
+ logLine("control.port_mismatch", {
177
+ installed: sidecar.controlPort,
178
+ actual: address.port,
179
+ why: "hooks point at the installed port — re-run `bir install` or free that port",
180
+ });
181
+ }
182
+ let closing = false;
183
+ const shutdown = async () => {
184
+ if (closing)
185
+ return;
186
+ closing = true;
187
+ await server.close().catch((err) => logLine("control.close_failed", { error: errText(err) }));
188
+ removeDiscovery(cwd);
189
+ logLine("control.stopped", {});
190
+ process.exit(0);
191
+ };
192
+ process.on("SIGINT", () => void shutdown());
193
+ process.on("SIGTERM", () => void shutdown());
194
+ process.on("uncaughtException", (err) => logLine("control.uncaught", { error: errText(err) }));
195
+ process.on("unhandledRejection", (err) => logLine("control.unhandled_rejection", { error: errText(err) }));
196
+ }
197
+ void main().catch((err) => {
198
+ logLine("control.fatal", { error: errText(err) });
199
+ process.exit(1);
200
+ });
201
+ //# sourceMappingURL=bir-hooks.js.map
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bir-proxy — the recording MCP proxy. One process per wrapped upstream.
4
+ *
5
+ * Usage:
6
+ * bir-proxy --server-name <key> -- <command> [args…] # stdio upstream
7
+ * bir-proxy --server-name <key> --url <url> [--transport http|sse]
8
+ * [--header 'K: V']… # remote upstream
9
+ *
10
+ * Options:
11
+ * --server-name <key> the client's config key for this server (required)
12
+ * --transport <t> `http` (default) or `sse`, with `--url`
13
+ * --header 'K: V' extra header for a remote upstream; repeatable
14
+ * --standalone skip control-server discovery; record as Tier 2
15
+ * --no-correlation never relax tool schemas; join on argument fingerprints
16
+ * --lazy create the upstream on the first host message, not at
17
+ * startup (see upstream/lazy-client.ts for the caveat)
18
+ * --restart restart a dead stdio upstream once
19
+ * --discovery-ms <n> how long to look for a control server (default 5000)
20
+ *
21
+ * Environment:
22
+ * BIR_CONTROL_URL / BIR_CONTROL_TOKEN control server, bypassing discovery
23
+ * BIR_AUTH_URL BaseIn service, for Tier 2 recording
24
+ * BIR_VERBOSE=1 per-message detail on stderr
25
+ *
26
+ * STDOUT IS SACRED. It is the host's JSON-RPC stream; every log line, every
27
+ * upstream stderr line and every diagnostic goes to stderr instead. A single
28
+ * stray byte here corrupts the session and surfaces somewhere else entirely.
29
+ */
30
+ interface ProxyArgs {
31
+ serverName: string;
32
+ url?: string;
33
+ transport: "http" | "sse";
34
+ headers: Record<string, string>;
35
+ command?: string;
36
+ commandArgs: string[];
37
+ standalone: boolean;
38
+ noCorrelation: boolean;
39
+ lazy: boolean;
40
+ restart: boolean;
41
+ discoveryMs?: number;
42
+ }
43
+ export declare function parseArgs(argv: string[]): ProxyArgs;
44
+ export {};
45
+ //# sourceMappingURL=bir-proxy.d.ts.map