@basein/runner 0.2.8 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -22
- package/dist/auth/client.d.ts +40 -1
- package/dist/auth/client.js +77 -9
- package/dist/bin/bir-hooks.d.ts +18 -3
- package/dist/bin/bir-hooks.js +124 -38
- package/dist/bin/bir-scenario.d.ts +18 -2
- package/dist/bin/bir-scenario.js +374 -4
- package/dist/bin/bir.d.ts +12 -0
- package/dist/bin/bir.js +501 -81
- package/dist/bin/investigate.js +1 -1
- package/dist/bin/scenario-edit.d.ts +173 -0
- package/dist/bin/scenario-edit.js +771 -0
- package/dist/bin/setup.d.ts +72 -0
- package/dist/bin/setup.js +286 -0
- package/dist/config/adapters/claude-code.d.ts +90 -4
- package/dist/config/adapters/claude-code.js +164 -16
- package/dist/config/generate.d.ts +114 -1
- package/dist/config/generate.js +106 -3
- package/dist/control/client.d.ts +5 -0
- package/dist/control/client.js +8 -0
- package/dist/control/daemon.d.ts +116 -0
- package/dist/control/daemon.js +339 -0
- package/dist/control/discovery.d.ts +26 -0
- package/dist/control/discovery.js +41 -9
- package/dist/control/ensure-hook.d.ts +39 -0
- package/dist/control/ensure-hook.js +98 -0
- package/dist/control/paths.d.ts +14 -0
- package/dist/control/paths.js +20 -0
- package/dist/control/server.d.ts +28 -0
- package/dist/control/server.js +15 -2
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +28 -6
- package/docs/calculatedReplay.md +51 -0
- package/docs/calculatedReplayGuide.md +471 -74
- package/docs/installRun.md +457 -111
- package/docs/loginWeb.md +1 -1
- package/docs/quickstart.md +195 -158
- package/package.json +2 -1
- package/scripts/install.ps1 +669 -0
- package/scripts/install.sh +586 -0
package/dist/bin/bir-hooks.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* bir-hooks — the Claude Code hook receiver and the control server (Tier 1).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Normally nobody runs it: `bir install` makes it the SessionStart hook
|
|
6
|
+
* (`bir-hooks ensure`), which starts it in the background for the project when no
|
|
7
|
+
* recorder is running there, and `bir up` / `bir down` start and stop that
|
|
8
|
+
* background process by hand. Its audit lines then go to
|
|
9
|
+
* ~/.baseinstrunner/logs/<project-key>.log. It can still be run in a terminal:
|
|
6
10
|
*
|
|
7
11
|
* bir-hooks # start; Ctrl-C to stop
|
|
8
12
|
* bir-hooks 2>&1 | tee -a ~/.baseinstrunner/audit.log
|
|
@@ -13,7 +17,8 @@
|
|
|
13
17
|
* 3. owns run identity, the monotonic `stepIndex`, and the recorder chain (D5).
|
|
14
18
|
*
|
|
15
19
|
* Environment:
|
|
16
|
-
* BIR_AUTH_URL BaseIn auth-service
|
|
20
|
+
* BIR_AUTH_URL BaseIn auth-service. Unset, the address `bir setup`
|
|
21
|
+
* stored in ~/.baseinstrunner/config.json is used
|
|
17
22
|
* BIR_CONTROL_PORT preferred port (default 53411; falls back if busy)
|
|
18
23
|
* BIR_CORRELATION_DECISION `allow` (default) or `ask` — see below
|
|
19
24
|
* BIR_NO_CORRELATION=1 never inject a call id; join on fingerprints
|
|
@@ -61,13 +66,19 @@
|
|
|
61
66
|
* fingerprint matching, which is lossy under identical concurrent calls.
|
|
62
67
|
*/
|
|
63
68
|
import { hostname } from "node:os";
|
|
69
|
+
import { Console } from "node:console";
|
|
70
|
+
import { createWriteStream } from "node:fs";
|
|
64
71
|
import { ControlServer, DEFAULT_CONTROL_PORT } from "../control/server.js";
|
|
65
|
-
import { removeDiscovery, writeDiscovery } from "../control/discovery.js";
|
|
66
|
-
import {
|
|
72
|
+
import { HEARTBEAT_MS, removeDiscovery, writeDiscovery, } from "../control/discovery.js";
|
|
73
|
+
import { DAEMON_ENV, DAEMON_LOG_ENV } from "../control/daemon.js";
|
|
74
|
+
import { runEnsureHook } from "../control/ensure-hook.js";
|
|
75
|
+
import { ENSURE_ARG } from "../config/adapters/claude-code.js";
|
|
76
|
+
import { authenticate, clearCredentials, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
|
|
77
|
+
import { packageVersion } from "../util/version.js";
|
|
67
78
|
import { NullRecorder } from "../record/recorder.js";
|
|
68
79
|
import { RemoteRecorder } from "../record/remote-recorder.js";
|
|
69
80
|
import { parseAllowList } from "../replay/coverage.js";
|
|
70
|
-
import { readSidecar } from "../config/generate.js";
|
|
81
|
+
import { projectRecord, projectToken, readSidecar } from "../config/generate.js";
|
|
71
82
|
import { resolveServers } from "../config/resolve.js";
|
|
72
83
|
import { isWrapped } from "../config/generate.js";
|
|
73
84
|
import { logLine, errText } from "../util/log.js";
|
|
@@ -89,9 +100,14 @@ function wrappedServersFor(cwd) {
|
|
|
89
100
|
}
|
|
90
101
|
}
|
|
91
102
|
async function buildRecorder() {
|
|
92
|
-
|
|
103
|
+
// BIR_AUTH_URL, or the address 'bir setup' stored. Read the same way by the
|
|
104
|
+
// proxies and the CLI, so a recorder started by a hook — with whatever
|
|
105
|
+
// environment the host had — still knows where the steps go.
|
|
106
|
+
const configured = resolveAuthUrl();
|
|
93
107
|
if (!configured) {
|
|
94
|
-
logLine("recorder.disabled", {
|
|
108
|
+
logLine("recorder.disabled", {
|
|
109
|
+
why: "no service address — run 'bir setup', or set BIR_AUTH_URL; running without recording",
|
|
110
|
+
});
|
|
95
111
|
return { recorder: new NullRecorder() };
|
|
96
112
|
}
|
|
97
113
|
// A plain-http URL fronted by a TLS proxy answers `308 → https`, and following
|
|
@@ -133,22 +149,26 @@ function parseIntentSources(value) {
|
|
|
133
149
|
.filter((s) => allowed.includes(s));
|
|
134
150
|
return new Set(wanted);
|
|
135
151
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const enabled =
|
|
147
|
-
const allowServers =
|
|
148
|
-
|
|
152
|
+
function buildReplayOptions(auth, policy) {
|
|
153
|
+
// The environment still wins — an operator at a terminal is the most
|
|
154
|
+
// deliberate signal there is — but a recorder started by the SessionStart
|
|
155
|
+
// hook has Claude Code's environment, not that terminal's, and it reads the
|
|
156
|
+
// switches the project stored instead of forgetting them (generate.ts).
|
|
157
|
+
const envReplay = process.env.BIR_REPLAY;
|
|
158
|
+
const envAllow = process.env.BIR_REPLAY_ALLOW_SERVERS;
|
|
159
|
+
const envMin = Number(process.env.BIR_MIN_STEER_SIMILARITY);
|
|
160
|
+
const fromEnv = envReplay !== undefined || envAllow !== undefined || Number.isFinite(envMin);
|
|
161
|
+
const source = fromEnv ? "env" : policy ? "sidecar" : "default";
|
|
162
|
+
const enabled = envReplay !== undefined ? envReplay !== "0" : policy?.enabled !== false;
|
|
163
|
+
const allowServers = envAllow !== undefined
|
|
164
|
+
? parseAllowList(envAllow)
|
|
165
|
+
: policy?.allowServers
|
|
166
|
+
? parseAllowList(policy.allowServers.join(","))
|
|
167
|
+
: undefined;
|
|
168
|
+
const minSimilarity = Number.isFinite(envMin) ? envMin : (policy?.minSimilarity ?? 0.92);
|
|
149
169
|
const opts = {
|
|
150
170
|
enabled,
|
|
151
|
-
minSimilarity
|
|
171
|
+
minSimilarity,
|
|
152
172
|
allowServers,
|
|
153
173
|
budgets: {
|
|
154
174
|
matchMs: positiveInt(process.env.BIR_MATCH_BUDGET_MS, 2_500),
|
|
@@ -194,10 +214,11 @@ function buildReplayOptions(auth) {
|
|
|
194
214
|
? "the service"
|
|
195
215
|
: "recorded sample values — sign in, or a scenario with a target will not run",
|
|
196
216
|
intentMatch: opts.intentMatch?.enabled ? "on" : "off",
|
|
217
|
+
source,
|
|
197
218
|
why: "matched prompts will run their calculated scenario — steered steps are auto-approved",
|
|
198
219
|
});
|
|
199
220
|
}
|
|
200
|
-
return opts;
|
|
221
|
+
return { opts, source };
|
|
201
222
|
}
|
|
202
223
|
async function main() {
|
|
203
224
|
const argv = process.argv.slice(2);
|
|
@@ -206,61 +227,126 @@ async function main() {
|
|
|
206
227
|
process.stderr.write("[bir] logged out — cached credentials cleared.\n");
|
|
207
228
|
return;
|
|
208
229
|
}
|
|
230
|
+
if (argv[0] === ENSURE_ARG) {
|
|
231
|
+
// The SessionStart hook: start the recorder for this directory if there is
|
|
232
|
+
// none, then relay the payload. Always exit 0 (control/ensure-hook.ts).
|
|
233
|
+
process.exitCode = await runEnsureHook(process.cwd());
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
209
236
|
const cwd = process.cwd();
|
|
237
|
+
const daemon = process.env[DAEMON_ENV] === "1";
|
|
238
|
+
const logFile = daemon ? process.env[DAEMON_LOG_ENV] : undefined;
|
|
239
|
+
if (logFile) {
|
|
240
|
+
// A background recorder owns its log. Nothing was inherited from whoever
|
|
241
|
+
// started it (control/daemon.ts says why), so the audit lines — every
|
|
242
|
+
// `logLine` goes through `console.error` — are pointed at the file here.
|
|
243
|
+
const stream = createWriteStream(logFile, { flags: "a" });
|
|
244
|
+
globalThis.console = new Console({ stdout: stream, stderr: stream });
|
|
245
|
+
}
|
|
210
246
|
const sidecar = readSidecar();
|
|
247
|
+
// The port this project's hooks were written with. An explicit BIR_CONTROL_PORT
|
|
248
|
+
// still wins; the per-project record is what lets two projects record at once.
|
|
249
|
+
const pinned = process.env.BIR_CONTROL_PORT !== undefined || projectRecord(sidecar, cwd) !== undefined;
|
|
250
|
+
const preferredPort = parsePort(process.env.BIR_CONTROL_PORT ??
|
|
251
|
+
String(projectRecord(sidecar, cwd)?.port ?? sidecar.controlPort ?? ""));
|
|
211
252
|
// Open before anything logs: the journal is what `bir investigate` reads
|
|
212
253
|
// later, and the startup lines are part of the story.
|
|
213
254
|
const journalFile = openJournal(cwd);
|
|
214
255
|
const auth = await buildRecorder();
|
|
256
|
+
const replay = buildReplayOptions(auth, projectRecord(sidecar, cwd)?.replay);
|
|
215
257
|
const server = new ControlServer({
|
|
216
258
|
recorder: auth.recorder,
|
|
217
259
|
cwd,
|
|
218
|
-
port:
|
|
219
|
-
token
|
|
260
|
+
port: preferredPort,
|
|
261
|
+
// This project's token (generate.ts); the machine-wide one for a record
|
|
262
|
+
// written before tokens were per project.
|
|
263
|
+
token: projectToken(sidecar, cwd),
|
|
220
264
|
wrappedServers: wrappedServersFor(cwd),
|
|
221
265
|
host: { app: "claude-code" },
|
|
222
266
|
correlationDecision: process.env.BIR_CORRELATION_DECISION === "ask" ? "ask" : "allow",
|
|
223
267
|
noCorrelation: process.env.BIR_NO_CORRELATION === "1",
|
|
224
268
|
deriveRecentResults: nonNegativeInt(process.env.BIR_DERIVE_RECENT_RESULTS, 5),
|
|
225
269
|
intentSources: parseIntentSources(process.env.BIR_INTENT_SOURCES),
|
|
226
|
-
replay:
|
|
270
|
+
replay: replay.opts,
|
|
271
|
+
replaySource: replay.source,
|
|
272
|
+
account: auth.session?.user.email,
|
|
273
|
+
// In the background with a port the hooks name, a fallback port is a
|
|
274
|
+
// recorder every hook but SessionStart would miss. Exit with a reason instead.
|
|
275
|
+
portFallback: !(daemon && pinned),
|
|
276
|
+
// 'bir down', and the SessionStart hook replacing an out-of-date recorder,
|
|
277
|
+
// ask over HTTP rather than sending a signal: on Windows a signal is a kill.
|
|
278
|
+
onStopRequested: () => void shutdown(),
|
|
227
279
|
});
|
|
228
|
-
|
|
229
|
-
|
|
280
|
+
let address;
|
|
281
|
+
try {
|
|
282
|
+
address = await server.listen();
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const code = err.code;
|
|
286
|
+
if (code === "EADDRINUSE" || code === "EACCES") {
|
|
287
|
+
logLine("control.port_busy", {
|
|
288
|
+
port: preferredPort,
|
|
289
|
+
code,
|
|
290
|
+
fix: `port ${preferredPort} is held by another program or excluded by the system — run \`bir setup --port <another>\` in this directory`,
|
|
291
|
+
});
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
296
|
+
const info = {
|
|
230
297
|
url: address.url,
|
|
231
298
|
token: address.token,
|
|
232
299
|
sessionId: address.sessionId,
|
|
233
300
|
pid: process.pid,
|
|
234
301
|
startedAt: Date.now(),
|
|
235
302
|
cwd,
|
|
236
|
-
|
|
303
|
+
version: packageVersion(),
|
|
304
|
+
...(daemon ? { daemon: true, logFile: process.env[DAEMON_LOG_ENV] } : {}),
|
|
305
|
+
};
|
|
306
|
+
const discoveryFile = writeDiscovery(info);
|
|
307
|
+
// A background recorder outlives the discovery file's 24-hour freshness
|
|
308
|
+
// window; the heartbeat is what keeps it findable (discovery.ts).
|
|
309
|
+
const heartbeat = setInterval(() => {
|
|
310
|
+
try {
|
|
311
|
+
writeDiscovery({ ...info, heartbeatAt: Date.now() });
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
logLine("control.heartbeat_failed", { error: errText(err) });
|
|
315
|
+
}
|
|
316
|
+
}, HEARTBEAT_MS);
|
|
317
|
+
heartbeat.unref();
|
|
237
318
|
logLine("control.listening", {
|
|
238
319
|
url: address.url,
|
|
239
320
|
cwd,
|
|
240
321
|
discovery: discoveryFile,
|
|
241
322
|
journal: journalFile,
|
|
242
323
|
machine: hostname(),
|
|
324
|
+
version: packageVersion(),
|
|
325
|
+
background: daemon ? "yes" : undefined,
|
|
243
326
|
wrapped: wrappedServersFor(cwd).join(",") || "(none)",
|
|
244
327
|
});
|
|
245
|
-
if (sidecar.controlPort
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
328
|
+
if (preferredPort !== DEFAULT_CONTROL_PORT || sidecar.controlPort || projectRecord(sidecar, cwd)) {
|
|
329
|
+
if (preferredPort !== address.port) {
|
|
330
|
+
// The hook URLs in settings.json name a port; if we could not take it,
|
|
331
|
+
// the hooks will POST into the void. Say so rather than looking healthy.
|
|
332
|
+
logLine("control.port_mismatch", {
|
|
333
|
+
installed: preferredPort,
|
|
334
|
+
actual: address.port,
|
|
335
|
+
why: "hooks point at the installed port — re-run `bir install` or free that port",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
253
338
|
}
|
|
254
339
|
let closing = false;
|
|
255
|
-
|
|
340
|
+
async function shutdown() {
|
|
256
341
|
if (closing)
|
|
257
342
|
return;
|
|
258
343
|
closing = true;
|
|
344
|
+
clearInterval(heartbeat);
|
|
259
345
|
await server.close().catch((err) => logLine("control.close_failed", { error: errText(err) }));
|
|
260
346
|
removeDiscovery(cwd);
|
|
261
347
|
logLine("control.stopped", {});
|
|
262
348
|
process.exit(0);
|
|
263
|
-
}
|
|
349
|
+
}
|
|
264
350
|
process.on("SIGINT", () => void shutdown());
|
|
265
351
|
process.on("SIGTERM", () => void shutdown());
|
|
266
352
|
process.on("uncaughtException", (err) => logLine("control.uncaught", { error: errText(err) }));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* bir-scenario —
|
|
4
|
-
*
|
|
3
|
+
* bir-scenario — the first-party `bir` MCP server (docs/calculatedReplay.md §6.3):
|
|
4
|
+
* `run_scenario`, plus the tools that read and fix a calculated scenario
|
|
5
|
+
* (editSteps.md in the BaseIn repository; docs/calculatedReplayGuide.md §9.2).
|
|
5
6
|
*
|
|
6
7
|
* WHY THIS EXISTS. On a fully-wrapped match the whole scenario runs server-side,
|
|
7
8
|
* through proxies that are already connected, and the model's only job is to read
|
|
@@ -17,6 +18,21 @@
|
|
|
17
18
|
* control server exactly as a proxy does — `~/.baseinstrunner/control/<key>.json`,
|
|
18
19
|
* mode 0600, bearer token — and forwards one call.
|
|
19
20
|
*
|
|
21
|
+
* THE SCENARIO TOOLS RUN `bir` ITSELF. `scenario_show`, `scenario_check` and
|
|
22
|
+
* the rest are the `bir scenario …` commands, spawned from this same package
|
|
23
|
+
* with `--json` (D1: one path, whether a person types it or the model calls
|
|
24
|
+
* it). That keeps this server credential-free — `bir` signs in the way it
|
|
25
|
+
* always does — and means the model sees exactly the answer a person would.
|
|
26
|
+
* Logic bodies travel through temporary files, never argv, and a non-zero exit
|
|
27
|
+
* is an `isError` result that carries the output, refusal and report included.
|
|
28
|
+
*
|
|
29
|
+
* The tools that CHANGE a scenario are offered only where a person ran
|
|
30
|
+
* `bir scenario editing on` (D2): this server runs in every session of every
|
|
31
|
+
* installed project, a fleet included, and a plan must not change because some
|
|
32
|
+
* agent there decided it should. The switch is read when the host lists the
|
|
33
|
+
* tools, and again on every call, so a call to a tool that was never offered —
|
|
34
|
+
* or was offered before the switch went off — is refused rather than run.
|
|
35
|
+
*
|
|
20
36
|
* STDOUT IS SACRED, as in every MCP server here: it is the host's JSON-RPC
|
|
21
37
|
* stream, and every log line goes to stderr instead.
|
|
22
38
|
*/
|