@basein/runner 0.2.8 → 0.2.10

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 CHANGED
@@ -22,40 +22,70 @@ hooks ──built-ins, prompt, stop──▶ bir-hooks (control server) ──
22
22
 
23
23
  ## Install
24
24
 
25
- On a runner machine, one command does machine, account and project scope:
25
+ One line, pasted into a terminal **opened in the project you start Claude Code
26
+ in**. The console's *Set up the runner* page shows it with a one-time setup
27
+ token filled in, so there is no sign-in step either:
26
28
 
27
29
  ```powershell
28
- .\scripts\install-runner.ps1 C:\work\repo -AuthUrl https://your-basein-service # Windows
30
+ $env:BIR_SETUP_TOKEN="<token>"; irm https://api.bi2202.com/install.ps1 | iex # Windows (PowerShell)
29
31
  ```
30
32
 
31
33
  ```bash
32
- BIR_AUTH_URL=https://your-basein-service ./scripts/install-runner.sh ~/work/repo # macOS/Linux
34
+ curl -fsSL https://api.bi2202.com/install.sh | BIR_SETUP_TOKEN="<token>" sh # macOS / Linux
33
35
  ```
34
36
 
35
- Step by step, for one machine: [docs/quickstart.md](docs/quickstart.md).
36
- For a fleet — packaging, releases, offline installs, updates:
37
- [docs/installRun.md](docs/installRun.md).
37
+ That installs Node if the machine has none (a private copy, never on PATH),
38
+ installs this package, installs Claude Code if it is missing, and runs
39
+ `bir setup`: it signs in, stores the service address, wraps the project's MCP
40
+ servers, wires Claude Code's hooks, adds the scenario server, and starts the
41
+ recorder in the background. It ends with the one thing left to do: `claude`.
42
+ Without a token the same line signs in through the browser. Re-running it is
43
+ safe, and is also how you upgrade. To record another project: `cd` there and
44
+ run `bir setup` (no token needed; the sign-in is kept).
45
+
46
+ The scripts are [scripts/install.ps1](scripts/install.ps1) and
47
+ [scripts/install.sh](scripts/install.sh); the API serves them with its own
48
+ address filled in. Step by step, and what each line does:
49
+ [docs/quickstart.md](docs/quickstart.md). For a fleet — packaging, releases,
50
+ offline installs, updates: [docs/installRun.md](docs/installRun.md).
38
51
 
39
52
  By hand, or when developing BaseInstRunner itself:
40
53
 
41
54
  ```bash
42
55
  npm i -g @basein/runner # or: npm install && npm run build, in a checkout
43
56
  cd the-project-you-record-from
44
- bir install --global # --local from a checkout; plain `bir install` writes a pinned npx entry
57
+ bir setup --auth-url https://api.bi2202.com # everything below, in one go
45
58
  ```
46
59
 
47
- `bir install` rewrites your MCP entries in place, keeping every key (so
48
- `mcp__<server>__<tool>` names never change), and wires Claude Code's hooks. It
49
- stashes the original text of every file first — `bir uninstall` puts it back
50
- byte-for-byte.
60
+ or the pieces `bir setup` is made of:
51
61
 
52
62
  ```bash
53
- export BIR_AUTH_URL=https://your-basein-service # required to record anything
54
- bir login # once; approve it in the browser
55
- bir-hooks # leave running; Ctrl-C to stop
56
- claude # your session, now recorded
63
+ bir login # once; approve it in the browser (or --token <t> from the console)
64
+ bir install --global --replay # --local from a checkout; plain `bir install` writes a pinned npx entry
65
+ bir up # the recorder, in the background; the SessionStart hook does this too
66
+ claude # your session, now recorded
57
67
  ```
58
68
 
69
+ `bir install` rewrites your MCP entries in place, keeping every key (so
70
+ `mcp__<server>__<tool>` names never change), and wires Claude Code's hooks into
71
+ `.claude/settings.local.json` — your file, not the one a team commits; in a git
72
+ repository it is added to `.git/info/exclude`. It stashes the original text of
73
+ every file first — `bir uninstall` puts a file back byte-for-byte when nothing
74
+ else has edited it since, and repairs it entry by entry when something has. A
75
+ project remembers whether it was installed with `--global`, `--local` or the
76
+ pinned-npx default, so a later `bir install` with no flag keeps that shape.
77
+
78
+ **Nothing has to stay open.** The SessionStart hook is `bir-hooks ensure`: it
79
+ starts the recorder for the project when none is running, in the background,
80
+ with its audit log at `~/.baseinstrunner/logs/<project-key>.log`. `bir up`
81
+ starts it by hand, `bir down` stops it, `bir status` shows it. Running
82
+ `bir-hooks` in a terminal still works and is what the hook does for you.
83
+
84
+ The service address is stored by `bir setup` and `bir login` in
85
+ `~/.baseinstrunner/config.json`; `BIR_AUTH_URL` in the environment overrides
86
+ it. A cached session is bound to the service that issued it and is never sent
87
+ anywhere else.
88
+
59
89
  `bir login` prints a link and a short code and waits for you to approve it in
60
90
  the browser — the OAuth device grant, the same shape `gh auth login` uses. It
61
91
  never handles a password, so it works for accounts that only sign in with
@@ -134,10 +164,18 @@ record, calculate, check, enable, read the audit lines — is
134
164
  [docs/calculatedReplayGuide.md](docs/calculatedReplayGuide.md).
135
165
 
136
166
  ```bash
137
- bir install --replay # adds the `bir` MCP server; raises the prompt-hook timeout
138
- bir-hooks # replay is on by default; BIR_REPLAY=0 bir-hooks turns it off
167
+ bir install --replay # adds the `bir` MCP server, pre-approved in .claude/settings.local.json
168
+ bir up # or let the SessionStart hook start the recorder; replay is on by default
169
+ bir replay allow fleet # the servers replay may call unattended — stored for this project
170
+ bir replay off # and back on with `bir replay on`; `bir replay status` shows it
139
171
  ```
140
172
 
173
+ `bir setup` does the first two. The switches are kept per project in
174
+ `~/.baseinstrunner/installed.json`, so a recorder the hook starts — with Claude
175
+ Code's environment, not your terminal's — keeps them; `BIR_REPLAY`,
176
+ `BIR_REPLAY_ALLOW_SERVERS` and `BIR_MIN_STEER_SIMILARITY` in the environment
177
+ still override them, and `bir doctor` says which source is in effect.
178
+
141
179
  Two switches, not one, because of what replay is: **a scenario is a pre-approved
142
180
  list of tool calls with computed arguments.** A steered step is answered
143
181
  `permissionDecision: "allow"`, so a pinned `Bash` or `Edit` runs without the
@@ -184,7 +222,8 @@ instead.
184
222
 
185
223
  | Variable | Effect |
186
224
  |---|---|
187
- | `BIR_AUTH_URL` | BaseIn auth-service — the **API** address (normally `https://api.<your-domain>`), not the docs or app website. **Unset means nothing is recorded** — everything else still works. |
225
+ | `BIR_AUTH_URL` | BaseIn auth-service — the **API** address (normally `https://api.<your-domain>`), not the docs or app website. Overrides the address `bir setup` / `bir login` stored in `~/.baseinstrunner/config.json`. **Neither set means nothing is recorded** — everything else still works. |
226
+ | `BIR_SETUP_TOKEN` | A one-time setup token from the console, read by `bir setup` when `--token` is not given; the install scripts pass it this way. Never inherited by the recorder. |
188
227
  | `BIR_AUTH_DISABLE=1` | Skip authentication (local dev / CI). |
189
228
  | `BIR_CONTROL_PORT` | Preferred control-server port (default 53411). |
190
229
  | `BIR_CONTROL_URL` / `BIR_CONTROL_TOKEN` | Point a proxy at a control server directly, bypassing discovery. Injected into generated config; the only reliable channel for an SDK session. |
@@ -200,7 +239,7 @@ what to do when they register, so there is one switch and no second copy of it:
200
239
  | Variable | Effect |
201
240
  |---|---|
202
241
  | `BIR_REPLAY=0` | Disable replay. It is on by default; nothing below matters while this is `0`. |
203
- | `BIR_REPLAY_ALLOW_SERVERS` | Server keys eligible for **direct** execution. Unset means every wrapped server; setting it is the recommendation. |
242
+ | `BIR_REPLAY_ALLOW_SERVERS` | Server keys eligible for **direct** execution. Unset means the list `bir replay allow` stored for the project, else every wrapped server; narrowing it is the recommendation. |
204
243
  | `BIR_MIN_STEER_SIMILARITY` | Minimum match similarity to replay (default `0.92`, above the service's own `0.9` detection threshold). |
205
244
  | `ANTHROPIC_API_KEY` | **Optional.** Working out what a new request is about — which fleet, which file, which date — is done for you by the service on its own key, as long as you are signed in. Set this only to keep that reading on your machine, on your key. Signed out *and* unset, a scenario whose values change between requests is declined rather than replayed on stale ones. |
206
245
  | `BIR_DERIVE_MODEL` | Derivation model, when you set a key of your own (default `claude-haiku-4-5-20251001`). |
@@ -211,7 +250,10 @@ what to do when they register, so there is one switch and no second copy of it:
211
250
  ### The audit log
212
251
 
213
252
  Every decision is one `key=value` line on **stderr** — never stdout, which is the
214
- host's JSON-RPC stream. There is no file sink to configure; persistence is the
253
+ host's JSON-RPC stream. A recorder started in the background (by `bir up`, `bir
254
+ setup` or the SessionStart hook) writes them to
255
+ `~/.baseinstrunner/logs/<project-key>.log` — `bir status` names the file. One
256
+ started in a terminal writes them to that terminal, and persistence is the
215
257
  shell's job:
216
258
 
217
259
  ```bash
@@ -227,6 +269,7 @@ The governing rule: **a host session never fails because of BaseInstRunner.**
227
269
 
228
270
  | Failure | Behaviour |
229
271
  |---|---|
272
+ | No recorder running at session start | the SessionStart hook starts one in the background; a session is never blocked or failed by it |
230
273
  | Control server unreachable at startup | buffer ≤ 5 s, then Tier 2; logged once |
231
274
  | Control server dies mid-session | the proxy switches to Tier 2 for the rest; run flagged lossy |
232
275
  | Recorder HTTP fails | send dropped and logged; never retried inline |
@@ -242,7 +285,7 @@ The governing rule: **a host session never fails because of BaseInstRunner.**
242
285
 
243
286
  ```bash
244
287
  npm run build # src → dist
245
- npm test # 233 tests: transcript identity, passthrough, correlation, config, replay
288
+ npm test # ~400 tests: transcript identity, passthrough, correlation, config, replay, setup
246
289
  npm run typecheck
247
290
  npm run test:smoke # end-to-end replay with real processes (needs build + pretest first)
248
291
  ```
@@ -32,7 +32,10 @@
32
32
  * server the host is waiting on. Callers get `undefined` and decide.
33
33
  *
34
34
  * Config:
35
- * BIR_AUTH_URL base URL of the BaseIn auth-service (required).
35
+ * BIR_AUTH_URL base URL of the BaseIn auth-service. When unset, the
36
+ * address `bir setup` / `bir login` stored in
37
+ * ~/.baseinstrunner/config.json is used — see
38
+ * {@link resolveAuthUrl}.
36
39
  * BIR_AUTH_DISABLE "1" to skip auth entirely (local dev / CI).
37
40
  */
38
41
  export interface AuthUser {
@@ -46,6 +49,15 @@ export interface AuthSession {
46
49
  /** Epoch milliseconds at which the access token expires. */
47
50
  accessExpiresAt: number;
48
51
  user: AuthUser;
52
+ /**
53
+ * The service that issued this session. `authenticate()` refuses to hand the
54
+ * tokens to any other origin: a stale `BIR_AUTH_URL` in some profile file, or
55
+ * one set by a repository's settings, must degrade to not-recording rather
56
+ * than send a live refresh token and every recording somewhere else. Absent
57
+ * on a file written before this field existed; those are trusted as before
58
+ * and re-bound by the next sign-in.
59
+ */
60
+ authUrl?: string;
49
61
  }
50
62
  export interface AuthenticateOptions {
51
63
  /** Base URL of the auth service. Defaults to BIR_AUTH_URL. */
@@ -73,6 +85,33 @@ export declare function loadCredentials(): AuthSession | undefined;
73
85
  export declare function saveCredentials(session: AuthSession): void;
74
86
  /** Remove the cached session (`bir logout`). No-op if absent. */
75
87
  export declare function clearCredentials(): void;
88
+ /** The persisted settings in ~/.baseinstrunner/config.json. */
89
+ export interface StoredConfig {
90
+ /** The service address 'bir setup' or 'bir login' last signed in against. */
91
+ authUrl?: string;
92
+ }
93
+ /** Read the stored settings; an absent or unreadable file is an empty one. */
94
+ export declare function loadStoredConfig(): StoredConfig;
95
+ /** Merge 'patch' into the stored settings and write them back, mode 0600. */
96
+ export declare function saveStoredConfig(patch: StoredConfig): StoredConfig;
97
+ /**
98
+ * Remember the service address, so no later process needs BIR_AUTH_URL.
99
+ *
100
+ * Called by 'bir setup' and 'bir login' — the two moments a person has just
101
+ * proved the address is right by signing in to it. A wrong address is never
102
+ * stored, because a session was never issued for it.
103
+ */
104
+ export declare function rememberAuthUrl(authUrl: string): void;
105
+ /**
106
+ * The service address this process should talk to, or "" when there is none.
107
+ *
108
+ * Three sources, in order: an explicit option, BIR_AUTH_URL, and the address
109
+ * stored by 'bir setup' / 'bir login'. The third is what makes the install a
110
+ * single step: a recorder started by a Claude Code hook inherits whatever
111
+ * environment the host had, which on a GUI-launched host is not the shell the
112
+ * person set things up in. The stored address is read the same way from every
113
+ * process, so they cannot disagree about where the recording goes.
114
+ */
76
115
  export declare function resolveAuthUrl(opts?: AuthenticateOptions): string;
77
116
  /**
78
117
  * Follow a redirect on the base URL *once*, so a bearer token is never sent to
@@ -32,14 +32,17 @@
32
32
  * server the host is waiting on. Callers get `undefined` and decide.
33
33
  *
34
34
  * Config:
35
- * BIR_AUTH_URL base URL of the BaseIn auth-service (required).
35
+ * BIR_AUTH_URL base URL of the BaseIn auth-service. When unset, the
36
+ * address `bir setup` / `bir login` stored in
37
+ * ~/.baseinstrunner/config.json is used — see
38
+ * {@link resolveAuthUrl}.
36
39
  * BIR_AUTH_DISABLE "1" to skip auth entirely (local dev / CI).
37
40
  */
38
41
  import { spawn } from "node:child_process";
39
42
  import { createInterface } from "node:readline";
40
43
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
41
44
  import { hostname } from "node:os";
42
- import { configDir } from "../control/paths.js";
45
+ import { configDir, configPath, ensureDir } from "../control/paths.js";
43
46
  import { join } from "node:path";
44
47
  import { logLine, errText } from "../util/log.js";
45
48
  import { packageVersion } from "../util/version.js";
@@ -103,16 +106,70 @@ export function clearCredentials() {
103
106
  function isAccessValid(session) {
104
107
  return session.accessExpiresAt - EXPIRY_SKEW_MS > Date.now();
105
108
  }
106
- function toSession(res) {
109
+ function toSession(res, authUrl) {
107
110
  return {
108
111
  accessToken: res.accessToken,
109
112
  refreshToken: res.refreshToken,
110
113
  accessExpiresAt: Date.now() + res.expiresIn * 1000,
111
114
  user: res.user,
115
+ ...(authUrl ? { authUrl: authUrl.replace(/\/+$/, "") } : {}),
112
116
  };
113
117
  }
118
+ /** True when a cached session may be used against `authUrl`. */
119
+ function issuedFor(session, authUrl) {
120
+ if (!session.authUrl)
121
+ return true; // pre-binding file: trusted as before
122
+ return session.authUrl === authUrl.replace(/\/+$/, "");
123
+ }
124
+ /** Read the stored settings; an absent or unreadable file is an empty one. */
125
+ export function loadStoredConfig() {
126
+ try {
127
+ if (!existsSync(configPath()))
128
+ return {};
129
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
130
+ return parsed && typeof parsed === "object" ? parsed : {};
131
+ }
132
+ catch {
133
+ return {};
134
+ }
135
+ }
136
+ /** Merge 'patch' into the stored settings and write them back, mode 0600. */
137
+ export function saveStoredConfig(patch) {
138
+ const next = { ...loadStoredConfig(), ...patch };
139
+ ensureDir(configDir());
140
+ writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
141
+ return next;
142
+ }
143
+ /**
144
+ * Remember the service address, so no later process needs BIR_AUTH_URL.
145
+ *
146
+ * Called by 'bir setup' and 'bir login' — the two moments a person has just
147
+ * proved the address is right by signing in to it. A wrong address is never
148
+ * stored, because a session was never issued for it.
149
+ */
150
+ export function rememberAuthUrl(authUrl) {
151
+ const url = authUrl.replace(/\/+$/, "");
152
+ if (!url)
153
+ return;
154
+ if (loadStoredConfig().authUrl === url)
155
+ return;
156
+ saveStoredConfig({ authUrl: url });
157
+ }
158
+ /**
159
+ * The service address this process should talk to, or "" when there is none.
160
+ *
161
+ * Three sources, in order: an explicit option, BIR_AUTH_URL, and the address
162
+ * stored by 'bir setup' / 'bir login'. The third is what makes the install a
163
+ * single step: a recorder started by a Claude Code hook inherits whatever
164
+ * environment the host had, which on a GUI-launched host is not the shell the
165
+ * person set things up in. The stored address is read the same way from every
166
+ * process, so they cannot disagree about where the recording goes.
167
+ */
114
168
  export function resolveAuthUrl(opts) {
115
- return (opts?.authUrl ?? process.env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
169
+ const explicit = opts?.authUrl ?? process.env.BIR_AUTH_URL;
170
+ if (explicit !== undefined && explicit !== "")
171
+ return explicit.replace(/\/+$/, "");
172
+ return (loadStoredConfig().authUrl ?? "").replace(/\/+$/, "");
116
173
  }
117
174
  /**
118
175
  * Follow a redirect on the base URL *once*, so a bearer token is never sent to
@@ -304,7 +361,7 @@ async function postJson(url, body) {
304
361
  /** Exchange a refresh token for a new session. Undefined on failure. */
305
362
  async function tryRefresh(authUrl, refreshToken) {
306
363
  try {
307
- return toSession(await postJson(`${authUrl}/auth/refresh`, { refreshToken }));
364
+ return toSession(await postJson(`${authUrl}/auth/refresh`, { refreshToken }), authUrl);
308
365
  }
309
366
  catch {
310
367
  return undefined;
@@ -326,11 +383,22 @@ export async function authenticate(opts) {
326
383
  const authUrl = resolveAuthUrl(opts);
327
384
  if (!authUrl) {
328
385
  logLine("auth.unconfigured", {
329
- why: "BIR_AUTH_URL is not set — nothing will be recorded",
386
+ why: "no service address — run `bir setup`, or set BIR_AUTH_URL; nothing will be recorded",
330
387
  });
331
388
  return undefined;
332
389
  }
333
390
  const cached = loadCredentials();
391
+ if (cached && !issuedFor(cached, authUrl)) {
392
+ // The tokens on disk belong to another service. Using them here would
393
+ // hand a live session to whatever this address is; refreshing them here
394
+ // would hand it the refresh token too. Neither is ever the right call.
395
+ logLine("auth.url_mismatch", {
396
+ cached: cached.authUrl,
397
+ requested: authUrl,
398
+ why: "the cached session was issued by another service — run `bir setup` (or `bir login`) for this one",
399
+ });
400
+ return undefined;
401
+ }
334
402
  if (cached && isAccessValid(cached))
335
403
  return cached;
336
404
  if (cached?.refreshToken) {
@@ -513,7 +581,7 @@ export async function deviceLogin(authUrl, opts = {}) {
513
581
  sleep: opts.sleep,
514
582
  signal: opts.signal,
515
583
  });
516
- const session = toSession(res);
584
+ const session = toSession(res, authUrl);
517
585
  saveCredentials(session);
518
586
  return session;
519
587
  }
@@ -536,7 +604,7 @@ export async function tokenLogin(authUrl, token, opts = {}) {
536
604
  if (res.status === 404)
537
605
  throw new DeviceFlowUnsupported();
538
606
  if (res.ok) {
539
- const session = toSession((await res.json()));
607
+ const session = toSession((await res.json()), authUrl);
540
608
  saveCredentials(session);
541
609
  logLine("auth.token_login", { url: authUrl });
542
610
  return session;
@@ -604,7 +672,7 @@ export async function legacyPasswordLogin(authUrl) {
604
672
  return undefined;
605
673
  }
606
674
  try {
607
- const session = toSession(await postJson(`${authUrl}/auth/login`, { email, password }));
675
+ const session = toSession(await postJson(`${authUrl}/auth/login`, { email, password }), authUrl);
608
676
  saveCredentials(session);
609
677
  return session;
610
678
  }
@@ -2,7 +2,11 @@
2
2
  /**
3
3
  * bir-hooks — the Claude Code hook receiver and the control server (Tier 1).
4
4
  *
5
- * Run it in the project you will launch `claude` from, in its own terminal:
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 (required to record anything)
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
@@ -60,5 +65,15 @@
60
65
  * hosts that ignore `updatedInput` on a non-allow decision then fall back to
61
66
  * fingerprint matching, which is lossy under identical concurrent calls.
62
67
  */
63
- export {};
68
+ /**
69
+ * Assemble the replay configuration (docs/calculatedReplay.md §5.3 of the guide).
70
+ *
71
+ * The steer threshold defaults **above** the service's own detection threshold
72
+ * (0.9). Detection decides "do not record this again", which is cheap — the worst
73
+ * case is a lost recording. Steering decides "do not think about this again",
74
+ * which is a much stronger claim, and a 0.90-similar prompt usually covers only
75
+ * part of the live task.
76
+ */
77
+ /** Where the three replay switches came from, for `/health` and `bir doctor`. */
78
+ export type ReplaySource = "env" | "sidecar" | "default";
64
79
  //# sourceMappingURL=bir-hooks.d.ts.map