@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.
- package/LICENSE +201 -0
- package/README.md +276 -0
- package/dist/auth/client.d.ts +85 -0
- package/dist/auth/client.js +284 -0
- package/dist/bin/bir-hooks.d.ts +48 -0
- package/dist/bin/bir-hooks.js +201 -0
- package/dist/bin/bir-proxy.d.ts +45 -0
- package/dist/bin/bir-proxy.js +207 -0
- package/dist/bin/bir-scenario.d.ts +24 -0
- package/dist/bin/bir-scenario.js +177 -0
- package/dist/bin/bir.d.ts +21 -0
- package/dist/bin/bir.js +876 -0
- package/dist/config/adapters/claude-code.d.ts +76 -0
- package/dist/config/adapters/claude-code.js +181 -0
- package/dist/config/adapters/generic.d.ts +17 -0
- package/dist/config/adapters/generic.js +36 -0
- package/dist/config/generate.d.ts +127 -0
- package/dist/config/generate.js +114 -0
- package/dist/config/resolve.d.ts +68 -0
- package/dist/config/resolve.js +132 -0
- package/dist/control/client.d.ts +56 -0
- package/dist/control/client.js +86 -0
- package/dist/control/correlation.d.ts +86 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/discovery.d.ts +50 -0
- package/dist/control/discovery.js +123 -0
- package/dist/control/ordering.d.ts +38 -0
- package/dist/control/ordering.js +44 -0
- package/dist/control/paths.d.ts +32 -0
- package/dist/control/paths.js +56 -0
- package/dist/control/server.d.ts +272 -0
- package/dist/control/server.js +1131 -0
- package/dist/control/transcript.d.ts +75 -0
- package/dist/control/transcript.js +241 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +32 -0
- package/dist/jsonrpc/framing.d.ts +49 -0
- package/dist/jsonrpc/framing.js +143 -0
- package/dist/jsonrpc/types.d.ts +52 -0
- package/dist/jsonrpc/types.js +46 -0
- package/dist/proxy/intercept.d.ts +55 -0
- package/dist/proxy/intercept.js +147 -0
- package/dist/proxy/relay.d.ts +97 -0
- package/dist/proxy/relay.js +166 -0
- package/dist/proxy/session.d.ts +116 -0
- package/dist/proxy/session.js +319 -0
- package/dist/record/housekeeping.d.ts +34 -0
- package/dist/record/housekeeping.js +39 -0
- package/dist/record/queue.d.ts +48 -0
- package/dist/record/queue.js +96 -0
- package/dist/record/recorder.d.ts +111 -0
- package/dist/record/recorder.js +39 -0
- package/dist/record/redact.d.ts +37 -0
- package/dist/record/redact.js +119 -0
- package/dist/record/remote-recorder.d.ts +110 -0
- package/dist/record/remote-recorder.js +301 -0
- package/dist/record/truncate.d.ts +36 -0
- package/dist/record/truncate.js +85 -0
- package/dist/replay/bundle.d.ts +36 -0
- package/dist/replay/bundle.js +89 -0
- package/dist/replay/controller.d.ts +300 -0
- package/dist/replay/controller.js +807 -0
- package/dist/replay/coverage.d.ts +41 -0
- package/dist/replay/coverage.js +56 -0
- package/dist/replay/derive.d.ts +58 -0
- package/dist/replay/derive.js +166 -0
- package/dist/replay/executor.d.ts +78 -0
- package/dist/replay/executor.js +233 -0
- package/dist/replay/logic.d.ts +31 -0
- package/dist/replay/logic.js +50 -0
- package/dist/replay/plan.d.ts +181 -0
- package/dist/replay/plan.js +397 -0
- package/dist/replay/pricing.d.ts +41 -0
- package/dist/replay/pricing.js +76 -0
- package/dist/replay/source-run.d.ts +50 -0
- package/dist/replay/source-run.js +98 -0
- package/dist/replay/tool-error.d.ts +22 -0
- package/dist/replay/tool-error.js +60 -0
- package/dist/replay/types.d.ts +116 -0
- package/dist/replay/types.js +35 -0
- package/dist/upstream/client.d.ts +78 -0
- package/dist/upstream/client.js +114 -0
- package/dist/upstream/http-client.d.ts +78 -0
- package/dist/upstream/http-client.js +261 -0
- package/dist/upstream/lazy-client.d.ts +31 -0
- package/dist/upstream/lazy-client.js +53 -0
- package/dist/upstream/stdio-client.d.ts +57 -0
- package/dist/upstream/stdio-client.js +203 -0
- package/dist/util/log.d.ts +27 -0
- package/dist/util/log.js +51 -0
- package/dist/util/version.d.ts +2 -0
- package/dist/util/version.js +40 -0
- package/docs/BaseInstRunner.md +621 -0
- package/docs/calculatedReplay.md +1185 -0
- package/docs/calculatedReplayGuide.md +448 -0
- package/docs/installRun.md +413 -0
- package/docs/mcpmark.md +752 -0
- package/docs/quickstart.md +201 -0
- package/docs/t-bench.md +394 -0
- 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
|