@basein/runner 0.2.11 → 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.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -1,772 +1,1013 @@
1
- # Packaging BaseInstRunner for runner machines
2
-
3
- **The approach: publish to npm as `@basein/runner`, and install it globally at a
4
- pinned version.** The code already assumed this — the installer writes an `npx`
5
- invocation of its own package name into every wrapped MCP entry — so publishing
6
- was less a new distribution channel than closing a loop that was already there.
7
-
8
- This is the fleet-facing reference: what ships, how a runner machine goes from
9
- bare to recording, how it gets updated, and what to do when there is no registry
10
- to reach. For one person setting up one machine, [quickstart.md](quickstart.md)
11
- is shorter and assumes nothing.
12
-
13
- **Status.** Everything here is implemented except the last item of §7: a
14
- non-interactive grant for machines with no person attached needs a service-side
15
- decision rather than a change here.
16
-
17
- ---
18
-
19
- ## 1. Why this package is an easy case
20
-
21
- | Property | Value | Why it matters for packaging |
22
- |---|---|---|
23
- | Runtime dependencies | **none** | The tarball is self-contained. No lockfile resolution, no transitive CVEs, no native builds, and offline install just works. |
24
- | Module format | pure ESM (`"type": "module"`) | No dual-build gymnastics. |
25
- | Node floor | `>=20` | The one prerequisite — and the served line installs a private copy when the machine has none, or only one the hook must not pin: a version-managed, Homebrew or snap Node, or one with no npm beside it (§5.1). |
26
- | Build | `tsc` → `dist/`, `prepublishOnly` wired | `npm publish` cannot ship a stale `dist/`. |
27
- | Bins | `bir`, `bir-proxy`, `bir-hooks`, `bir-scenario`, all with `#!/usr/bin/env node` | npm creates the shims, including `.cmd`/`.ps1` on Windows. |
28
- | Tarball | 120 files, ~390 kB | `dist/` + types + docs + `scripts/` (the install scripts, so a tarball can bootstrap an offline machine). Sourcemaps and `docs/next.md` are excluded. |
29
-
30
- A zero-dependency, pure-JS, four-bin package is the best-behaved thing npm
31
- distributes. The delivery does not need to be clever.
32
-
33
- ### What is excluded, and why
34
-
35
- `files` carries two negations:
36
-
37
- - `!dist/**/*.map` — 106 files, ~490 kB. The maps carry no `sourcesContent`, and
38
- `src/` is not published, so they cannot resolve a single stack frame. They were
39
- bytes that did nothing on every install and every `npx` cold start.
40
- - `!docs/next.md` — a working scratchpad, not documentation.
41
-
42
- The rest of `docs/` **does** ship, and deliberately: the GitHub repository is
43
- private, so for anyone on a runner machine the tarball is the only place this
44
- documentation exists.
45
-
46
- ---
47
-
48
- ## 2. The options, and why this one
49
-
50
- | Approach | Runner install | Good for | Cost |
51
- |---|---|---|---|
52
- | **npm registry (public)** ← chosen | `npm i -g @basein/runner@0.1.0` | the default; makes the generated entries resolvable | the code is public |
53
- | npm registry (private, scoped) | same, plus an `.npmrc` token | code you cannot publish | every runner needs a read token, and so does `npx` |
54
- | GitHub Packages | same, `registry=https://npm.pkg.github.com` | no second account | GH tokens are clumsier to rotate across a fleet |
55
- | Tarball from a Release | `npm i -g ./basein-runner-0.1.0.tgz` | airgapped runners | no registry resolution — see §6 |
56
- | `npm i -g git+ssh://…#v0.1.0` | one line, no registry | a handful of machines | needs git and a toolchain on every runner; slowest and most fragile |
57
- | Node SEA / bundled binary | copy one file | machines where Node cannot be installed | you own a three-OS build matrix |
58
- | **Docker** | — | **not suitable** | The proxy spawns the host's own MCP servers as child processes, rewrites config in the user's home, and shares a cwd with the agent session. A container isolates exactly what this tool must reach. |
59
-
60
- The package is public; the repository is private. Those are separate decisions
61
- and this project makes them differently. Nothing operational leaks either way —
62
- the service is gated by its address plus credentials.
63
-
64
- ---
65
-
66
- ## 3. Publishing
67
-
68
- ### 3.1 Manifest — done
69
-
70
- `package.json` carries `repository` (npm requires it for provenance) and
71
- `publishConfig.access: public` (a scoped package is private by default, and
72
- `npm publish` on a scope you have no paid plan for otherwise fails).
73
-
74
- There is deliberately **no** `provenance`, and no `--provenance` in the workflow:
75
- npm's attestation requires a *public* repository. Turn both on the day that
76
- changes; nothing else has to move.
77
-
78
- `homepage` and `bugs` are also omitted, for the same reason — they would be dead
79
- links on a public package page.
80
-
81
- ### 3.2 The name is load-bearing
82
-
83
- `PACKAGE_NAME` in [generate.ts](../src/config/generate.ts) is written into every
84
- config file the installer produces. Renaming after runners are deployed points
85
- every previously-installed project at a package that no longer resolves. It is
86
- settled now; treat it as fixed.
87
-
88
- ### 3.3 The publishing credential
89
-
90
- The `basein` org exists on npmjs.com (free tier, public packages), and
91
- `NPM_TOKEN` is set as a repository secret in GitHub Actions.
92
-
93
- It is a **granular** token, not a classic automation one, because npm now steers
94
- you there:
95
-
96
- | Property | Value |
97
- |---|---|
98
- | Name | `github-actions-release` |
99
- | Scope | `basein`, read and write |
100
- | Organizations | no access — publishing needs the scope, not org administration |
101
- | 2FA | bypassed, which is what lets CI publish unattended |
102
- | **Expires** | **2026-10-05** |
103
-
104
- **Two dates to keep.**
105
-
106
- *2026-10-05 — the token dies.* Granular tokens must carry an expiry; npm offers
107
- no perpetual option. When a release starts failing with `ENEEDAUTH` around then,
108
- this is why. Regenerate with the same settings and overwrite the secret.
109
-
110
- *January 2027 — direct token publishing ends.* The token summary says so
111
- outright: "Publish new versions directly (deprecated — ends January 2027)". The
112
- replacement is **trusted publishing**, where the workflow authenticates to npm
113
- over OIDC and there is no stored credential at all — no secret to leak, and no
114
- expiry to forget. Migrating also removes the first date above, so doing it before
115
- 2026-10-05 turns two chores into one.
116
-
117
- Releasing is then a tag:
118
-
119
- ```bash
120
- git tag v0.1.0 && git push origin v0.1.0
121
- ```
122
-
123
- ### 3.4 The workflows — done
124
-
125
- - [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — every push and PR
126
- to main, on Ubuntu, Windows and macOS.
127
- - [`.github/workflows/release.yml`](../.github/workflows/release.yml) — on a
128
- `v*` tag: the same three-platform suite, then publish, then attach the tarball
129
- to a GitHub Release.
130
-
131
- Three platforms is not decoration. `resolveCommand` shells a `.cmd` upstream
132
- through `cmd.exe` with its own quoting, and `normalizePath` reconciles Claude
133
- Code's forward slashes with `process.cwd()`'s backslashes — the failure the code
134
- itself calls "the number one cause of *installed but never runs*". Neither is
135
- exercised on Linux.
136
-
137
- Both workflows run `npm run build` before the tests, because `test:smoke` spawns
138
- the real binaries out of `dist/` while `npm test` only builds `.test-build/`.
139
-
140
- The release job refuses to publish when the tag disagrees with `package.json` —
141
- npm has no undo past 72 hours, and a mismatched tag publishes a version nobody
142
- asked for.
143
-
144
- **One known flake.** `run_scenario finds the session whose plan is armed` loses
145
- an arming race roughly one run in three. It is unrelated to packaging, and the
146
- test steps retry once so it cannot redden a release at random. Remove the retry
147
- the day that test is made deterministic.
148
-
149
- ---
150
-
151
- ## 4. What `bir install` writes
152
-
153
- Three invocation modes, one flag each:
154
-
155
- | Mode | Flag | Entry |
156
- |---|---|---|
157
- | **npx** | *(default)* | `npx -y -p @basein/runner@0.2.9 bir-proxy …` |
158
- | **global** | `--global` | `<node> <prefix>/lib/node_modules/@basein/runner/dist/bin/bir-proxy.js …` (Windows: `<prefix>/node_modules/@basein/runner/…`) |
159
- | **local** | `--local` | `<node> <this checkout>/dist/bin/bir-proxy.js …` |
160
-
161
- `<node>` is the absolute path of the Node that ran the install, symlinks
162
- resolved. Under the served line (§5.1) that is the machine's own Node when it
163
- qualifies (20 or newer, not under a version manager, npm beside it), else the
164
- private Node in `~/.baseinstrunner/node/v<V>/`; `<prefix>` is
165
- `~/.baseinstrunner/npm` either way. By hand it is whatever `npm i -g` used.
166
- npm's global layout differs by platform:
167
- the package is at `<prefix>/lib/node_modules/@basein/runner` on macOS and Linux
168
- and at `<prefix>/node_modules/@basein/runner` on Windows.
169
-
170
- **The npx spec is pinned.** It was not, and that was the one real defect in the
171
- original packaging story: two runners provisioned a week apart could run
172
- different proxy versions against the same service, with nothing in a recording to
173
- say which.
174
-
175
- **`--global` is what a fleet wants**, and it is what `bir setup` uses: from an
176
- installed package it runs `bir install --global --replay` in-process (from a
177
- checkout, `--local`, since `--global` refuses one). It removes the registry from the hot
178
- path entirely: no resolution before the MCP server can start, no dependence on
179
- the MCP host seeing the same `PATH` as the shell that installed things — which
180
- GUI-launched hosts on Windows do not — and it works with no network at all.
181
-
182
- It also survives upgrades. The path
183
- `<prefix>/lib/node_modules/@basein/runner/dist/bin/bir-proxy.js` contains no
184
- version, so `npm i -g @basein/runner@<version>` changes what that path points at
185
- without changing the path. **A pinned-npx install, by contrast, must re-run
186
- `bir install` on every upgrade** to move the pin.
187
-
188
- A flag-less `bir install` keeps the invocation the project was installed with
189
- (`invocation` in its record, §4.3): a `bir install --replay` after `bir setup`
190
- keeps the absolute paths and the command SessionStart hook instead of quietly
191
- turning every entry into a pinned-npx one.
192
-
193
- **An entry whose runner moved is re-wrapped.** `bir install` compares each
194
- wrapped entry — its Node path, its proxy path, or its pinned npx version — with
195
- the package it is running from, and regenerates any that disagree from the
196
- stashed original: `~ fleet — re-wrapping: its runner moved (was …)`. The
197
- scenario entry gets the same check. That is what lets `bir setup` be the upgrade
198
- path even when the package changed prefix (from the machine's npm prefix to the
199
- private one) or the Node moved (a version manager, a reinstall), instead of
200
- printing "already wrapped" about an entry the host would keep spawning from a
201
- path that is gone.
202
-
203
- `--global` refuses to run from a checkout: it asserts a path that outlives the
204
- shell, and a checkout's `dist/` moves with the checkout. Use `--local` when you
205
- mean the checkout on purpose.
206
-
207
- ### 4.1 Hooks go to `.claude/settings.local.json`
208
-
209
- Claude Code's hooks are written to `<project>/.claude/settings.local.json`, not
210
- to the `.claude/settings.json` a team commits. They carry a bearer token and an
211
- absolute path to this machine's Node; neither belongs in a teammate's clone.
212
- Inside a git repository `bir` adds the line `.claude/settings.local.json` to
213
- `.git/info/exclude` — the person's own, untracked list, never a `.gitignore` the
214
- team would see in a diff — and says so. A worktree's `gitdir:` pointer is
215
- followed. Outside a repository, or when the exclude file cannot be written, that
216
- is a note, not a failed install.
217
-
218
- An install that finds `bir` hooks in a legacy `.claude/settings.json` removes
219
- them from there on the way (`- hooks removed from … (they live in
220
- settings.local.json now)`). `bir uninstall` cleans both files and takes the
221
- `.claude/settings.local.json` line out of `.git/info/exclude` again. It restores
222
- a file byte-for-byte only when nothing else edited it since the install; a file
223
- it created — usually `settings.local.json` itself — stays behind, empty of our
224
- entries.
225
-
226
- With `--replay`, `bir install` does two more things: the `bir` scenario server
227
- goes into `.mcp.json` (project scope — never `~/.claude.json`, whose per-project
228
- map Claude Code keys with forward slashes, so an entry written from a Windows
229
- cwd lands under a key it never reads), and `bir` is merged into
230
- `enabledMcpjsonServers` in `settings.local.json`, so Claude Code never shows its
231
- approval dialog for the scenario server. A "No" there leaves a direct replay
232
- with nowhere to deliver its results, and nothing to say so.
233
- `bir uninstall --replay` removes only the scenario server; the proxies, the
234
- hooks and the pre-approval stay as they are.
235
-
236
- ### 4.2 SessionStart is a command hook
237
-
238
- For `--global` and `--local` installs the SessionStart hook is a command hook in
239
- Claude Code's exec form — `command` spawned directly with `args` as its argument
240
- vector, no shell in between, so a path with a space needs no quoting and it runs
241
- the same under Git Bash, PowerShell and cmd.exe:
242
-
243
- ```json
244
- { "type": "command", "command": "<node>", "args": ["<dist>/bin/bir-hooks.js", "ensure"], "timeout": 30 }
245
- ```
246
-
247
- `bir-hooks ensure` reads the SessionStart payload from stdin, starts the
248
- recorder for this directory in the background when none answers `/health`, and
249
- relays the payload to it. It *replaces* the HTTP SessionStart hook rather than
250
- sitting beside it: Claude Code runs every hook of an event in parallel, and an
251
- HTTP hook racing the daemon it depends on would fail on every first session of
252
- the day. It prints nothing on stdout — which Claude Code adds to the model's
253
- context — unless the control server answered with something, and it always exits
254
- 0: a recorder that would not start is one line on stderr and a session that
255
- records Tier 2, exactly what happened before the hook existed.
256
-
257
- Every other event (`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`,
258
- `SessionEnd`, …) stays an HTTP hook aimed at the control server, with the
259
- project's bearer token in its `Authorization` header.
260
-
261
- A pinned-npx install keeps the HTTP SessionStart hook: resolving a package
262
- inside a hook is too slow to start a recorder from, so those installs need a
263
- recorder somebody started (`bir up`).
264
-
265
- `bir doctor` checks that the hook's `command` still exists and, when it does
266
- not, reports:
267
-
268
- ```
269
- the SessionStart hook in <file> points at a Node that is gone (<command>) — run `bir setup` here again
270
- ```
271
-
272
- That is the failure a version manager's per-shell directory produces, and it is
273
- why the bootstrap script installs a private Node rather than pin one that lives
274
- under `fnm_multishells` or `.nvm` (§5.1).
275
-
276
- ### 4.3 One port and one token per project
277
-
278
- `~/.baseinstrunner/installed.json` keeps a record per project directory:
279
-
280
- ```
281
- projects[<normalised cwd>] = { port, token, replay, invocation }
282
- ```
283
-
284
- **Port.** The first project on a machine gets 53411; the next gets the lowest
285
- port above it that no other project holds *and* that is free right now — it is
286
- bound once to check, because 53411 sits inside the ranges Hyper-V and WSL
287
- reserve on Windows, and a recorder told to use an excluded port exits while every
288
- session records Tier 2. Once recorded the port is kept; the hooks name it.
289
- `bir setup --port <n>` (or `bir install --port <n>`) overrides. A *background*
290
- recorder — the one the hook, `bir up` or `bir setup` starts — for a project with
291
- a recorded port (or with `BIR_CONTROL_PORT` set) does not fall back to an
292
- ephemeral one: it exits 1 with `control.port_busy` on its log, whose `fix` reads
293
- "port N is held by another program or excluded by the system — run `bir setup
294
- --port <another>` in this directory", and the hook turns that into one stderr
295
- line, rather than let the project's hooks post to whatever else is listening
296
- there. `bir-hooks` run by hand in a terminal still falls back to an ephemeral
297
- port, and says so on the same log line. Two projects used to share 53411 and one
298
- project's steps landed in the other's run; this is the fix.
299
-
300
- **Invocation.** `global`, `local` or `npx` — what the entries were generated
301
- with, so a later flag-less `bir install` keeps the same shape (§4).
302
-
303
- **Token.** Generated once per project, so a token in one project's settings
304
- file opens that project's recorder and no other. A record written by 0.2.8 has
305
- none and falls back to the machine-wide `token` in the same file.
306
-
307
- **Replay.** The switches `bir replay on|off|allow …` and
308
- `bir setup --allow-servers` store, read by the recorder at start (§5.1).
309
-
310
- ---
311
-
312
- ## 5. Provisioning a runner
313
-
314
- Four scopes, and conflating them is the usual source of "installed but never
315
- runs":
316
-
317
- | Scope | How often | What it does |
318
- |---|---|---|
319
- | **machine** | once | Node ≥ 20 and the package. The served line uses the machine's Node when it qualifies and otherwise puts a private one under `~/.baseinstrunner/node/`; the package goes under `~/.baseinstrunner/npm/`, and `bir` / `bir-hooks` launchers in `~/.baseinstrunner/bin/`, first on the user PATH |
320
- | **account** | once per user | the sign-in → `~/.baseinstrunner/credentials.json` (0600); the service address → `~/.baseinstrunner/config.json`. `bir setup` stores both; so does `bir login` |
321
- | **project** | once per repo | `bir setup` in that directory: `bir install --global --replay` (`--local` from a checkout) rewrites the MCP entries, wires the hooks into `.claude/settings.local.json`, records the port, token and invocation |
322
- | **session** | while recording | the recorder — `bir-hooks` as a background process — started by the SessionStart hook, discovered by `sha256(normalised cwd)` |
323
-
324
- That cwd keying is why the recorder must run in the *same directory* the agent
325
- session runs in. A control server started elsewhere is invisible, and the run
326
- silently degrades to Tier 2. It is also why the hook is what starts it: the hook
327
- already runs in the right directory, with the right environment, at exactly the
328
- right moment.
329
-
330
- ### 5.1 One command
331
-
332
- The console's *Set up the runner* page shows one line, with a fresh one-time
333
- setup token filled in, to paste into a terminal opened in the project folder:
334
-
335
- ```powershell
336
- $env:BIR_SETUP_TOKEN="<token>"; irm https://api.bi2202.com/install.ps1 | iex
337
- ```
338
-
339
- ```cmd
340
- powershell -NoProfile -Command "$env:BIR_SETUP_TOKEN='<token>'; irm https://api.bi2202.com/install.ps1 | iex"
341
- ```
342
-
343
- ```bash
344
- curl -fsSL https://api.bi2202.com/install.sh | BIR_SETUP_TOKEN="<token>" sh
345
- ```
346
-
347
- The first is Windows PowerShell, the second the same line from cmd.exe (a
348
- terminal opened from Start is often cmd), the third macOS and Linux. Without the
349
- variable the line still installs everything; `bir setup` then opens the browser
350
- for the device flow — even under the scripts, where its output is a pipe — and
351
- over SSH prints the link instead (§7).
352
-
353
- The scripts are [`scripts/install.ps1`](../scripts/install.ps1) and
354
- [`scripts/install.sh`](../scripts/install.sh); they replace
355
- `install-runner.ps1` and `install-runner.sh`, and they ship in the tarball. The
356
- API serves copies at `/install.ps1` and `/install.sh` with two placeholders
357
- filled in: `__BIR_AUTH_URL__` (the service's `PUBLIC_API_URL`, else the origin
358
- the request arrived on) and `__BIR_VERSION__` (`RUNNER_VERSION`, default
359
- `latest`). Run straight from the repository or the tarball the placeholders are
360
- unreplaced, which counts as unset: `BIR_AUTH_URL` must then be in the
361
- environment, and the version is `latest`.
362
-
363
- What they do, in order, the same on every platform:
364
-
365
- 0. **Stop before any download** if the current directory is the home folder or
366
- a drive root: "This is your home folder. Open a terminal in the project you
367
- start Claude Code in (cd there) and paste the line again." Nothing is
368
- installed, nobody is signed in and the token is unused, so the fix is
369
- exactly that — `cd` to the project and paste the *same* line
370
- (`BIR_PROJECT=<dir>` names the project instead; the script `cd`s there
371
- first). Move `BIR_SETUP_TOKEN` out of the environment into a local variable,
372
- so npm, the Claude Code installer and the `claude` the person starts next
373
- never see it.
374
- 1. **Node.** A Node ≥ 20 already on PATH is used by its real binary — unless
375
- that binary is one the hook must not pin: under a version manager's
376
- per-shell or per-version directory (nvm, fnm, Volta, asdf, mise and nodenv
377
- on every platform; Scoop on Windows) or, on macOS and Linux, a Homebrew Node
378
- (`*/Cellar/*` — a `brew upgrade` moves the path the hook pinned) or a snap
379
- one. Both scripts also insist on npm's `npm-cli.js` beside the binary
380
- (`lib/node_modules/npm/bin/` on macOS and Linux, `node_modules\npm\bin\` on
381
- Windows). Otherwise a private Node is installed instead: pinned version,
382
- digest embedded in the script and checked, unpacked to
383
- `~/.baseinstrunner/node/v<V>/`, never put on PATH. The SessionStart hook
384
- pins an absolute node path, and a path that changes with the shell is a
385
- hook that fails every session. `BIR_NODE_ARCHIVE=<file>` uses a downloaded
386
- archive instead of nodejs.org.
387
- 2. **The package.** `@basein/runner@<version>` into the private prefix
388
- `~/.baseinstrunner/npm`, always — never the machine's npm prefix, which is
389
- root-owned under a `.pkg` or apt Node and per-version under nvm, Volta, Scoop
390
- or asdf, where a global install vanishes on the next switch.
391
- `BIR_TARBALL=<file.tgz>` instead of the registry. Launchers go into
392
- `~/.baseinstrunner/bin`, which is put first on the user PATH; in PowerShell
393
- `bir` then resolves to `bir.cmd`, so there is no execution-policy change,
394
- ever. The PowerShell window the line was pasted into gets `bir` in place —
395
- the script's last two lines are "bir works in every new terminal (and in
396
- this window when it is PowerShell); `bir doctor` checks the setup" and the
397
- cmd hint below — but the cmd variant
398
- runs the script in a child `powershell.exe`, so a cmd window sees `bir` only
399
- once it is a new window, or after the `set PATH=…` line the script prints
400
- for it. On macOS and Linux the script prints the
401
- `export PATH="$HOME/.baseinstrunner/bin:$PATH"` line for the terminal it ran
402
- in (with `~/.local/bin` added when it just installed Claude Code); every new
403
- terminal has it from the profile.
404
- 3. **Claude Code**, through its official installer, when `claude` is not on
405
- PATH. `BIR_NO_CLAUDE=1` skips it. It needs a Claude subscription and asks to
406
- sign in the first time it starts.
407
- 4. **`bir setup --auth-url <url> --project <cwd>`**, with the token in that
408
- child's environment only. It signs in, stores the service address, runs
409
- `bir install --global --replay`, starts the recorder in the background, and
410
- prints a summary that names the account, the wrapped servers (`(no MCP
411
- servers here)` and "steer mode only" mean none in *any* scope — `.mcp.json`,
412
- and the user and local scopes of `~/.claude.json`), the recorder's pid and
413
- log file, and the one thing left to do: `claude`.
414
-
415
- Both scripts are idempotent, neither prompts (under `curl | sh` stdin is the
416
- script itself), and `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` and
417
- `NODE_EXTRA_CA_CERTS` pass through — to the scripts' own downloads and to every
418
- node and npm they run. The one fetch they cannot reach is the `irm` that fetches
419
- the script itself: Windows PowerShell 5.1's `irm` uses the system (WinINET)
420
- proxy, not `HTTPS_PROXY`. `bir setup` exits 1 when the recorder reports it has
421
- no session to record with, so a provisioning script can gate on the exit code.
422
-
423
- The scripts never reach `bir setup` from the home folder (step 0). `bir setup`
424
- run *by hand* there, with the runner already installed, is different: it keeps
425
- the sign-in, wires nothing, prints the two commands left — `cd <project>` and
426
- `bir setup` — and exits 0. The second needs no token; the session is cached.
427
- That is also how a second project on the same machine is added.
428
-
429
- Fleet switches on `bir setup`: `--allow-servers a,b` stores the servers replay
430
- may call unattended for this project; `bir replay allow|on|off|status` changes
431
- it later. Both live in the project record (§4.3), so a recorder the hook starts
432
- — with Claude Code's environment, not the operator's terminal — keeps them;
433
- `BIR_REPLAY`, `BIR_REPLAY_ALLOW_SERVERS` and `BIR_MIN_STEER_SIMILARITY` in the
434
- recorder's environment still override, and `bir doctor` says which source is in
435
- effect. `--no-replay` records without the scenario server; `--no-daemon` wires
436
- everything and starts nothing; `--no-browser` prints the sign-in link without
437
- opening one.
438
-
439
- Two things the scripts deliberately do not do: supervise the recorder (the hook
440
- does, §5.2) and mint the token (the console does, §7).
441
-
442
- The PowerShell script is **pure ASCII** on purpose. Windows PowerShell 5.1
443
- decodes a BOM-less `.ps1` using the machine's ANSI codepage, and PowerShell
444
- accepts curly quotes as string delimiters — so a UTF-8 em-dash in a comment can
445
- decode into a quote and swallow the rest of the file. It parsed fine on one
446
- machine and failed on another before this was fixed. Keep it ASCII. It also
447
- never calls `exit` — under `iex` that closes the person's window — and it sets
448
- strict mode and `$ErrorActionPreference` inside a function so neither leaks into
449
- the caller's session.
450
-
451
- ### 5.2 Keeping the recorder alive
452
-
453
- Not needed. The SessionStart hook is `bir-hooks ensure` (§4.2): every Claude
454
- Code session in the project starts the recorder when there is none, in the
455
- background, so a recorder that died or was never started is running by the first
456
- prompt. Nothing has to stay open. `bir up` starts it by hand, `bir up --restart`
457
- replaces it, `bir down` stops it — it asks first (`POST /control/stop`, which
458
- finishes the run being written), signals only a pid that answered `/health` with
459
- the token, and says "forced" when it had to — and `bir status` and `bir doctor`
460
- show its pid and log. Two Claude Code windows opened together take a lock, so
461
- only one spawns.
462
-
463
- On POSIX the daemon is a detached spawn with its stdio ignored. On Windows it is
464
- launched through `Start-Process -WindowStyle Hidden` in a short-lived
465
- `powershell.exe`, because a node started under a PowerShell layer carries an
466
- extra copy of its stdout pipe and a daemon that inherited it would hold that pipe
467
- open for days.
468
-
469
- Its audit lines go to `~/.baseinstrunner/logs/<project-key>.log`: the daemon
470
- opens the file itself, appends, and rolls it to `.1` when it starts and the
471
- file is past 8 MB. `bir status` names the file. The old shape still works —
472
- `bir-hooks` in a terminal writes them to that terminal — and is what the hook
473
- does for you.
474
-
475
- ```bash
476
- grep version.skew ~/.baseinstrunner/logs/*.log
477
- grep run.lossy ~/.baseinstrunner/logs/*.log
478
- ```
479
-
480
- What the hook does not give you is restart-on-crash mid-session, which the
481
- proxies already survive (they fall to Tier 2 and say so). A service manager is
482
- optional. If you want one — a machine that records SDK sessions with nobody
483
- starting Claude Code, or a recorder up before the first session — have it run
484
- `bir up` in the project directory at logon, and nothing else: `bir up` returns
485
- once the recorder answers and leaves it running, so a supervisor must not expect
486
- a long-lived process. The three recipes below are recipes — nothing the package
487
- ships or its tests cover.
488
-
489
- **Windows** — a Scheduled Task at logon, with *Start in* set to the project
490
- directory (`bir.cmd` is the launcher the served line writes; a by-hand install
491
- has `bir` wherever npm put it):
492
-
493
- ```powershell
494
- $action = New-ScheduledTaskAction -Execute "$env:USERPROFILE\.baseinstrunner\bin\bir.cmd" -Argument 'up' -WorkingDirectory 'C:\work\repo'
495
- $trigger = New-ScheduledTaskTrigger -AtLogOn
496
- Register-ScheduledTask -TaskName 'bir up (repo)' -Action $action -Trigger $trigger
497
- ```
498
-
499
- **macOS** — a launchd agent at `~/Library/LaunchAgents/com.basein.up.plist`
500
- running `$HOME/.baseinstrunner/bin/bir up` with `WorkingDirectory` set to the
501
- project and `RunAtLoad` (not `KeepAlive`), then `launchctl load` it.
502
-
503
- **Linux** — a systemd user unit, `Type=oneshot` with `RemainAfterExit=yes` (so
504
- the unit does not stop and take the recorder down with it), `ExecStart=%h/.baseinstrunner/bin/bir up`,
505
- `WorkingDirectory=` the project, plus `loginctl enable-linger "$USER"` so it
506
- runs without a login session.
507
-
508
- ### 5.3 Verify
509
-
510
- ```bash
511
- bir doctor --json # exits non-zero if it is not working right now
512
- ```
513
-
514
- `bir doctor` asks the control server which proxies actually registered; it never
515
- reads the config file back. That makes it the right gate for a provisioning
516
- script. `bir status` only describes intent. `bir doctor` also says when there is
517
- no recorder ("no control server for this directory — recording will be Tier 2
518
- (MCP only); `bir up` starts one, and the SessionStart hook starts one with each
519
- session"), when the recorder has nowhere to record to ("the recorder is running
520
- but has nowhere to send steps — run `bir setup` (or set BIR_AUTH_URL and
521
- `bir login`), then `bir up --restart`"), and when the SessionStart hook names a
522
- Node that is gone (§4.2).
523
-
524
- ---
525
-
526
- ## 6. Machines with no registry
527
-
528
- Zero dependencies makes offline install trivial — no `npm ci --offline`, no
529
- vendored `node_modules`. The install script ships in the tarball, takes a local
530
- tarball instead of the registry and a local Node archive instead of nodejs.org,
531
- and can skip the Claude Code installer:
532
-
533
- ```bash
534
- # on a connected machine
535
- npm pack @basein/runner@<version> # or download the Release asset
536
- tar -xzf basein-runner-<version>.tgz package/scripts
537
- # plus the Node archive for the runner's platform from nodejs.org/dist, if the
538
- # runner has no Node ≥ 20 (the script names the pinned version, 24.21.0)
539
-
540
- # on the runner
541
- BIR_AUTH_URL=https://api.example.com \
542
- BIR_TARBALL=./basein-runner-<version>.tgz \
543
- BIR_NODE_ARCHIVE=./node-v24.21.0-linux-x64.tar.gz \
544
- BIR_NO_CLAUDE=1 sh ./package/scripts/install.sh
545
- ```
546
-
547
- ```powershell
548
- $env:BIR_AUTH_URL='https://api.example.com'; $env:BIR_TARBALL='.\basein-runner-<version>.tgz'; $env:BIR_NODE_ARCHIVE='.\node-v24.21.0-win-x64.zip'; $env:BIR_NO_CLAUDE=1; Get-Content -Raw .\package\scripts\install.ps1 | iex
549
- ```
550
-
551
- `<version>` is whatever the registry holds (`npm view @basein/runner version`).
552
- The PowerShell line pipes the file through `iex`, as the served line does: a
553
- `.ps1` invoked by path is subject to the execution policy, and a default client
554
- (Restricted) refuses it with "running scripts is disabled". The script is
555
- written to be run that way — everything inside one function, no `exit`.
556
-
557
- `BIR_AUTH_URL` is required here because the raw script's placeholder is
558
- unreplaced (§5.1). Claude Code has to come from somewhere too; `BIR_NO_CLAUDE=1`
559
- only stops the script from fetching it. The service itself must be reachable —
560
- "no registry" is not "no network" — or the sign-in has nothing to talk to.
561
-
562
- `bir setup` passes `--global`, so the resulting configs contain absolute paths
563
- and never reach for a registry. Nothing further is needed.
564
-
565
- Note that a private repository's Release assets require a GitHub token to fetch,
566
- so the tarball has to be carried to the runner by whatever means you already use.
567
-
568
- ---
569
-
570
- ## 7. Headless authentication
571
-
572
- `bir login` signs in through the browser — the OAuth 2.0 device grant (RFC 8628,
573
- [loginWeb.md](loginWeb.md)) — and caches
574
- `{accessToken, refreshToken, accessExpiresAt, user, authUrl}` at
575
- `~/.baseinstrunner/credentials.json`, mode 0600. `bir setup` does the same
576
- sign-in and stores the service address beside it in
577
- `~/.baseinstrunner/config.json`, so no process afterwards needs `BIR_AUTH_URL`
578
- (it still overrides when set).
579
-
580
- **The setup token.** The served line carries `BIR_SETUP_TOKEN`: a one-time token
581
- minted by the console's *Set up the runner* page — ten-minute TTL, single use,
582
- hashed at rest, and minting a new one retires the person's earlier unused ones.
583
- It travels as an environment-variable assignment rather than an argument so that
584
- it is never on a command line, where a process listing would show it; the
585
- variable's *name* is what keeps the pasted line out of PSReadLine's on-disk
586
- history, which drops lines containing "token" (a `--token` argument would be
587
- dropped just the same). The script moves it out of the environment as its first
588
- statement and hands it to `bir setup` through that child's environment only;
589
- `bir setup` deletes it again the moment it has read it, so the recorder it
590
- starts never holds it. By hand: `bir setup --token <t>` or
591
- `bir login --token <t>`. It signs in one person on one machine with no browser
592
- step; it is not a fleet credential.
593
-
594
- What `bir setup` says about a token that is not fresh:
595
-
596
- - pasted a second time after a successful paste — "the setup token was already
597
- used; keeping the sign-in from before (<email>)", and it goes on; that is the
598
- upgrade path (§8) and the retry after a failure past the sign-in;
599
- - used or expired, with no cached sign-in to keep — "this setup token has
600
- expired or was already used — make a new one on the console's Set up the
601
- runner page and paste it within ten minutes";
602
- - not a token at all — "that is not a valid setup token — copy the whole token
603
- from the console's Set up the runner page".
604
-
605
- "sign-in failed: …" is reserved for the unexpected, and "not signed in — run
606
- `bir setup` again (or `bir login`)." for a sign-in that ended with no session.
607
-
608
- **Credentials are bound to the service that issued them.** `credentials.json`
609
- carries `authUrl`, and `authenticate()` refuses to use or refresh a session
610
- against another origin — `auth.url_mismatch` on the audit log, and the runner
611
- records nothing rather than send a session where it was not minted. A file from
612
- before the binding is trusted as before. So a copied `credentials.json` works
613
- only where `config.json` (or `BIR_AUTH_URL`) names the same service, and a stale
614
- `BIR_AUTH_URL` in a profile file now means "not recording" — `bir setup` warns
615
- when the environment names a different address than the one being set up, or
616
- than the stored one (a bare `bir setup` in a second project included), and says
617
- to remove the variable.
618
-
619
- **For a machine with a person attached, headless is no longer a problem.** The
620
- runner prints a link and a short code and waits; the link opens on *any* device,
621
- so a box with no browser, no display, or only an SSH session is signed in from a
622
- phone. Nothing is typed into the terminal, so nothing lands in a shell history or
623
- a CI transcript. `bir login --no-browser` (or `bir setup --no-browser`) skips
624
- even trying to open one, which is the right flag in a provisioning script (over
625
- SSH it is the default; a pipe on stdout is not — under the install scripts
626
- `bir setup` still opens the browser, because somebody just pasted the line). The
627
- setup token removes even the waiting.
628
-
629
- For machines with **no person attached**, in order of preference:
630
-
631
- 1. **A service account, signed in once.** Approve the device code for a dedicated
632
- account, then copy `credentials.json` to each machine at 0600, together with
633
- a `config.json` naming the same service. The rotating refresh token keeps it
634
- alive and nothing ever prompts. It is still a shared long-lived secret, so
635
- never a person's account.
636
- 2. **No service address at all** for runners that should record nothing: no
637
- address in `~/.baseinstrunner/config.json` and no `BIR_AUTH_URL`. The
638
- recorder then starts with a null recorder (`recorder.disabled` on its log)
639
- and everything else still works; `bir up` and `bir doctor` exit 1 to say so.
640
- `BIR_AUTH_DISABLE=1` is *not* that: it fakes a session and records with a
641
- bogus token — a local-dev and CI switch, not a fleet one.
642
- 3. **Ask the service for a non-interactive grant** — a long-lived runner token,
643
- created in the console and read from an env var. This remains the right answer
644
- for a large fleet and is still a service-side change; the device grant and the
645
- setup token make it less urgent, not unnecessary.
646
-
647
- `bir logout` revokes the session server-side before clearing the file, so
648
- decommissioning a runner ends its access rather than merely tidying the disk.
649
-
650
- `BIR_HOME` relocates all of this if state belongs somewhere other than
651
- `~/.baseinstrunner`.
652
-
653
- ---
654
-
655
- ## 8. Updating a runner
656
-
657
- Paste the line again — the same line, used token and all. It installs the
658
- version the service serves (`RUNNER_VERSION`, default `latest`), keeps the
659
- sign-in it already has ("the setup token was already used; keeping the sign-in
660
- from before (<email>)"), and runs `bir setup`, which re-wraps whatever moved and
661
- restarts the recorder. For a machine installed by hand: the new package, then
662
- `bir setup` in each recorded project.
663
-
664
- ```bash
665
- npm i -g @basein/runner@<version> && cd ~/work/repo && bir setup
666
- ```
667
-
668
- Two rules used to follow, and neither is a step now:
669
-
670
- - **Restarting the recorder.** A running process is still the old code, and
671
- this was the step people forgot. `bir setup`, `bir up` and the SessionStart
672
- hook all restart a running recorder whose version is not the package's —
673
- provided no session is mid-run. A recorder still serving an open Claude Code
674
- session is left alone; `bir setup` says so ("recorder 0.2.8 is still serving
675
- an open Claude Code session; it restarts to 0.2.9 with the next session") and
676
- the next session's hook does it. `bir up --restart` forces it now.
677
- - **Re-running `bir install`.** `bir setup` does. A `--global` entry whose path
678
- did not change needs nothing (§4); one whose runner moved — a new prefix, a
679
- new Node — is re-wrapped from the stashed original; a pinned-npx entry always
680
- has its pin moved.
681
-
682
- Rolling back: install the older version by hand and run `bir setup`.
683
-
684
- ### The half-upgraded machine
685
-
686
- This is the update failure that looks like success: the package is new, but a
687
- proxy is still the old code because a config points elsewhere, or the recorder
688
- is a process that outlived the upgrade. Everything keeps working, and quietly
689
- runs code you did not ship.
690
-
691
- The restart-on-skew above closes the common case. For the rest, a proxy reports
692
- its version when it registers, the control server compares it with its own, and
693
- a mismatch is one line on the audit log:
694
-
695
- ```
696
- [bir] … version.skew server=fleet proxy=0.0.9 hooks=0.1.0 fix="restart bir-hooks after upgrading, …"
697
- ```
698
-
699
- `bir doctor --json` carries the same facts structurally — under `health`,
700
- `hooksVersion` and a `version` on each entry of `registeredProxies` — so a
701
- provisioning script can assert agreement rather than grep a log. A proxy too old
702
- to report a version is not treated as skewed: silence, not a false alarm.
703
-
704
- ---
705
-
706
- ## 9. Per-release verification
707
-
708
- CI does this on three platforms. To do it by hand on a clean machine:
709
-
710
- ```bash
711
- npm run build && npm test # ~400 tests (npm runs pretest first)
712
- npm run test:smoke # real processes: hooks + proxy + scenario over stdio;
713
- # needs the build and the pretest above
714
- npm pack && npm i -g ./basein-runner-*.tgz
715
- cd /tmp/scratch && bir setup --auth-url <api> --token <t> # or BIR_AUTH_URL=<api> bir login, then bir install --global && bir up
716
- bir doctor --json # must exit 0
717
- bir uninstall # must restore the config byte-for-byte
718
- ```
719
-
720
- `test:smoke` is the one that matters for packaging: it is the only test that
721
- crosses process boundaries, which is exactly where a bad `files` list, a missing
722
- shebang, or a broken bin mapping shows up.
723
-
724
- Sandbox a manual run with `BIR_HOME` and `BIR_CLAUDE_JSON` so it cannot touch
725
- your real Claude Code configuration. A fresh `BIR_HOME` has no sign-in, which is
726
- why the recipe signs in: a bare `bir install --global && bir up` there starts a
727
- recorder with nothing to record to, `bir up` ends with "it has no session to
728
- record with" and exit 1, and `bir doctor --json` exits 1 for the same reason.
729
- `bir uninstall` restores byte-for-byte only what nothing else edited since the
730
- install; a file it created stays behind, empty of our entries.
731
-
732
- ---
733
-
734
- ## 10. Where things stand
735
-
736
- | # | Item | State |
737
- |---|---|---|
738
- | 1 | Name settled; `PACKAGE_NAME` aligned | done |
739
- | 2 | `repository` + `publishConfig`; provenance deferred (private repo) | done |
740
- | 3 | Tarball trimmed — no dead sourcemaps, no scratchpad; `scripts/` ships | done |
741
- | 4 | npx spec pinned; `--global` added; moved runners re-wrapped | done |
742
- | 5 | CI and release workflows, three platforms | done |
743
- | 6 | `scripts/install.{ps1,sh}`, served by the API at `/install.ps1` and `/install.sh` (replace `install-runner.*`) | done |
744
- | 7 | Version-skew detection on registration; recorder restarts itself between sessions | done |
745
- | 8 | UTF-8 BOM tolerated in MCP configs | done |
746
- | 9 | npm org `basein`, `NPM_TOKEN` secret | done |
747
- | 10 | `bir setup`: sign-in, wrap, replay, recorder in one command | done |
748
- | 11 | Background recorder started by the SessionStart hook (`bir-hooks ensure`); `bir up` / `bir down`; log per project | done |
749
- | 12 | Hooks in `.claude/settings.local.json` + `.git/info/exclude`; one port and token per project | done |
750
- | 13 | Setup token (`BIR_SETUP_TOKEN`) for the first sign-in; credentials bound to their service; address in `config.json` | done |
751
- | 14 | Tag `v0.2.9` to publish | ready |
752
- | 15 | Non-interactive auth grant for fleets | **needs the service** |
753
- | 16 | Migrate to trusted publishing before Jan 2027 (§3.3) | scheduled |
754
-
755
- Item 15 is the only one that needs somebody else.
756
-
757
- ---
758
-
759
- ## Appendix: the BOM
760
-
761
- `bir` used to reject any MCP config saved with a UTF-8 byte order mark. Notepad
762
- writes one, so does VS Code's "UTF-8 with BOM", and so does PowerShell's
763
- `Out-File -Encoding utf8`. `readFileSync(path, "utf8")` decodes the BOM to U+FEFF
764
- rather than consuming it, and `JSON.parse` then rejects the file.
765
-
766
- The failure mode was the bad part: `bir install` logged the parse error and
767
- **still reported success**, so an operator saw a green run and no wrapping. On
768
- Windows runners this was a matter of time.
769
-
770
- `parseJsonFile` now strips it, which covers every user-authored config read.
771
- Restoring is unaffected — the uninstall backup stashes raw text on a separate
772
- path, so a BOM'd file comes back with its BOM intact.
1
+ # Packaging BaseInstRunner for runner machines
2
+
3
+ **The approach: publish to npm as `@basein/runner`, and install it globally at a
4
+ pinned version.** The code already assumed this — the installer wrote an `npx`
5
+ invocation of its own package name into every wrapped MCP entry (it still does
6
+ with `--npx`, §4) — so publishing was less a new distribution channel than
7
+ closing a loop that was already there.
8
+
9
+ This is the fleet-facing reference: what ships, how a runner machine goes from
10
+ bare to recording, how it gets updated, and what to do when there is no registry
11
+ to reach. For one person setting up one machine, [quickstart.md](quickstart.md)
12
+ is shorter and assumes nothing.
13
+
14
+ **Status.** Everything here is implemented except the last item of §7: a
15
+ non-interactive grant for machines with no person attached needs a service-side
16
+ decision rather than a change here.
17
+
18
+ ---
19
+
20
+ ## 1. Why this package is an easy case
21
+
22
+ | Property | Value | Why it matters for packaging |
23
+ |---|---|---|
24
+ | Runtime dependencies | **none** | The tarball is self-contained. No lockfile resolution, no transitive CVEs, no native builds, and offline install just works. |
25
+ | Module format | pure ESM (`"type": "module"`) | No dual-build gymnastics. |
26
+ | Node floor | `>=20` | The one prerequisite — and the served line installs a private copy when the machine has none, or only one the hook must not pin: a version-managed, Homebrew or snap Node, or one with no npm beside it (§5.1). |
27
+ | Build | `tsc` → `dist/`, `prepublishOnly` wired | `npm publish` cannot ship a stale `dist/`. |
28
+ | Bins | `bir`, `bir-proxy`, `bir-hooks`, `bir-scenario`, all with `#!/usr/bin/env node` | npm creates the shims, including `.cmd`/`.ps1` on Windows. |
29
+ | Tarball | 161 files, ~910 kB | `dist/` + types + docs + `production.md` + `scripts/` (the install scripts, so a tarball can bootstrap an offline machine). Sourcemaps and `docs/next.md` are excluded. |
30
+
31
+ A zero-dependency, pure-JS, four-bin package is the best-behaved thing npm
32
+ distributes. The delivery does not need to be clever.
33
+
34
+ ### What is excluded, and why
35
+
36
+ `files` carries two negations:
37
+
38
+ - `!dist/**/*.map` — 146 files, ~1.2 MB. The maps carry no `sourcesContent`, and
39
+ `src/` is not published, so they cannot resolve a single stack frame. They were
40
+ bytes that did nothing on every install and every `npx` cold start.
41
+ - `!docs/next.md` — a working scratchpad, not documentation.
42
+
43
+ The rest of `docs/` **does** ship, and deliberately: the GitHub repository is
44
+ private, so for anyone on a runner machine the tarball is the only place this
45
+ documentation exists.
46
+
47
+ ---
48
+
49
+ ## 2. The options, and why this one
50
+
51
+ | Approach | Runner install | Good for | Cost |
52
+ |---|---|---|---|
53
+ | **npm registry (public)** ← chosen | `npm i -g @basein/runner@0.1.0` | the default; makes the generated entries resolvable | the code is public |
54
+ | npm registry (private, scoped) | same, plus an `.npmrc` token | code you cannot publish | every runner needs a read token, and so does `npx` |
55
+ | GitHub Packages | same, `registry=https://npm.pkg.github.com` | no second account | GH tokens are clumsier to rotate across a fleet |
56
+ | Tarball from a Release | `npm i -g ./basein-runner-0.1.0.tgz` | airgapped runners | no registry resolution — see §6 |
57
+ | `npm i -g git+ssh://…#v0.1.0` | one line, no registry | a handful of machines | needs git and a toolchain on every runner; slowest and most fragile |
58
+ | Node SEA / bundled binary | copy one file | machines where Node cannot be installed | you own a three-OS build matrix |
59
+ | **Docker** | — | **not suitable** | The proxy spawns the host's own MCP servers as child processes, rewrites config in the user's home, and shares a cwd with the agent session. A container isolates exactly what this tool must reach. |
60
+
61
+ The package is public; the repository is private. Those are separate decisions
62
+ and this project makes them differently. Nothing operational leaks either way —
63
+ the service is gated by its address plus credentials.
64
+
65
+ ---
66
+
67
+ ## 3. Publishing
68
+
69
+ ### 3.1 Manifest — done
70
+
71
+ `package.json` carries `repository` (npm requires it for provenance) and
72
+ `publishConfig.access: public` (a scoped package is private by default, and
73
+ `npm publish` on a scope you have no paid plan for otherwise fails).
74
+
75
+ There is deliberately **no** `provenance`, and no `--provenance` in the workflow:
76
+ npm's attestation requires a *public* repository. Turn both on the day that
77
+ changes; nothing else has to move.
78
+
79
+ `homepage` and `bugs` are also omitted, for the same reason — they would be dead
80
+ links on a public package page.
81
+
82
+ ### 3.2 The name is load-bearing
83
+
84
+ `PACKAGE_NAME` in [generate.ts](../src/config/generate.ts) is written into every
85
+ config file the installer produces. Renaming after runners are deployed points
86
+ every previously-installed project at a package that no longer resolves. It is
87
+ settled now; treat it as fixed.
88
+
89
+ ### 3.3 The publishing credential
90
+
91
+ The `basein` org exists on npmjs.com (free tier, public packages), and
92
+ `NPM_TOKEN` is set as a repository secret in GitHub Actions.
93
+
94
+ It is a **granular** token, not a classic automation one, because npm now steers
95
+ you there:
96
+
97
+ | Property | Value |
98
+ |---|---|
99
+ | Name | `github-actions-release` |
100
+ | Scope | `basein`, read and write |
101
+ | Organizations | no access — publishing needs the scope, not org administration |
102
+ | 2FA | bypassed, which is what lets CI publish unattended |
103
+ | **Expires** | **2026-10-05** |
104
+
105
+ **Two dates to keep.**
106
+
107
+ *2026-10-05 — the token dies.* Granular tokens must carry an expiry; npm offers
108
+ no perpetual option. When a release starts failing with `ENEEDAUTH` around then,
109
+ this is why. Regenerate with the same settings and overwrite the secret.
110
+
111
+ *January 2027 — direct token publishing ends.* The token summary says so
112
+ outright: "Publish new versions directly (deprecated — ends January 2027)". The
113
+ replacement is **trusted publishing**, where the workflow authenticates to npm
114
+ over OIDC and there is no stored credential at all — no secret to leak, and no
115
+ expiry to forget. Migrating also removes the first date above, so doing it before
116
+ 2026-10-05 turns two chores into one.
117
+
118
+ Releasing is then a tag:
119
+
120
+ ```bash
121
+ git tag v0.1.0 && git push origin v0.1.0
122
+ ```
123
+
124
+ ### 3.4 The workflows — done
125
+
126
+ - [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — every push and PR
127
+ to main, on Ubuntu, Windows and macOS.
128
+ - [`.github/workflows/release.yml`](../.github/workflows/release.yml) — on a
129
+ `v*` tag: the same three-platform suite, then publish, then attach the tarball
130
+ to a GitHub Release.
131
+
132
+ Three platforms is not decoration. `resolveCommand` shells a `.cmd` upstream
133
+ through `cmd.exe` with its own quoting, and `normalizePath` reconciles Claude
134
+ Code's forward slashes with `process.cwd()`'s backslashes — the failure the code
135
+ itself calls "the number one cause of *installed but never runs*". Neither is
136
+ exercised on Linux.
137
+
138
+ Both workflows run `npm run build` before the tests, because `test:smoke` spawns
139
+ the real binaries out of `dist/` while `npm test` only builds `.test-build/`.
140
+
141
+ The release job refuses to publish when the tag disagrees with `package.json` —
142
+ npm has no undo past 72 hours, and a mismatched tag publishes a version nobody
143
+ asked for.
144
+
145
+ **One known flake.** `run_scenario finds the session whose plan is armed` loses
146
+ an arming race roughly one run in three. It is unrelated to packaging, and the
147
+ test steps retry once so it cannot redden a release at random. Remove the retry
148
+ the day that test is made deterministic.
149
+
150
+ ---
151
+
152
+ ## 4. What `bir install` writes
153
+
154
+ Three invocation modes, one flag each:
155
+
156
+ | Mode | Flag | Entry |
157
+ |---|---|---|
158
+ | **global** | `--global`; the flag-less default when `bir` runs from an installed package | `<node> [flags] <prefix>/lib/node_modules/@basein/runner/dist/bin/bir-proxy.js …` (Windows: `<prefix>/node_modules/@basein/runner/…`) |
159
+ | **npx** | `--npx`; the flag-less default only from a checkout or from npx's cache | `npx --prefer-offline -y -p @basein/runner@0.2.9 bir-proxy …` — plain `npx` on Windows too; only the npx SessionStart hook runs through `cmd /c` there |
160
+ | **local** | `--local` | `<node> [flags] <this checkout>/dist/bin/bir-proxy.js …` |
161
+
162
+ Where the entries are written — this project's local scope in `~/.claude.json`,
163
+ not `.mcp.json` — is §4.4.
164
+
165
+ `<node>` is the Node that ran the install, by absolute path: on macOS and Linux
166
+ with symlinks resolved, except that a Homebrew keg is named by Homebrew's stable
167
+ `opt/<formula>/bin/node` link, which a `brew upgrade` keeps; on Windows the path
168
+ it was launched through (nvm-windows' and Scoop's stable links), except fnm's
169
+ per-shell directory. A Node that a version manager will move gets a one-line
170
+ warning at install. `[flags]` are `--use-system-ca` when the install ran with
171
+ it, and `--use-env-proxy` when it did or a proxy is set — either way only while
172
+ `NO_PROXY` exempts `127.0.0.1` (§5.1). Under the served line that Node is the
173
+ machine's own when it qualifies (20 or newer, not under a version manager, npm
174
+ beside it), else the private Node in `~/.baseinstrunner/node/v<V>/`; `<prefix>`
175
+ is `~/.baseinstrunner/npm` either way. By hand it is whatever `npm i -g` used.
176
+ npm's global layout differs by platform:
177
+ the package is at `<prefix>/lib/node_modules/@basein/runner` on macOS and Linux
178
+ and at `<prefix>/node_modules/@basein/runner` on Windows.
179
+
180
+ **The npx spec is pinned.** It was not, and that was the one real defect in the
181
+ original packaging story: two runners provisioned a week apart could run
182
+ different proxy versions against the same service, with nothing in a recording to
183
+ say which. Pinned is also what makes `--prefer-offline` safe: a cached copy of an
184
+ exact version starts without asking the registry.
185
+
186
+ **Absolute paths are what a fleet wants**, and they are the default: a flag-less
187
+ `bir install` from an installed package writes `global` entries, and `bir setup`
188
+ runs `bir install --global --replay` in-process (from a checkout `--local`, since
189
+ `--global` refuses one; run as `npx @basein/runner setup`, pinned npx, never a
190
+ path into npx's cache). It removes the registry from the hot
191
+ path entirely: no resolution before the MCP server can start, no dependence on
192
+ the MCP host seeing the same `PATH` as the shell that installed things — which
193
+ GUI-launched hosts on Windows do not — and it works with no network at all.
194
+
195
+ It also survives upgrades. The path
196
+ `<prefix>/lib/node_modules/@basein/runner/dist/bin/bir-proxy.js` contains no
197
+ version, so `npm i -g @basein/runner@<version>` changes what that path points at
198
+ without changing the path. **A pinned-npx install, by contrast, must re-run
199
+ `bir install` on every upgrade** to move the pin.
200
+
201
+ A flag-less `bir install` keeps the invocation the project was installed with
202
+ (`invocation` in its record, §4.3): a `bir install --replay` after `bir setup`
203
+ keeps the absolute paths instead of quietly turning every entry into a
204
+ pinned-npx one. The one exception is a remembered npx that nobody asked for with
205
+ `--npx` — the flag-less default of older versions — which an installed `bir`
206
+ turns into absolute paths.
207
+
208
+ **An entry whose runner moved is re-wrapped.** `bir install` regenerates each
209
+ wrapped entry and compares it with what is there — its Node path and flags, its
210
+ proxy path or pinned npx version, its control port, the upstream entry it
211
+ wraps — and rewrites any that differ from its source:
212
+ `~ fleet — re-wrapping: its runner moved (was …)`. The
213
+ scenario entry gets the same check. That is what lets `bir setup` be the upgrade
214
+ path even when the package changed prefix (from the machine's npm prefix to the
215
+ private one) or the Node moved (a version manager, a reinstall), instead of
216
+ printing "already wrapped" about an entry the host would keep spawning from a
217
+ path that is gone.
218
+
219
+ `--global` refuses to run from a checkout: it asserts a path that outlives the
220
+ shell, and a checkout's `dist/` moves with the checkout. Use `--local` when you
221
+ mean the checkout on purpose.
222
+
223
+ ### 4.1 Hooks go to `.claude/settings.local.json`
224
+
225
+ Claude Code's hooks are written to `<project>/.claude/settings.local.json`, not
226
+ to the `.claude/settings.json` a team commits. They carry a bearer token and an
227
+ absolute path to this machine's Node; neither belongs in a teammate's clone.
228
+ Inside a git repository `bir` adds the line `.claude/settings.local.json` to
229
+ `.git/info/exclude` — the person's own, untracked list, never a `.gitignore` the
230
+ team would see in a diff — and says so. A linked worktree's `gitdir:` pointer
231
+ is followed to the repository's common exclude file, the only one git reads for
232
+ it; nothing is created inside the worktree. Outside a repository, or when the
233
+ exclude file cannot be written, that is a note, not a failed install.
234
+
235
+ An install that finds `bir` hooks in a legacy `.claude/settings.json` removes
236
+ them from there on the way (`- hooks removed from … (they live in
237
+ settings.local.json now)`). `bir uninstall` cleans both files and takes the
238
+ `.claude/settings.local.json` line out of `.git/info/exclude` again — unless the
239
+ line was there before the install. It restores
240
+ a file byte-for-byte only when nothing else edited it since the install; a file
241
+ it created — usually `settings.local.json` itself — stays behind, empty of our
242
+ entries.
243
+
244
+ With `--replay`, `bir install` also adds the `bir` scenario server, beside the
245
+ wrapped entries in this project's local scope (§4.4). Claude Code never asks
246
+ before it starts a local-scope server, so there is no `enabledMcpjsonServers`
247
+ pre-approval any more; an install finds an older one in `settings.local.json`
248
+ and takes it out. Without `--replay`, in a project with no scenario server,
249
+ replay is stored *off* (§4.3). `bir uninstall --replay` removes only the
250
+ scenario server and switches replay off, restarting an idle recorder so that
251
+ applies at once; the proxies and the hooks stay as they are, and
252
+ `bir install --replay` turns both back on.
253
+
254
+ ### 4.2 SessionStart is a command hook
255
+
256
+ Every install writes the SessionStart hook as a command hook in
257
+ Claude Code's exec form — `command` spawned directly with `args` as its argument
258
+ vector, no shell in between, so a path with a space needs no quoting and it runs
259
+ the same under Git Bash, PowerShell and cmd.exe:
260
+
261
+ ```json
262
+ { "type": "command", "command": "<node>", "args": ["<dist>/bin/bir-hooks.js", "ensure"], "timeout": 15 }
263
+ ```
264
+
265
+ (`args` begins with the node flags of §4 when there are any.) A pinned-npx
266
+ install writes the same hook through npx —
267
+ `npx --prefer-offline -y -p @basein/runner@<v> bir-hooks ensure --budget-ms 8000`,
268
+ inside `cmd /c` on Windows — so npx's start-up is paid once per session and still
269
+ fits the 15 s. The hook's own work is capped at 11 s (8 s for the npx form) and
270
+ never waits on the BaseIn service.
271
+
272
+ `bir-hooks ensure` reads the SessionStart payload from stdin, starts the
273
+ recorder for this directory in the background when none answers `/health`, and
274
+ relays the payload to it. It *replaces* the HTTP SessionStart hook rather than
275
+ sitting beside it: Claude Code runs every hook of an event in parallel, and an
276
+ HTTP hook racing the daemon it depends on would fail on every first session of
277
+ the day. It prints nothing on stdout — which Claude Code adds to the model's
278
+ context — unless the control server answered with something, and it always exits
279
+ 0: a recorder that would not start is one line on stderr and a session that
280
+ records Tier 2, exactly what happened before the hook existed.
281
+
282
+ Every other event (`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`,
283
+ `SessionEnd`, …) stays an HTTP hook aimed at the control server, with the
284
+ project's bearer token in its `Authorization` header. Their timeouts are the
285
+ longest a hung service could make a person wait, so they are short:
286
+ `UserPromptSubmit` 10 s, `PreToolUse` 15 s, `PostToolUse`/`PostToolUseFailure`,
287
+ `Stop`/`StopFailure` 5 s, `SubagentStart`/`SubagentStop` and `SessionEnd` 3 s.
288
+ The control server answers each well inside its timeout, `{}` on an overrun
289
+ (see [production.md](../production.md)). Re-running `bir setup` rewrites
290
+ the timeouts of an older install.
291
+
292
+ `bir doctor` checks that the hook's `command` and script still exist and, when
293
+ the Node does not, reports:
294
+
295
+ ```
296
+ the SessionStart hook in <file> points at a Node that is gone (<command>) — run `bir setup` here again
297
+ ```
298
+
299
+ That is the failure a version manager's per-shell directory produces, and it is
300
+ why the bootstrap script installs a private Node rather than pin one that lives
301
+ under `fnm_multishells` or `.nvm` (§5.1).
302
+
303
+ ### 4.3 One port and one token per project
304
+
305
+ `~/.baseinstrunner/installed.json` keeps a record per project directory:
306
+
307
+ ```
308
+ projects[<normalised cwd>] = { port, token, replay, invocation, … }
309
+ ```
310
+
311
+ The rest is bookkeeping for `bir uninstall`: whether the exclude line was
312
+ added by the install, and every `.claude.json` the install wrote to
313
+ (`CLAUDE_CONFIG_DIR` moves it, §4.4).
314
+
315
+ **Port.** The first project on a machine gets 53411; the next gets the lowest
316
+ port above it that no other project holds *and* that is free right now — it is
317
+ bound once to check, because 53411 sits inside the ranges Hyper-V, WSL and
318
+ Docker reserve on Windows, and a recorder told to use an excluded port exits.
319
+ Once recorded the port is kept; the hooks name it.
320
+ `bir setup --port <n>` (or `bir install --port <n>`) overrides. A *background*
321
+ recorder — the one the hook, `bir up` or `bir setup` starts — for a project with
322
+ a recorded port (or with `BIR_CONTROL_PORT` set) does not fall back to an
323
+ ephemeral one, rather than let the project's hooks post to whatever else is
324
+ listening there. It finds out who holds the port — the system's reservation,
325
+ another project's recorder, another program — and exits 1 with
326
+ `control.port_busy` on its log; its `fix` reads "run `bir setup --port <another
327
+ port>` in <project> (below 49152 avoids the ranges the system reserves); the
328
+ next Claude Code session then uses it". It also writes
329
+ `~/.baseinstrunner/control/<key>.port.json`, which `bir doctor` reports as a
330
+ problem ("the recorder could not take port N: …") and `bir status` as a `Port`
331
+ line, and the hook adds one stderr line. When the holder is this project's own
332
+ recorder, the new process steps aside and exits 0 (`control.already_running`).
333
+ `bir setup` without `--port` repairs this itself: a recorded port this machine
334
+ no longer lets it bind — reservations move across reboots — is replaced by the
335
+ lowest free one ("port N is not available on this machine; this project moves
336
+ to M"), and the hooks and entries are rewritten with it. `bir-hooks` run by hand
337
+ in a terminal still falls back to an ephemeral port. Two projects used to share
338
+ 53411 and one project's steps landed in the other's run; this is the fix.
339
+
340
+ **A port change with a window open.** Claude Code reads a session's hooks when
341
+ the session starts, so a window opened before the port moved still posts to the
342
+ old one. That recorder keeps serving it, as a *superseded* recorder — `bir
343
+ status` lists it, it logs to `logs/<key>.superseded-<port>.log` — while a second
344
+ one serves the new port. It exits by itself once it has had nothing to serve for
345
+ a day (`BIR_SUPERSEDED_EXIT_MS`); `bir down` stops both.
346
+
347
+ **Invocation.** `global`, `local` or `npx` — what the entries were generated
348
+ with, so a later flag-less `bir install` keeps the same shape (§4).
349
+
350
+ **Token.** Generated once per project, so a token in one project's settings
351
+ file opens that project's recorder and no other. A record written by 0.2.8 has
352
+ none and falls back to the machine-wide `token` in the same file. A running
353
+ recorder re-reads the project's current token from this file (at most every
354
+ 3 s), so a reinstall that changes it needs no restart.
355
+
356
+ **Replay.** The switches `bir replay on|off|allow …` and
357
+ `bir setup --allow-servers` store, read by the recorder at start (§5.1).
358
+ `bir setup --no-replay` and `bir uninstall --replay` store replay off, and so
359
+ does a `bir install` without `--replay` where no scenario server is installed;
360
+ an install with `--replay` turns such an off back on, but never a
361
+ `bir replay off` of your own.
362
+
363
+ ### 4.4 Wrapped entries go to the local scope
364
+
365
+ `bir install` does not rewrite `.mcp.json` or the user scope. It writes a
366
+ wrapped copy of each server, under the same key, into this project's *local
367
+ scope*: `projects[<key>].mcpServers` in `~/.claude.json` (in
368
+ `$CLAUDE_CONFIG_DIR/.claude.json` when that is set, as Claude Code does), under
369
+ the key Claude Code itself uses — the canonical git root, forward slashes on
370
+ Windows. The local scope outranks the project and user scopes, so the copy is
371
+ what Claude Code starts here, other projects are untouched, and the team's
372
+ `.mcp.json` never carries this machine's paths.
373
+
374
+ What is copied, and what is left as Claude Code runs it (its calls are still
375
+ recorded by the hooks):
376
+
377
+ - **A `.mcp.json` server only once Claude Code has approved it** — "Use this MCP
378
+ server", `enabledMcpjsonServers` or `enableAllProjectMcpServers` in a settings
379
+ file Claude Code honours for that. Claude Code asks before it starts a
380
+ `.mcp.json` server and never asks about a local-scope one, so a copy would
381
+ skip the question: a rejected server is never copied, and one never asked
382
+ about (a fresh clone) is left unwrapped with a line saying so. On a fresh
383
+ clone that means `bir setup`, then `claude` and approve the servers, then
384
+ `bir setup` again. `bir install --server <name>` wraps a pending one anyway,
385
+ and later installs remember it. A fleet that runs `claude -p`, where nobody
386
+ answers the question, sets `enableAllProjectMcpServers` in user or managed
387
+ settings.
388
+ - **A remote (`http`/`sse`) server only when its entry carries a literal
389
+ credential header.** One Claude Code signs in itself — OAuth, a
390
+ `headersHelper`, `${VAR}` headers — would reach the proxy with no credentials
391
+ and answer 401, so it is left unwrapped; `--server <name>` wraps it anyway. A
392
+ wrapped remote entry carries its headers in its env (`BIR_UPSTREAM_HEADERS`),
393
+ not on the command line.
394
+ - **Nothing** when managed settings set `allowManagedMcpServersOnly`, or match
395
+ servers by `serverCommand` or `serverUrl` — wrapping changes the command.
396
+ - **Nothing in a subdirectory or a linked worktree** of a repository: Claude Code
397
+ keeps the local scope per repository, so a copy would apply to the root and
398
+ every worktree. Hooks only; `bir setup` in the root wraps the servers. From the
399
+ root, the install names the linked worktrees that now run the wrapped servers
400
+ too, and leaves a server alone where a worktree's own `.mcp.json` defines it
401
+ differently.
402
+
403
+ A copy remembers what it copied. When the `.mcp.json` or user-scope entry
404
+ changes, or its approval is withdrawn, `bir doctor` reports the copy (a changed
405
+ or removed source exits 1), `bir status` marks it `STALE`, the SessionStart hook
406
+ names it in the recorder log (`overrides.stale`), and `bir setup` copies it
407
+ again or removes it. `bir install` refuses, changing nothing, when
408
+ `~/.claude.json`, `.mcp.json`, `settings.local.json` or a `--config` file is not
409
+ plain JSON (comments, a trailing comma). `bir uninstall` repairs
410
+ `~/.claude.json` entry by entry; it never restores that file whole.
411
+
412
+ `--mcp-json` (or `BIR_WRAP_MCP_JSON=1`) keeps the old shape — `.mcp.json`
413
+ servers wrapped in place, the scenario server added there and pre-approved in
414
+ `settings.local.json` — for a `.mcp.json` nobody commits. An install over an
415
+ older one that used that shape puts `.mcp.json` back as it was, byte for byte
416
+ when nothing else changed it, and moves the entries to the local scope.
417
+
418
+ ---
419
+
420
+ ## 5. Provisioning a runner
421
+
422
+ Four scopes, and conflating them is the usual source of "installed but never
423
+ runs":
424
+
425
+ | Scope | How often | What it does |
426
+ |---|---|---|
427
+ | **machine** | once | Node ≥ 20 and the package. The served line uses the machine's Node when it qualifies and otherwise puts a private one under `~/.baseinstrunner/node/`; the package goes under `~/.baseinstrunner/npm/`, and `bir` / `bir-hooks` launchers in `~/.baseinstrunner/bin/`, first on the user PATH |
428
+ | **account** | once per user | the sign-in → `~/.baseinstrunner/credentials.json` (0600); the service address → `~/.baseinstrunner/config.json`. `bir setup` stores both; so does `bir login` |
429
+ | **project** | once per repo | `bir setup` in that directory: `bir install --global --replay` (`--local` from a checkout) writes wrapped copies of the MCP servers into the project's local scope (§4.4), wires the hooks into `.claude/settings.local.json`, records the port, token and invocation |
430
+ | **session** | while recording | the recorder — `bir-hooks` as a background process — started by the SessionStart hook, discovered by `sha256(normalised cwd)` |
431
+
432
+ That cwd keying is why the recorder must be started for the *same directory*
433
+ the agent session runs in. A control server started for another one is
434
+ invisible, and the run silently degrades to Tier 2. It is also why the hook is
435
+ what starts it: the hook already runs in the right directory, with the right
436
+ environment, at exactly the right moment. (The background process itself runs
437
+ in `~/.baseinstrunner` and is told the project in `BIR_DAEMON_CWD`, so the
438
+ project folder can be renamed, deleted or `git worktree remove`d while it
439
+ runs.)
440
+
441
+ ### 5.1 One command
442
+
443
+ The console's *Set up the runner* page shows one line, with a fresh one-time
444
+ setup token filled in, to paste into a terminal opened in the project folder:
445
+
446
+ ```powershell
447
+ $env:BIR_SETUP_TOKEN="<token>"; irm https://api.bi2202.com/install.ps1 | iex
448
+ ```
449
+
450
+ ```cmd
451
+ powershell -NoProfile -Command "$env:BIR_SETUP_TOKEN='<token>'; irm https://api.bi2202.com/install.ps1 | iex"
452
+ ```
453
+
454
+ ```bash
455
+ curl -fsSL https://api.bi2202.com/install.sh | BIR_SETUP_TOKEN="<token>" sh
456
+ ```
457
+
458
+ The first is Windows PowerShell, the second the same line from cmd.exe (a
459
+ terminal opened from Start is often cmd), the third macOS and Linux. Without the
460
+ variable the line still installs everything; `bir setup` then opens the browser
461
+ for the device flow — even under the scripts, where its output is a pipe — and
462
+ over SSH prints the link instead (§7).
463
+
464
+ The scripts are [`scripts/install.ps1`](../scripts/install.ps1) and
465
+ [`scripts/install.sh`](../scripts/install.sh); they replace
466
+ `install-runner.ps1` and `install-runner.sh`, and they ship in the tarball. The
467
+ API serves copies at `/install.ps1` and `/install.sh` with two placeholders
468
+ filled in: `__BIR_AUTH_URL__` (the service's `PUBLIC_API_URL`, else the origin
469
+ the request arrived on) and `__BIR_VERSION__` (`RUNNER_VERSION`, default
470
+ `latest`). Run straight from the repository or the tarball the placeholders are
471
+ unreplaced, which counts as unset: `BIR_AUTH_URL` must then be in the
472
+ environment, and the version is `latest`.
473
+
474
+ What they do, in order, the same on every platform:
475
+
476
+ 0. **Stop before any download** if the current directory is the home folder or
477
+ a drive root: "This is your home folder. Open a terminal in the project you
478
+ start Claude Code in (cd there) and paste the line again." Nothing is
479
+ installed, nobody is signed in and the token is unused, so the fix is
480
+ exactly that — `cd` to the project and paste the *same* line
481
+ (`BIR_PROJECT=<dir>` names the project instead; the script `cd`s there
482
+ first). Move `BIR_SETUP_TOKEN` out of the environment into a local variable,
483
+ so npm, the Claude Code installer and the `claude` the person starts next
484
+ never see it.
485
+ 1. **Node.** A Node ≥ 20 already on PATH is used by its real binary — unless
486
+ that binary is one the hook must not pin: under a version manager's
487
+ per-shell or per-version directory (nvm, fnm, Volta, asdf, mise and nodenv
488
+ on every platform; Scoop on Windows) or, on macOS and Linux, a Homebrew Node
489
+ (`*/Cellar/*` — a `brew upgrade` moves the path the hook pinned) or a snap
490
+ one. Both scripts also insist on npm's `npm-cli.js` beside the binary
491
+ (`lib/node_modules/npm/bin/` on macOS and Linux, `node_modules\npm\bin\` on
492
+ Windows). Otherwise a private Node is installed instead: pinned version,
493
+ digest embedded in the script and checked, unpacked to
494
+ `~/.baseinstrunner/node/v<V>/`, never put on PATH. The SessionStart hook
495
+ pins an absolute node path, and a path that changes with the shell is a
496
+ hook that fails every session. `BIR_NODE_ARCHIVE=<file>` uses a downloaded
497
+ archive instead of nodejs.org.
498
+ 2. **The package.** `@basein/runner@<version>` into the private prefix
499
+ `~/.baseinstrunner/npm`, always — never the machine's npm prefix, which is
500
+ root-owned under a `.pkg` or apt Node and per-version under nvm, Volta, Scoop
501
+ or asdf, where a global install vanishes on the next switch.
502
+ `BIR_TARBALL=<file.tgz>` instead of the registry. Launchers go into
503
+ `~/.baseinstrunner/bin`, which is put first on the user PATH; in PowerShell
504
+ `bir` then resolves to `bir.cmd`, so there is no execution-policy change,
505
+ ever. The PowerShell window the line was pasted into gets `bir` in place —
506
+ the script's last two lines are "bir works in every new terminal (and in
507
+ this window when it is PowerShell); `bir doctor` checks the setup" and the
508
+ cmd hint below — but the cmd variant
509
+ runs the script in a child `powershell.exe`, so a cmd window sees `bir` only
510
+ once it is a new window, or after the `set PATH=…` line the script prints
511
+ for it. On macOS and Linux the script prints the
512
+ `export PATH="$HOME/.baseinstrunner/bin:$PATH"` line for the terminal it ran
513
+ in (with `~/.local/bin` added when it just installed Claude Code); every new
514
+ terminal has it from the profile.
515
+ 3. **Claude Code**, through its official installer, when `claude` is not on
516
+ PATH. `BIR_NO_CLAUDE=1` skips it. It needs a Claude subscription and asks to
517
+ sign in the first time it starts.
518
+ 4. **`bir setup --auth-url <url> --project <cwd>`**, with the token in that
519
+ child's environment only. It signs in, stores the service address, runs
520
+ `bir install --global --replay`, starts the recorder in the background, and
521
+ prints a summary that names the account, the wrapped servers (`(no MCP
522
+ servers here)` means none in *any* scope — `.mcp.json`, and the user and
523
+ local scopes of `~/.claude.json`; `(none — x, y left as they are, see
524
+ above)` means servers exist but none could be wrapped, for the reasons of
525
+ §4.4; replay is "steer mode only" in both cases), the recorder's pid and log
526
+ file, and the one thing left to do: `claude`.
527
+
528
+ `BIR_UNINSTALL=1` on the same line runs the other direction and nothing else:
529
+ `bir uninstall --all` with the runner already installed (§8). It downloads
530
+ nothing and needs no project folder and no token; `BIR_DRY_RUN=1` prints it.
531
+
532
+ Both scripts are idempotent and neither prompts (under `curl | sh` stdin is the
533
+ script itself). `bir setup` exits 1 when the recorder reports it has no session
534
+ to record with, so a provisioning script can gate on the exit code.
535
+
536
+ Behind a corporate proxy:
537
+
538
+ - The scripts' own downloads and npm use `HTTPS_PROXY`, `HTTP_PROXY` and
539
+ `NO_PROXY` as they are. The one fetch they cannot reach is the `irm` that
540
+ fetches the script itself: Windows PowerShell 5.1's `irm` uses the system
541
+ (WinINET) proxy, not `HTTPS_PROXY`.
542
+ - The runner's own calls to the service — the sign-in, the recorder's uploads —
543
+ go through Node's `fetch`, which ignores those variables unless node runs with
544
+ `--use-env-proxy`. The scripts add that flag to the launchers and to
545
+ `bir setup`, which carries it into every entry, hook and recorder it writes,
546
+ only when the Node accepts it (the node is asked, not judged by its version;
547
+ recent 22.x and 24.x have it) **and** `NO_PROXY` exempts `127.0.0.1` — as
548
+ `127.0.0.1`, `*` or a suffix such as `.0.0.1`; `localhost` alone is not
549
+ enough. Without that exemption the flag would send the runner's own loopback
550
+ calls — the SessionStart hook reaching the recorder, proxies registering,
551
+ `bir setup` waiting for it — to the proxy, so it is
552
+ left out, the runner reaches the service directly or not at all, and the
553
+ script says so. Set `NO_PROXY=127.0.0.1,localhost,::1` (plus your own
554
+ entries) before the line; `bir install` prints the same hint.
555
+ - A remote (`http`/`sse`) MCP server behind `bir-proxy` is reached through
556
+ `HTTPS_PROXY`/`NO_PROXY` by Node's `http.Agent` `proxyEnv`, flag or not, on
557
+ Node 24.5+ and 22.21+. An older Node connects directly and logs
558
+ `upstream.proxy_env_ignored`; loopback upstreams always connect directly, and
559
+ `BIR_UPSTREAM_PROXY_ENV=0` turns this off.
560
+ - `NODE_EXTRA_CA_CERTS` passes through to every node and npm the scripts run. A
561
+ Node that knows `--use-system-ca` also trusts the system certificate store,
562
+ and the launchers carry that flag too.
563
+
564
+ The scripts never reach `bir setup` from the home folder (step 0). `bir setup`
565
+ run *by hand* there, with the runner already installed, is different: it keeps
566
+ the sign-in, wires nothing, prints the two commands left — `cd <project>` and
567
+ `bir setup` — and exits 0. The second needs no token; the session is cached.
568
+ That is also how a second project on the same machine is added.
569
+
570
+ Fleet switches on `bir setup`: `--allow-servers a,b` stores the servers replay
571
+ may call unattended for this project; `bir replay allow|on|off|status` changes
572
+ it later. Both live in the project record (§4.3), so a recorder the hook starts
573
+ — with Claude Code's environment, not the operator's terminal — keeps them;
574
+ `BIR_REPLAY`, `BIR_REPLAY_ALLOW_SERVERS` and `BIR_MIN_STEER_SIMILARITY` in the
575
+ recorder's environment still override, and `bir doctor` says which source is in
576
+ effect. `--no-replay` adds no scenario server (one an earlier setup added stays
577
+ until `bir uninstall --replay`) and stores replay off (`bir replay on` turns it
578
+ on); `--no-daemon` wires everything and starts
579
+ nothing; `--no-browser` prints the sign-in link without opening one.
580
+
581
+ **Permissions on a fleet.** The runner does not approve wrapped MCP tool calls:
582
+ Claude Code asks about them as it would without the runner. (0.2.12 answered
583
+ every one "allow"; §8.) Headless `claude -p` runs must allow those tools
584
+ themselves — `permissions.allow` in a trusted workspace, or `--allowedTools` —
585
+ or set `BIR_CORRELATION_DECISION=allow` in the environment the recorder starts
586
+ in — Claude Code's when the SessionStart hook starts it, the shell's for
587
+ `bir up --restart` — which brings the old auto-approval back. A recorder already
588
+ running keeps what it started with. `bir doctor` says which applies. A
589
+ matched calculated scenario's own steps are still approved by the runner
590
+ ([calculatedReplayGuide.md §5.1](calculatedReplayGuide.md)).
591
+
592
+ Two things the scripts deliberately do not do: supervise the recorder (the hook
593
+ does, §5.2) and mint the token (the console does, §7).
594
+
595
+ The PowerShell script is **pure ASCII** on purpose. Windows PowerShell 5.1
596
+ decodes a BOM-less `.ps1` using the machine's ANSI codepage, and PowerShell
597
+ accepts curly quotes as string delimiters — so a UTF-8 em-dash in a comment can
598
+ decode into a quote and swallow the rest of the file. It parsed fine on one
599
+ machine and failed on another before this was fixed. Keep it ASCII. It also
600
+ never calls `exit` — under `iex` that closes the person's window — and it sets
601
+ strict mode and `$ErrorActionPreference` inside a function so neither leaks into
602
+ the caller's session.
603
+
604
+ ### 5.2 Keeping the recorder alive
605
+
606
+ Not needed. The SessionStart hook is `bir-hooks ensure` (§4.2): every Claude
607
+ Code session in the project starts the recorder when there is none, in the
608
+ background, so a recorder that died or was never started is running by the first
609
+ prompt. Nothing has to stay open. `bir up` starts it by hand, `bir up --restart`
610
+ replaces it, `bir down` stops it — it asks first (`POST /control/stop`, which
611
+ finishes the run being written), signals only a pid that answered `/health` with
612
+ the token, and says "forced" when it had to — and `bir status` and `bir doctor`
613
+ show its pid and log. Two Claude Code windows opened together take a lock, so
614
+ only one spawns.
615
+
616
+ `bir down` stops the recorder and nothing else: the hooks stay installed, so a
617
+ Claude Code session open in the project shows a "hook error" on each prompt and
618
+ tool call (the session keeps working) until it ends, and the next session starts
619
+ the recorder again. It also stops superseded recorders (§4.3), and it stays down
620
+ against the automatic revival below until the next session start, `bir up` or
621
+ `bir setup`. To take the runner out of the way without hook errors, use the
622
+ kill switch instead: `bir off --here` for one project, `bir off` for every
623
+ project (`bir on` undoes it). Hooks then answer with nothing within about a
624
+ second, in open sessions too; wrapped servers run as plain pipes from their next
625
+ start; nothing is uninstalled.
626
+
627
+ On POSIX the daemon is a detached spawn with its stdio ignored. On Windows it is
628
+ launched through `Start-Process -WindowStyle Hidden` in a short-lived
629
+ `powershell.exe`, because a node started under a PowerShell layer carries an
630
+ extra copy of its stdout pipe and a daemon that inherited it would hold that pipe
631
+ open for days. That costs about a second, once per project per boot.
632
+ `BIR_WINDOWS_LAUNCH=spawn` opts into a direct hidden spawn instead — faster, and
633
+ unsafe only when something above Claude Code captures its output
634
+ (`$r = claude -p …`).
635
+
636
+ Its audit lines go to `~/.baseinstrunner/logs/<project-key>.log`: the daemon
637
+ opens the file itself, appends, and rolls it to `.1` once it is past 8 MB — at
638
+ start and while it runs. `bir status` names the file. The old shape still works —
639
+ `bir-hooks` in a terminal writes them to that terminal — and is what the hook
640
+ does for you.
641
+
642
+ ```bash
643
+ grep version.skew ~/.baseinstrunner/logs/*.log
644
+ grep run.lossy ~/.baseinstrunner/logs/*.log
645
+ ```
646
+
647
+ Restart-on-crash mid-session is the session's own part: a bound `bir-proxy`, or
648
+ the `bir` scenario server, that finds the project's recorder gone — its pid dead
649
+ and its port refusing — starts it again with `bir-hooks revive`
650
+ (`proxy.recorder_revive` / `scenario.recorder_revive` on stderr), rate-limited,
651
+ and never after `bir down`. A session with neither is back on the next session
652
+ start or `/clear`. A service manager is
653
+ optional. If you want one — a machine that records SDK sessions with nobody
654
+ starting Claude Code, or a recorder up before the first session — have it run
655
+ `bir up` in the project directory at logon, and nothing else: `bir up` returns
656
+ once the recorder answers and leaves it running, so a supervisor must not expect
657
+ a long-lived process. The three recipes below are recipes — nothing the package
658
+ ships or its tests cover.
659
+
660
+ **Windows** — a Scheduled Task at logon, with *Start in* set to the project
661
+ directory (`bir.cmd` is the launcher the served line writes; a by-hand install
662
+ has `bir` wherever npm put it):
663
+
664
+ ```powershell
665
+ $action = New-ScheduledTaskAction -Execute "$env:USERPROFILE\.baseinstrunner\bin\bir.cmd" -Argument 'up' -WorkingDirectory 'C:\work\repo'
666
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
667
+ Register-ScheduledTask -TaskName 'bir up (repo)' -Action $action -Trigger $trigger
668
+ ```
669
+
670
+ **macOS** — a launchd agent at `~/Library/LaunchAgents/com.basein.up.plist`
671
+ running `$HOME/.baseinstrunner/bin/bir up` with `WorkingDirectory` set to the
672
+ project and `RunAtLoad` (not `KeepAlive`), then `launchctl load` it.
673
+
674
+ **Linux** — a systemd user unit, `Type=oneshot` with `RemainAfterExit=yes` (so
675
+ the unit does not stop and take the recorder down with it), `ExecStart=%h/.baseinstrunner/bin/bir up`,
676
+ `WorkingDirectory=` the project, plus `loginctl enable-linger "$USER"` so it
677
+ runs without a login session.
678
+
679
+ ### 5.3 Verify
680
+
681
+ ```bash
682
+ bir doctor --json # exits 1 if something here breaks a Claude Code session
683
+ bir doctor --json --strict # exits 1 on warnings too: not recording, a proxy not registered, …
684
+ ```
685
+
686
+ `bir doctor` asks the control server which proxies actually registered; it never
687
+ reads the config file back. That makes it the right gate for a provisioning
688
+ script. `bir status` only describes intent. It sorts what it finds three ways.
689
+ *Problems* break a session and are the only thing that exits 1: a wrapped
690
+ server, the scenario server or the SessionStart hook naming a Node or script
691
+ that is gone (§4.2), a local copy out of date with its source (§4.4), a remote
692
+ server wrapped without credentials, hooks posting to a port the recorder is not
693
+ on or could not take (§4.3), hooks with nothing to start the recorder, a
694
+ recorder that does not answer. *Warnings* exit 0 unless `--strict`: the
695
+ recorder has nowhere to record to ("the recorder is running but has nowhere to
696
+ send steps — run `bir setup` …"), a proxy has not registered while sessions
697
+ run, the service is not answering the recorder, a recorder of another version.
698
+ *Notes* are facts, such as "the recorder is not running; the SessionStart hook
699
+ starts it with the next session (`bir up` starts it now)", who approves wrapped
700
+ MCP calls, and the other projects set up on the machine. `--quick` skips the
701
+ network call; `--json` carries `problems`, `warnings`, `notes`, `off`,
702
+ `service`, `otherProjects` and `portProblem`.
703
+
704
+ ---
705
+
706
+ ## 6. Machines with no registry
707
+
708
+ Zero dependencies makes offline install trivial — no `npm ci --offline`, no
709
+ vendored `node_modules`. The install script ships in the tarball, takes a local
710
+ tarball instead of the registry and a local Node archive instead of nodejs.org,
711
+ and can skip the Claude Code installer:
712
+
713
+ ```bash
714
+ # on a connected machine
715
+ npm pack @basein/runner@<version> # or download the Release asset
716
+ tar -xzf basein-runner-<version>.tgz package/scripts
717
+ # plus the Node archive for the runner's platform from nodejs.org/dist, if the
718
+ # runner has no Node ≥ 20 (the script names the pinned version, 24.21.0)
719
+
720
+ # on the runner
721
+ BIR_AUTH_URL=https://api.example.com \
722
+ BIR_TARBALL=./basein-runner-<version>.tgz \
723
+ BIR_NODE_ARCHIVE=./node-v24.21.0-linux-x64.tar.gz \
724
+ BIR_NO_CLAUDE=1 sh ./package/scripts/install.sh
725
+ ```
726
+
727
+ ```powershell
728
+ $env:BIR_AUTH_URL='https://api.example.com'; $env:BIR_TARBALL='.\basein-runner-<version>.tgz'; $env:BIR_NODE_ARCHIVE='.\node-v24.21.0-win-x64.zip'; $env:BIR_NO_CLAUDE=1; Get-Content -Raw .\package\scripts\install.ps1 | iex
729
+ ```
730
+
731
+ `<version>` is whatever the registry holds (`npm view @basein/runner version`).
732
+ The PowerShell line pipes the file through `iex`, as the served line does: a
733
+ `.ps1` invoked by path is subject to the execution policy, and a default client
734
+ (Restricted) refuses it with "running scripts is disabled". The script is
735
+ written to be run that way — everything inside one function, no `exit`.
736
+
737
+ `BIR_AUTH_URL` is required here because the raw script's placeholder is
738
+ unreplaced (§5.1). Claude Code has to come from somewhere too; `BIR_NO_CLAUDE=1`
739
+ only stops the script from fetching it. The service itself must be reachable —
740
+ "no registry" is not "no network" — or the sign-in has nothing to talk to.
741
+
742
+ `bir setup` passes `--global`, so the resulting configs contain absolute paths
743
+ and never reach for a registry. Nothing further is needed.
744
+
745
+ Note that a private repository's Release assets require a GitHub token to fetch,
746
+ so the tarball has to be carried to the runner by whatever means you already use.
747
+
748
+ ---
749
+
750
+ ## 7. Headless authentication
751
+
752
+ `bir login` signs in through the browser — the OAuth 2.0 device grant (RFC 8628,
753
+ [loginWeb.md](loginWeb.md)) — and caches
754
+ `{accessToken, refreshToken, accessExpiresAt, user, authUrl}` at
755
+ `~/.baseinstrunner/credentials.json`, mode 0600. `bir setup` does the same
756
+ sign-in and stores the service address beside it in
757
+ `~/.baseinstrunner/config.json`, so no process afterwards needs `BIR_AUTH_URL`
758
+ (it still overrides when set).
759
+
760
+ **The setup token.** The served line carries `BIR_SETUP_TOKEN`: a one-time token
761
+ minted by the console's *Set up the runner* page — ten-minute TTL, single use,
762
+ hashed at rest, and minting a new one retires the person's earlier unused ones.
763
+ It travels as an environment-variable assignment rather than an argument so that
764
+ it is never on a command line, where a process listing would show it; the
765
+ variable's *name* is what keeps the pasted line out of PSReadLine's on-disk
766
+ history, which drops lines containing "token" (a `--token` argument would be
767
+ dropped just the same). The script moves it out of the environment as its first
768
+ statement and hands it to `bir setup` through that child's environment only;
769
+ `bir setup` deletes it again the moment it has read it, so the recorder it
770
+ starts never holds it. By hand: `bir setup --token <t>` or
771
+ `bir login --token <t>`. It signs in one person on one machine with no browser
772
+ step; it is not a fleet credential.
773
+
774
+ What `bir setup` says about a token that is not fresh:
775
+
776
+ - pasted a second time after a successful paste — "the setup token was already
777
+ used; keeping the sign-in from before (<email>)", and it goes on; that is the
778
+ upgrade path (§8) and the retry after a failure past the sign-in;
779
+ - used or expired, with no cached sign-in to keep — "this setup token has
780
+ expired or was already used — make a new one on the console's Set up the
781
+ runner page and paste it within ten minutes";
782
+ - not a token at all — "that is not a valid setup token — copy the whole token
783
+ from the console's Set up the runner page".
784
+
785
+ "sign-in failed: …" is reserved for the unexpected, and "not signed in — run
786
+ `bir setup` again (or `bir login`)." for a sign-in that ended with no session.
787
+
788
+ **Credentials are bound to the service that issued them.** `credentials.json`
789
+ carries `authUrl`, and `authenticate()` refuses to use or refresh a session
790
+ against another origin — `auth.url_mismatch` on the audit log, and the runner
791
+ records nothing rather than send a session where it was not minted. A file from
792
+ before the binding is trusted as before. So a copied `credentials.json` works
793
+ only where `config.json` (or `BIR_AUTH_URL`) names the same service, and a stale
794
+ `BIR_AUTH_URL` in a profile file now means "not recording" — `bir setup` warns
795
+ when the environment names a different address than the one being set up, or
796
+ than the stored one (a bare `bir setup` in a second project included), and says
797
+ to remove the variable.
798
+
799
+ **For a machine with a person attached, headless is no longer a problem.** The
800
+ runner prints a link and a short code and waits; the link opens on *any* device,
801
+ so a box with no browser, no display, or only an SSH session is signed in from a
802
+ phone. Nothing is typed into the terminal, so nothing lands in a shell history or
803
+ a CI transcript. `bir login --no-browser` (or `bir setup --no-browser`) skips
804
+ even trying to open one, which is the right flag in a provisioning script (over
805
+ SSH it is the default; a pipe on stdout is not — under the install scripts
806
+ `bir setup` still opens the browser, because somebody just pasted the line). The
807
+ setup token removes even the waiting.
808
+
809
+ For machines with **no person attached**, in order of preference:
810
+
811
+ 1. **A service account, signed in once.** Approve the device code for a dedicated
812
+ account, then copy `credentials.json` to each machine at 0600, together with
813
+ a `config.json` naming the same service. The rotating refresh token keeps it
814
+ alive and nothing ever prompts. It is still a shared long-lived secret, so
815
+ never a person's account.
816
+ 2. **No service address at all** for runners that should record nothing: no
817
+ address in `~/.baseinstrunner/config.json` and no `BIR_AUTH_URL`. The
818
+ recorder then starts with a null recorder (`recorder.disabled` on its log)
819
+ and everything else still works; `bir up` exits 1 to say so, and
820
+ `bir doctor` warns (exit 1 only with `--strict`).
821
+ `BIR_AUTH_DISABLE=1` is *not* that: it fakes a session and records with a
822
+ bogus token — a local-dev and CI switch, not a fleet one.
823
+ 3. **Ask the service for a non-interactive grant** — a long-lived runner token,
824
+ created in the console and read from an env var. This remains the right answer
825
+ for a large fleet and is still a service-side change; the device grant and the
826
+ setup token make it less urgent, not unnecessary.
827
+
828
+ `bir logout` clears the file first — a hung service or a Ctrl-C cannot leave the
829
+ machine signed in — and then revokes the session at the service that issued it
830
+ (the one `credentials.json` names, not whatever `BIR_AUTH_URL` says), best
831
+ effort and within 8 s, so decommissioning a runner ends its access rather than
832
+ merely tidying the disk. Running recorders stop sending within seconds.
833
+ Decommissioning also means `bir uninstall --all` first (§8).
834
+
835
+ `BIR_HOME` relocates all of this if state belongs somewhere other than
836
+ `~/.baseinstrunner`.
837
+
838
+ ---
839
+
840
+ ## 8. Updating a runner
841
+
842
+ Paste the line again — the same line, used token and all. It installs the
843
+ version the service serves (`RUNNER_VERSION`, default `latest`), keeps the
844
+ sign-in it already has ("the setup token was already used; keeping the sign-in
845
+ from before (<email>)"), and runs `bir setup`, which re-wraps whatever moved and
846
+ restarts an older recorder. For a machine installed by hand: the new package,
847
+ then `bir setup` in each recorded project.
848
+
849
+ ```bash
850
+ npm i -g @basein/runner@<version> && cd ~/work/repo && bir setup
851
+ ```
852
+
853
+ Two rules used to follow, and neither is a step now:
854
+
855
+ - **Restarting the recorder.** A running process is still the old code, and
856
+ this was the step people forgot. `bir setup`, `bir up` and the SessionStart
857
+ hook all replace a running recorder that is older than the package — provided
858
+ no session is mid-turn. A newer one (another runner copy on the machine) is
859
+ kept. A recorder still serving a turn is left alone, and `bir setup` says so:
860
+ "it is still 0.2.12, serving a Claude Code session that is mid-turn; it keeps
861
+ running as 0.2.12 until `bir up --restart` (run it once that session ends)
862
+ replaces it with <version>". The next session's hook replaces it when no turn
863
+ is open, but a 0.2.12 recorder can report an interrupted turn as open for good;
864
+ `bir setup` and `bir up` look twice, a few seconds apart, and replace one when
865
+ nothing moved in between and no session's proxy is alive. `bir up --restart`
866
+ forces it now.
867
+ - **Re-running `bir install`.** `bir setup` does. A `--global` entry whose path
868
+ did not change needs nothing (§4); one whose runner moved — a new prefix, a
869
+ new Node — is re-wrapped from its source; a pinned-npx entry always
870
+ has its pin moved.
871
+
872
+ **Upgrading from 0.2.12.** The first `bir setup` after it changes more than
873
+ paths:
874
+
875
+ - Wrapped entries move to the local scope (§4.4). `.mcp.json` is put back as it
876
+ was; a user-scope server 0.2.12 wrapped in place is put back too, and every
877
+ other project that recorded it that way needs `bir setup` there again (the
878
+ install names them). A `.mcp.json` server Claude Code has not approved is no
879
+ longer wrapped.
880
+ - The runner no longer approves wrapped MCP tool calls; 0.2.12 answered every
881
+ one "allow". `bir install` and `bir setup` say so once per project. Headless
882
+ `claude -p` fleets must allow those tools themselves, or set
883
+ `BIR_CORRELATION_DECISION=allow` (§5.1).
884
+ - In a project with no scenario server (a plain `bir install`), the next
885
+ install without `--replay` switches replay off (§4.3); `bir replay on` turns
886
+ it back on.
887
+ - Until it is replaced, a 0.2.12 recorder still running keeps approving those
888
+ calls and ignores `bir off`; `bir doctor` warns about a recorder of another
889
+ version, and `bir up --restart` replaces it once its session ends.
890
+
891
+ **Rolling back.** First run `bir uninstall --all` with the version installed
892
+ now: an older runner cannot read everything this one wrote, and would find no
893
+ record for some wrapped entries and leave them — credentials included — in
894
+ place. Then install the older version (`BIR_VERSION=<version>` on the macOS and
895
+ Linux line, or by hand) and run `bir setup` in each project.
896
+
897
+ ### The half-upgraded machine
898
+
899
+ This is the update failure that looks like success: the package is new, but a
900
+ proxy is still the old code because a config points elsewhere, or the recorder
901
+ is a process that outlived the upgrade. Everything keeps working, and quietly
902
+ runs code you did not ship.
903
+
904
+ The restart-on-skew above closes the common case. For the rest, a proxy reports
905
+ its version when it registers, the control server compares it with its own, and
906
+ a mismatch is one line on the audit log:
907
+
908
+ ```
909
+ [bir] … version.skew server=fleet proxy=0.0.9 hooks=0.1.0 fix="restart bir-hooks after upgrading, …"
910
+ ```
911
+
912
+ `bir doctor --json` carries the same facts structurally — under `health`,
913
+ `hooksVersion` and a `version` on each entry of `registeredProxies` — so a
914
+ provisioning script can assert agreement rather than grep a log. A proxy too old
915
+ to report a version is not treated as skewed: silence, not a false alarm.
916
+
917
+ ### Removing a runner
918
+
919
+ `bir uninstall` covers one project. Before `npm rm -g @basein/runner` or
920
+ deleting `~/.baseinstrunner`, run `bir uninstall --all` — or paste the install
921
+ line with `BIR_UNINSTALL=1`, which runs just that:
922
+
923
+ ```bash
924
+ curl -fsSL https://api.bi2202.com/install.sh | BIR_UNINSTALL=1 sh
925
+ ```
926
+
927
+ ```powershell
928
+ $env:BIR_UNINSTALL="1"; irm https://api.bi2202.com/install.ps1 | iex
929
+ ```
930
+
931
+ Every wrapped server and every hook names a file under the runner's install,
932
+ so removing it first breaks every project still set up, with no `bir` left to
933
+ repair them. `--all` undoes every installed project as `bir uninstall` would
934
+ there, sweeps every `.claude.json` an install wrote to (user scope included),
935
+ stops every recorder, superseded ones too, and clears the off switches. When
936
+ something could not be undone it lists it, keeps the off switch in place, and
937
+ exits 1 ("Not everything could be undone"); fix that and run it again before
938
+ removing anything. Then `bir logout` (§7), then remove the package.
939
+
940
+ ---
941
+
942
+ ## 9. Per-release verification
943
+
944
+ CI does this on three platforms. To do it by hand on a clean machine:
945
+
946
+ ```bash
947
+ npm run build && npm test # ~1,000 tests (npm runs pretest first)
948
+ npm run test:smoke # real processes: hooks + proxy + scenario over stdio;
949
+ # needs the build and the pretest above
950
+ npm pack && npm i -g ./basein-runner-*.tgz
951
+ cd /tmp/scratch && bir setup --auth-url <api> --token <t> # or BIR_AUTH_URL=<api> bir login, then bir install --global && bir up
952
+ bir doctor --json # must exit 0 (--strict: no warnings either)
953
+ bir uninstall # must end with "Uninstalled." and exit 0
954
+ ```
955
+
956
+ `test:smoke` is the one that matters for packaging: it is the only test that
957
+ crosses process boundaries, which is exactly where a bad `files` list, a missing
958
+ shebang, or a broken bin mapping shows up.
959
+
960
+ Sandbox a manual run with `BIR_HOME` and `BIR_CLAUDE_JSON` so it cannot touch
961
+ your real Claude Code configuration. A fresh `BIR_HOME` has no sign-in, which is
962
+ why the recipe signs in: a bare `bir install --global && bir up` there starts a
963
+ recorder with nothing to record to, `bir up` ends with "it has no session to
964
+ record with" and exit 1, and `bir doctor --json` warns about the same thing
965
+ (exit 1 with `--strict`). `bir uninstall` restores byte-for-byte only a project
966
+ file nothing else edited since the install, and repairs `~/.claude.json` entry
967
+ by entry; a file it created stays behind, empty of our entries. It exits 1 with
968
+ "Not everything could be undone" while anything here still runs through the
969
+ runner.
970
+
971
+ ---
972
+
973
+ ## 10. Where things stand
974
+
975
+ | # | Item | State |
976
+ |---|---|---|
977
+ | 1 | Name settled; `PACKAGE_NAME` aligned | done |
978
+ | 2 | `repository` + `publishConfig`; provenance deferred (private repo) | done |
979
+ | 3 | Tarball trimmed — no dead sourcemaps, no scratchpad; `scripts/` ships | done |
980
+ | 4 | npx spec pinned; `--global` added; moved runners re-wrapped | done |
981
+ | 5 | CI and release workflows, three platforms | done |
982
+ | 6 | `scripts/install.{ps1,sh}`, served by the API at `/install.ps1` and `/install.sh` (replace `install-runner.*`) | done |
983
+ | 7 | Version-skew detection on registration; recorder restarts itself between sessions | done |
984
+ | 8 | UTF-8 BOM tolerated in MCP configs | done |
985
+ | 9 | npm org `basein`, `NPM_TOKEN` secret | done |
986
+ | 10 | `bir setup`: sign-in, wrap, replay, recorder in one command | done |
987
+ | 11 | Background recorder started by the SessionStart hook (`bir-hooks ensure`); `bir up` / `bir down`; log per project | done |
988
+ | 12 | Hooks in `.claude/settings.local.json` + `.git/info/exclude`; one port and token per project | done |
989
+ | 13 | Setup token (`BIR_SETUP_TOKEN`) for the first sign-in; credentials bound to their service; address in `config.json` | done |
990
+ | 14 | Tag `v0.2.9` to publish | ready |
991
+ | 15 | Non-interactive auth grant for fleets | **needs the service** |
992
+ | 16 | Migrate to trusted publishing before Jan 2027 (§3.3) | scheduled |
993
+ | 17 | Production readiness: local-scope wrapping (§4.4), no auto-approval of wrapped calls, `bir off` / `bir on`, `bir uninstall --all` and `BIR_UNINSTALL=1` (§8) — contract and measurements in [production.md](../production.md) | done, unreleased |
994
+
995
+ Item 15 is the only one that needs somebody else.
996
+
997
+ ---
998
+
999
+ ## Appendix: the BOM
1000
+
1001
+ `bir` used to reject any MCP config saved with a UTF-8 byte order mark. Notepad
1002
+ writes one, so does VS Code's "UTF-8 with BOM", and so does PowerShell's
1003
+ `Out-File -Encoding utf8`. `readFileSync(path, "utf8")` decodes the BOM to U+FEFF
1004
+ rather than consuming it, and `JSON.parse` then rejects the file.
1005
+
1006
+ The failure mode was the bad part: `bir install` logged the parse error and
1007
+ **still reported success**, so an operator saw a green run and no wrapping. On
1008
+ Windows runners this was a matter of time.
1009
+
1010
+ `parseJsonFile` now strips it, which covers every user-authored config read.
1011
+ Restoring is unaffected — the uninstall backup keeps the file's own text (its
1012
+ env and header values replaced by placeholders) on a separate path, so a BOM'd
1013
+ file comes back with its BOM intact.