@basein/runner 0.2.12 → 0.3.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/README.md +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2786 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/bin/bir-hooks.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* Normally nobody runs it: `bir install` makes it the SessionStart hook
|
|
6
6
|
* (`bir-hooks ensure`), which starts it in the background for the project when no
|
|
7
7
|
* recorder is running there, and `bir up` / `bir down` start and stop that
|
|
8
|
-
* background process by hand.
|
|
8
|
+
* background process by hand. `bir-hooks revive --cwd <project>` starts it
|
|
9
|
+
* again mid-session: a bound proxy spawns it, detached, when the recorder it
|
|
10
|
+
* reported to is gone (control/daemon.ts, reviveDaemon). Its audit lines then go to
|
|
9
11
|
* ~/.baseinstrunner/logs/<project-key>.log. It can still be run in a terminal:
|
|
10
12
|
*
|
|
11
13
|
* bir-hooks # start; Ctrl-C to stop
|
|
@@ -16,31 +18,69 @@
|
|
|
16
18
|
* 2. accepts step reports from every `bir-proxy` that finds it;
|
|
17
19
|
* 3. owns run identity, the monotonic `stepIndex`, and the recorder chain (D5).
|
|
18
20
|
*
|
|
21
|
+
* The recorder listens first and signs in afterwards, in the background, so a
|
|
22
|
+
* slow or unreachable BaseIn service never holds a hook; it starts recording
|
|
23
|
+
* by itself when a sign-in appears (`bir login`) — no restart
|
|
24
|
+
* (control/recorder-main.ts, control/recorder-keeper.ts).
|
|
25
|
+
*
|
|
19
26
|
* Environment:
|
|
20
27
|
* BIR_AUTH_URL BaseIn auth-service. Unset, the address `bir setup`
|
|
21
28
|
* stored in ~/.baseinstrunner/config.json is used
|
|
22
|
-
* BIR_CONTROL_PORT
|
|
23
|
-
*
|
|
29
|
+
* BIR_CONTROL_PORT the port to listen on, over the project's own
|
|
30
|
+
* (default: the project's, else 53411, falling
|
|
31
|
+
* back if busy). 0 is any free port — the one
|
|
32
|
+
* taken is in the discovery file, /health and the
|
|
33
|
+
* log (tests and the smoke run; a project's hooks
|
|
34
|
+
* name a fixed port). A value that is not a port
|
|
35
|
+
* is ignored
|
|
36
|
+
* BIR_CORRELATION_DECISION unset (default: `auto`), `auto`, `allow` or
|
|
37
|
+
* `ask` — see below
|
|
24
38
|
* BIR_NO_CORRELATION=1 never inject a call id; join on fingerprints
|
|
25
39
|
* BIR_VERBOSE=1 per-step detail lines
|
|
40
|
+
* BIR_SERVICE_TIMEOUT_MS ceiling for one call to the service made off a
|
|
41
|
+
* hook's path (default 10000)
|
|
42
|
+
* BIR_SUPERSEDED_EXIT_MS how long a recorder the project's hooks moved
|
|
43
|
+
* away from (a reinstall on another port) must
|
|
44
|
+
* have had no session, run, live proxy or unsent
|
|
45
|
+
* step before it exits (default 86400000, a day)
|
|
46
|
+
* BIR_WINDOWS_LAUNCH=spawn Windows only: start the background recorder with
|
|
47
|
+
* a direct hidden spawn instead of PowerShell's
|
|
48
|
+
* Start-Process (faster by about a second; unsafe
|
|
49
|
+
* only when something above Claude Code captures
|
|
50
|
+
* its output — see control/daemon.ts)
|
|
26
51
|
*
|
|
27
52
|
* BIR_REPLAY=0 turn OFF calculated replay — running a scenario on
|
|
28
53
|
* a similar-meaning match (docs/calculatedReplay.md).
|
|
29
54
|
* ON by default; unset or any value other than "0"
|
|
30
|
-
* keeps it on
|
|
31
|
-
*
|
|
32
|
-
*
|
|
55
|
+
* keeps it on (`bir replay off` stores the same
|
|
56
|
+
* for one project). Read §13.2: only when a prompt
|
|
57
|
+
* matches a scenario, that scenario's own steps run
|
|
58
|
+
* without a prompt of their own — a steered step is
|
|
59
|
+
* answered `allow`, a directly executed one never
|
|
60
|
+
* reaches the permission system. Never in plan
|
|
61
|
+
* mode; every other tool call is asked as usual.
|
|
33
62
|
* BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
|
|
34
63
|
* execution. Unset means every wrapped server
|
|
35
64
|
* BIR_MIN_STEER_SIMILARITY minimum match similarity to replay (default 0.92)
|
|
36
|
-
*
|
|
65
|
+
* BIR_DERIVE_API_KEY OPTIONAL. Derivation — reading what this turn acts
|
|
37
66
|
* on — is done by the service for a signed-in
|
|
38
|
-
* runner. Set this only to keep
|
|
39
|
-
* machine; signed out and
|
|
40
|
-
* target does not run
|
|
67
|
+
* runner. Set this (an Anthropic key) only to keep
|
|
68
|
+
* that reading on this machine; signed out and
|
|
69
|
+
* unset, a scenario with a target does not run
|
|
70
|
+
* (segmented.md R-PARAM-5)
|
|
71
|
+
* BIR_USE_ANTHROPIC_API_KEY=1 let the background recorder use an
|
|
72
|
+
* ANTHROPIC_API_KEY from its environment for
|
|
73
|
+
* derivation, as older versions did. Off by
|
|
74
|
+
* default: a recorder the SessionStart hook starts
|
|
75
|
+
* has Claude Code's environment, and using the key
|
|
76
|
+
* found there billed the person's own account. A
|
|
77
|
+
* `bir-hooks` run by hand in a terminal uses that
|
|
78
|
+
* terminal's ANTHROPIC_API_KEY as it always did
|
|
41
79
|
* BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
|
|
42
|
-
* BIR_MATCH_BUDGET_MS prompt-hook match wait (default
|
|
43
|
-
* BIR_DERIVE_BUDGET_MS
|
|
80
|
+
* BIR_MATCH_BUDGET_MS prompt-hook match wait (default 1500)
|
|
81
|
+
* BIR_DERIVE_BUDGET_MS derivation wait before a direct run (default 8000);
|
|
82
|
+
* inside a hook it is at most BIR_HOOK_DERIVE_BUDGET_MS
|
|
83
|
+
* (default 4000)
|
|
44
84
|
* BIR_DERIVE_RECENT_RESULTS how many of this prompt's tool results derivation
|
|
45
85
|
* reads, so a target named by an earlier step can be
|
|
46
86
|
* found (default 5; 0 off)
|
|
@@ -49,310 +89,162 @@
|
|
|
49
89
|
* BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40).
|
|
50
90
|
* Separate from BIR_INTENT_MATCHES_PER_TURN: arming
|
|
51
91
|
* is a claim on the turn, counting hits is not
|
|
92
|
+
* BIR_INTENT_MATCH_BUDGET_MS one intent probe's own ceiling (default 4000).
|
|
93
|
+
* Not a wait: PreToolUse does not wait for a probe
|
|
94
|
+
* unless arming is on (BIR_SEGMENT_ARM=1 or
|
|
95
|
+
* BIR_INTENT_ARM=1), and then at most
|
|
96
|
+
* BIR_INTENT_ARM_BUDGET_MS (default 500)
|
|
52
97
|
* BIR_SEGMENT_ARM 1 lets a handed-out segment actually run mid-task.
|
|
53
98
|
* Unset is observe-only: what would have armed is
|
|
54
99
|
* logged and nothing is replaced
|
|
55
100
|
* BIR_MIN_SEGMENT_STEER_SIMILARITY minimum similarity to steer a segment
|
|
56
101
|
* (default 0.95)
|
|
57
|
-
* BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (
|
|
102
|
+
* BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (unset: the
|
|
103
|
+
* controller's 100000, BIR_REPLAY_PLAN_BUDGET_MS)
|
|
58
104
|
* BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
|
|
59
105
|
*
|
|
60
|
-
* ABOUT `BIR_CORRELATION_DECISION`. Correlating a wrapped MCP call
|
|
61
|
-
* answering `PreToolUse` with
|
|
62
|
-
*
|
|
63
|
-
* **auto-approves the call**, so a tool the user would have been asked about
|
|
64
|
-
* no longer asked about.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
106
|
+
* ABOUT `BIR_CORRELATION_DECISION`. Correlating a wrapped MCP call exactly
|
|
107
|
+
* means answering `PreToolUse` with an extra argument, and Claude Code only
|
|
108
|
+
* takes that together with `permissionDecision: "allow"` — which also
|
|
109
|
+
* **auto-approves the call**, so a tool the user would have been asked about
|
|
110
|
+
* is no longer asked about. Unset (or `auto`), nothing is passed to the
|
|
111
|
+
* control server, which never does that for an ordinary call: it injects only
|
|
112
|
+
* where `allow` changes nothing the user is asked (a `bypassPermissions`
|
|
113
|
+
* session) and otherwise joins the proxy's report by fingerprint. `allow` is
|
|
114
|
+
* the behaviour of 0.2.12 and earlier, an explicit opt-in that is logged loudly at start
|
|
115
|
+
* — every wrapped call auto-approved and exactly correlated; `ask` keeps the
|
|
116
|
+
* prompt (a host that ignores the injection on a non-allow answer falls back
|
|
117
|
+
* to fingerprints).
|
|
67
118
|
*/
|
|
68
|
-
import {
|
|
119
|
+
import { appendFileSync } from "node:fs";
|
|
69
120
|
import { Console } from "node:console";
|
|
70
|
-
import {
|
|
71
|
-
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";
|
|
121
|
+
import { dirname, resolve } from "node:path";
|
|
122
|
+
import { Writable } from "node:stream";
|
|
75
123
|
import { ENSURE_ARG } from "../config/adapters/claude-code.js";
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
78
|
-
import {
|
|
79
|
-
import {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
124
|
+
import { exemptLoopbackFromProxy } from "../config/generate.js";
|
|
125
|
+
import { REVIVE_ARG } from "../control/daemon.js";
|
|
126
|
+
import { daemonLogPath, ensureDir } from "../control/paths.js";
|
|
127
|
+
import { errText, logLine } from "../util/log.js";
|
|
128
|
+
/**
|
|
129
|
+
* The SessionStart hook. Claude Code holds the session start until it exits,
|
|
130
|
+
* so whatever happens it exits 0 and never outlives its budget.
|
|
131
|
+
*/
|
|
132
|
+
async function ensure(argv = []) {
|
|
133
|
+
const bail = (err) => {
|
|
134
|
+
try {
|
|
135
|
+
process.stderr.write(`[bir] ensure: ${errText(err)}\n`);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* nothing left to tell */
|
|
139
|
+
}
|
|
140
|
+
process.exit(0);
|
|
141
|
+
};
|
|
142
|
+
process.on("uncaughtException", bail);
|
|
143
|
+
process.on("unhandledRejection", bail);
|
|
144
|
+
// Loaded here, not at the top: this runs on every session start, /clear and
|
|
145
|
+
// compaction, and the control server's module graph is not needed for it.
|
|
146
|
+
const { runEnsureHook, ENSURE_HOOK_BUDGET_MS } = await import("../control/ensure-hook.js");
|
|
147
|
+
// `--budget-ms <n>`: an npx-form hook spends part of the hook's timeout on
|
|
148
|
+
// npx itself before this code runs, so `bir install --npx` writes a smaller
|
|
149
|
+
// budget. It can only shorten the default, never lengthen it.
|
|
150
|
+
const asked = budgetArg(argv);
|
|
151
|
+
const budgetMs = asked !== undefined ? Math.min(asked, ENSURE_HOOK_BUDGET_MS) : ENSURE_HOOK_BUDGET_MS;
|
|
152
|
+
const ceiling = setTimeout(() => process.exit(0), budgetMs + 500);
|
|
153
|
+
ceiling.unref();
|
|
154
|
+
process.exitCode = await runEnsureHook(process.cwd(), { budgetMs });
|
|
89
155
|
}
|
|
90
|
-
/**
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return [];
|
|
156
|
+
/** The value of `--budget-ms <n>` or `--budget-ms=<n>` when it is a positive integer. */
|
|
157
|
+
function budgetArg(argv) {
|
|
158
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
159
|
+
const a = argv[i];
|
|
160
|
+
const raw = a === "--budget-ms" ? argv[i + 1] : a.startsWith("--budget-ms=") ? a.slice("--budget-ms=".length) : undefined;
|
|
161
|
+
if (raw === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
const n = Number(raw);
|
|
164
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
100
165
|
}
|
|
166
|
+
return undefined;
|
|
101
167
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
168
|
+
/** The longest a revive may take: {@link ensureDaemon}'s default budget, and a margin. */
|
|
169
|
+
const REVIVE_CEILING_MS = 20_000;
|
|
170
|
+
/** The value of `--cwd <dir>` or `--cwd=<dir>`, or undefined. */
|
|
171
|
+
function cwdArg(argv) {
|
|
172
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
173
|
+
const a = argv[i];
|
|
174
|
+
if (a === "--cwd" && argv[i + 1] !== undefined)
|
|
175
|
+
return argv[i + 1];
|
|
176
|
+
if (a.startsWith("--cwd="))
|
|
177
|
+
return a.slice("--cwd=".length);
|
|
112
178
|
}
|
|
113
|
-
|
|
114
|
-
// that strips the bearer token: sign-in succeeds and every later call fails
|
|
115
|
-
// `401 missing_token`. Settle the base URL before anything authenticates.
|
|
116
|
-
const baseUrl = await normalizeAuthUrl(configured);
|
|
117
|
-
const session = await authenticate({ authUrl: baseUrl });
|
|
118
|
-
if (!session) {
|
|
119
|
-
logLine("recorder.disabled", { why: "no BaseIn session — run `bir login`" });
|
|
120
|
-
return { recorder: new NullRecorder() };
|
|
121
|
-
}
|
|
122
|
-
logLine("auth.ok", { user: session.user.email });
|
|
123
|
-
return { recorder: new RemoteRecorder({ baseUrl, session }), session, baseUrl };
|
|
124
|
-
}
|
|
125
|
-
function positiveInt(value, fallback) {
|
|
126
|
-
const n = Number(value);
|
|
127
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
128
|
-
}
|
|
129
|
-
/** Like {@link positiveInt}, but 0 is a meaningful setting rather than unset. */
|
|
130
|
-
function nonNegativeInt(value, fallback) {
|
|
131
|
-
const n = Number(value);
|
|
132
|
-
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
179
|
+
return undefined;
|
|
133
180
|
}
|
|
134
181
|
/**
|
|
135
|
-
* `
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
182
|
+
* `bir-hooks revive --cwd <project>` (K1): start the project's recorder
|
|
183
|
+
* again when it is gone mid-session (control/daemon.ts, reviveDaemon).
|
|
184
|
+
* Spawned detached, with no stdio, by a bound `bir-proxy` or `bir-scenario`
|
|
185
|
+
* — possibly by several at once. It reads no stdin, writes nothing to stdout
|
|
186
|
+
* (its audit lines go to the project's recorder log), never throws, and exits
|
|
187
|
+
* 0 whatever happens, within {@link REVIVE_CEILING_MS}.
|
|
141
188
|
*/
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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);
|
|
169
|
-
const opts = {
|
|
170
|
-
enabled,
|
|
171
|
-
minSimilarity,
|
|
172
|
-
allowServers,
|
|
173
|
-
budgets: {
|
|
174
|
-
matchMs: positiveInt(process.env.BIR_MATCH_BUDGET_MS, 2_500),
|
|
175
|
-
deriveMs: positiveInt(process.env.BIR_DERIVE_BUDGET_MS, 8_000),
|
|
176
|
-
planMs: positiveInt(process.env.BIR_REPLAY_BUDGET_MS, 120_000),
|
|
177
|
-
stepMs: positiveInt(process.env.BIR_STEP_TIMEOUT_MS, 60_000),
|
|
189
|
+
async function revive(argv) {
|
|
190
|
+
const done = () => process.exit(0);
|
|
191
|
+
process.on("uncaughtException", done);
|
|
192
|
+
process.on("unhandledRejection", done);
|
|
193
|
+
const ceiling = setTimeout(done, REVIVE_CEILING_MS);
|
|
194
|
+
ceiling.unref();
|
|
195
|
+
const cwd = resolve(cwdArg(argv) ?? process.cwd());
|
|
196
|
+
// Nobody reads this process's stdio: every line it logs (the start, why it
|
|
197
|
+
// did not) goes where `bir doctor` and a person looking read.
|
|
198
|
+
const file = daemonLogPath(cwd);
|
|
199
|
+
ensureDir(dirname(file));
|
|
200
|
+
const sink = new Writable({
|
|
201
|
+
write(chunk, _encoding, callback) {
|
|
202
|
+
try {
|
|
203
|
+
appendFileSync(file, chunk);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
/* dropped */
|
|
207
|
+
}
|
|
208
|
+
callback();
|
|
178
209
|
},
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// band has one live question to ask under the server's own 2 500 ms
|
|
185
|
-
// attempt, and must still answer inside the hook's 30 s (segmented.md
|
|
186
|
-
// R-HIT-11).
|
|
187
|
-
budgetMs: positiveInt(process.env.BIR_INTENT_MATCH_BUDGET_MS, 4_000),
|
|
188
|
-
maxPerTurn: positiveInt(process.env.BIR_INTENT_MATCHES_PER_TURN, 3),
|
|
189
|
-
maxRequestsPerTurn: positiveInt(process.env.BIR_INTENT_REQUESTS_PER_TURN, 40),
|
|
190
|
-
},
|
|
191
|
-
// Observe-only until an operator has read the logs and turned it on
|
|
192
|
-
// (segmented.md R-OUT-10, R-LIFE-7). Arming is never the way to find out.
|
|
193
|
-
segmentArm: process.env.BIR_SEGMENT_ARM === "1",
|
|
194
|
-
minSegmentSimilarity: (() => {
|
|
195
|
-
const n = Number(process.env.BIR_MIN_SEGMENT_STEER_SIMILARITY);
|
|
196
|
-
return Number.isFinite(n) ? n : 0.95;
|
|
197
|
-
})(),
|
|
198
|
-
authUrl: auth.baseUrl,
|
|
199
|
-
// Read late, not captured: `RemoteRecorder` refreshes the access token as the
|
|
200
|
-
// session outlives it, and a snapshot taken here would go stale mid-run.
|
|
201
|
-
authToken: auth.session ? () => auth.session.accessToken : undefined,
|
|
202
|
-
};
|
|
203
|
-
if (enabled) {
|
|
204
|
-
// Loud, at startup, on the operator's own terminal. A capability that
|
|
205
|
-
// auto-approves tool calls must never be discoverable only from a log file.
|
|
206
|
-
logLine("replay.enabled", {
|
|
207
|
-
minSimilarity: opts.minSimilarity,
|
|
208
|
-
allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
|
|
209
|
-
// Who reads what this turn acts on (segmented.md R-PARAM-5). Named at
|
|
210
|
-
// startup because the third state is a replay that silently never runs.
|
|
211
|
-
derive: process.env.ANTHROPIC_API_KEY
|
|
212
|
-
? "this machine (ANTHROPIC_API_KEY)"
|
|
213
|
-
: auth.session && auth.baseUrl
|
|
214
|
-
? "the service"
|
|
215
|
-
: "recorded sample values — sign in, or a scenario with a target will not run",
|
|
216
|
-
intentMatch: opts.intentMatch?.enabled ? "on" : "off",
|
|
217
|
-
source,
|
|
218
|
-
why: "matched prompts will run their calculated scenario — steered steps are auto-approved",
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
return { opts, source };
|
|
210
|
+
});
|
|
211
|
+
globalThis.console = new Console({ stdout: sink, stderr: sink });
|
|
212
|
+
const { reviveDaemon } = await import("../control/daemon.js");
|
|
213
|
+
await reviveDaemon(cwd);
|
|
214
|
+
done();
|
|
222
215
|
}
|
|
223
216
|
async function main() {
|
|
217
|
+
// Before any fetch: started with --use-env-proxy (a launcher written behind a
|
|
218
|
+
// proxy) where NO_PROXY does not name 127.0.0.1, the SessionStart hook's
|
|
219
|
+
// look at its own recorder and its relay would go to the proxy and fail —
|
|
220
|
+
// and a recorder it could not see is one it starts twice. As bir.ts does;
|
|
221
|
+
// the recorder does it again for itself (recorder-main.ts).
|
|
222
|
+
exemptLoopbackFromProxy();
|
|
224
223
|
const argv = process.argv.slice(2);
|
|
225
224
|
if (argv[0] === "logout") {
|
|
225
|
+
const { clearCredentials } = await import("../auth/client.js");
|
|
226
226
|
clearCredentials();
|
|
227
227
|
process.stderr.write("[bir] logged out — cached credentials cleared.\n");
|
|
228
228
|
return;
|
|
229
229
|
}
|
|
230
230
|
if (argv[0] === ENSURE_ARG) {
|
|
231
|
-
|
|
232
|
-
// none, then relay the payload. Always exit 0 (control/ensure-hook.ts).
|
|
233
|
-
process.exitCode = await runEnsureHook(process.cwd());
|
|
231
|
+
await ensure(argv.slice(1));
|
|
234
232
|
return;
|
|
235
233
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
}
|
|
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 ?? ""));
|
|
252
|
-
// Open before anything logs: the journal is what `bir investigate` reads
|
|
253
|
-
// later, and the startup lines are part of the story.
|
|
254
|
-
const journalFile = openJournal(cwd);
|
|
255
|
-
const auth = await buildRecorder();
|
|
256
|
-
const replay = buildReplayOptions(auth, projectRecord(sidecar, cwd)?.replay);
|
|
257
|
-
const server = new ControlServer({
|
|
258
|
-
recorder: auth.recorder,
|
|
259
|
-
cwd,
|
|
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),
|
|
264
|
-
wrappedServers: wrappedServersFor(cwd),
|
|
265
|
-
host: { app: "claude-code" },
|
|
266
|
-
correlationDecision: process.env.BIR_CORRELATION_DECISION === "ask" ? "ask" : "allow",
|
|
267
|
-
noCorrelation: process.env.BIR_NO_CORRELATION === "1",
|
|
268
|
-
deriveRecentResults: nonNegativeInt(process.env.BIR_DERIVE_RECENT_RESULTS, 5),
|
|
269
|
-
intentSources: parseIntentSources(process.env.BIR_INTENT_SOURCES),
|
|
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(),
|
|
279
|
-
});
|
|
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 = {
|
|
297
|
-
url: address.url,
|
|
298
|
-
token: address.token,
|
|
299
|
-
sessionId: address.sessionId,
|
|
300
|
-
pid: process.pid,
|
|
301
|
-
startedAt: Date.now(),
|
|
302
|
-
cwd,
|
|
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();
|
|
318
|
-
logLine("control.listening", {
|
|
319
|
-
url: address.url,
|
|
320
|
-
cwd,
|
|
321
|
-
discovery: discoveryFile,
|
|
322
|
-
journal: journalFile,
|
|
323
|
-
machine: hostname(),
|
|
324
|
-
version: packageVersion(),
|
|
325
|
-
background: daemon ? "yes" : undefined,
|
|
326
|
-
wrapped: wrappedServersFor(cwd).join(",") || "(none)",
|
|
327
|
-
});
|
|
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
|
-
}
|
|
338
|
-
}
|
|
339
|
-
let closing = false;
|
|
340
|
-
async function shutdown() {
|
|
341
|
-
if (closing)
|
|
342
|
-
return;
|
|
343
|
-
closing = true;
|
|
344
|
-
clearInterval(heartbeat);
|
|
345
|
-
await server.close().catch((err) => logLine("control.close_failed", { error: errText(err) }));
|
|
346
|
-
removeDiscovery(cwd);
|
|
347
|
-
logLine("control.stopped", {});
|
|
348
|
-
process.exit(0);
|
|
234
|
+
if (argv[0] === REVIVE_ARG) {
|
|
235
|
+
await revive(argv.slice(1));
|
|
236
|
+
return;
|
|
349
237
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
process.on("uncaughtException", (err) => logLine("control.uncaught", { error: errText(err) }));
|
|
353
|
-
process.on("unhandledRejection", (err) => logLine("control.unhandled_rejection", { error: errText(err) }));
|
|
238
|
+
const { runRecorder } = await import("../control/recorder-main.js");
|
|
239
|
+
await runRecorder();
|
|
354
240
|
}
|
|
355
241
|
void main().catch((err) => {
|
|
242
|
+
if (process.argv[2] === REVIVE_ARG)
|
|
243
|
+
process.exit(0);
|
|
244
|
+
if (process.argv[2] === ENSURE_ARG) {
|
|
245
|
+
process.stderr.write(`[bir] ensure: ${errText(err)}\n`);
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
356
248
|
logLine("control.fatal", { error: errText(err) });
|
|
357
249
|
process.exit(1);
|
|
358
250
|
});
|
package/dist/bin/bir-proxy.d.ts
CHANGED
|
@@ -15,17 +15,32 @@
|
|
|
15
15
|
* --no-correlation never relax tool schemas; join on argument fingerprints
|
|
16
16
|
* --lazy create the upstream on the first host message, not at
|
|
17
17
|
* startup (see upstream/lazy-client.ts for the caveat)
|
|
18
|
-
* --restart restart a dead stdio upstream once
|
|
19
|
-
*
|
|
18
|
+
* --restart restart a dead stdio upstream once (in-flight calls
|
|
19
|
+
* are answered; the host's handshake is replayed to it)
|
|
20
|
+
* --discovery-ms <n> how long to look for a control server (default 10000)
|
|
20
21
|
*
|
|
21
22
|
* Environment:
|
|
22
23
|
* BIR_CONTROL_URL / BIR_CONTROL_TOKEN control server, bypassing discovery
|
|
23
24
|
* BIR_AUTH_URL BaseIn service, for Tier 2 recording
|
|
25
|
+
* BIR_UPSTREAM_HEADERS a remote upstream's headers, as a JSON
|
|
26
|
+
* object of name → value; `--header`
|
|
27
|
+
* wins on a clash. Read once, then
|
|
28
|
+
* removed from the environment: no
|
|
29
|
+
* process started from here sees it
|
|
30
|
+
* BIR_OFF=1 the kill switch (also `bir off`): run
|
|
31
|
+
* the server as if it were not wrapped
|
|
32
|
+
* BIR_PROXY_STAY_UP=1 keep answering after the upstream
|
|
33
|
+
* dies, instead of exiting with it
|
|
24
34
|
* BIR_VERBOSE=1 per-message detail on stderr
|
|
25
35
|
*
|
|
26
36
|
* STDOUT IS SACRED. It is the host's JSON-RPC stream; every log line, every
|
|
27
37
|
* upstream stderr line and every diagnostic goes to stderr instead. A single
|
|
28
38
|
* stray byte here corrupts the session and surfaces somewhere else entirely.
|
|
39
|
+
*
|
|
40
|
+
* STDERR IS NOT FREE EITHER. Claude Code files every stderr line of an MCP
|
|
41
|
+
* server as a "Server stderr" error in its MCP logs, so routine lines (started,
|
|
42
|
+
* spawned, bound, stopped) are `BIR_VERBOSE`-only; what is left on stderr is a
|
|
43
|
+
* decision or a failure somebody may need to read.
|
|
29
44
|
*/
|
|
30
45
|
interface ProxyArgs {
|
|
31
46
|
serverName: string;
|