@basein/runner 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -6
- package/dist/auth/client.d.ts +15 -0
- package/dist/auth/client.js +34 -12
- package/dist/bin/bir-hooks.d.ts +1 -1
- package/dist/bin/bir-hooks.js +1 -1
- package/dist/bin/bir.d.ts +2 -0
- package/dist/bin/bir.js +60 -6
- package/dist/bin/investigate.d.ts +5 -0
- package/dist/bin/investigate.js +117 -15
- package/dist/bin/setup.d.ts +12 -0
- package/dist/bin/setup.js +185 -56
- package/dist/bin/update.d.ts +81 -0
- package/dist/bin/update.js +194 -0
- package/dist/config/adapters/claude-code.d.ts +18 -1
- package/dist/config/adapters/claude-code.js +30 -3
- package/dist/config/generate.d.ts +14 -0
- package/dist/control/daemon.d.ts +26 -1
- package/dist/control/daemon.js +53 -3
- package/dist/control/pin-guard.d.ts +6 -1
- package/dist/control/pin-guard.js +15 -1
- package/dist/control/recorder-keeper.js +2 -1
- package/dist/control/server.d.ts +205 -2
- package/dist/control/server.js +766 -56
- package/dist/control/transcript.d.ts +4 -0
- package/dist/control/transcript.js +4 -0
- package/dist/proxy/session.js +1 -0
- package/dist/record/housekeeping.d.ts +7 -1
- package/dist/record/housekeeping.js +11 -3
- package/dist/record/recorder.d.ts +34 -0
- package/dist/record/remote-recorder.d.ts +6 -0
- package/dist/record/remote-recorder.js +31 -4
- package/dist/record/switchable-recorder.js +1 -0
- package/dist/replay/controller.d.ts +139 -3
- package/dist/replay/controller.js +248 -24
- package/dist/replay/derive.js +2 -0
- package/dist/replay/handover.d.ts +13 -1
- package/dist/replay/handover.js +19 -0
- package/dist/replay/safety.d.ts +15 -4
- package/dist/replay/safety.js +19 -6
- package/dist/replay/source-run.js +2 -1
- package/dist/util/service-headers.d.ts +24 -0
- package/dist/util/service-headers.js +47 -0
- package/docs/BaseInstRunner.md +115 -1
- package/docs/calculatedReplay.md +209 -32
- package/docs/calculatedReplayGuide.md +133 -12
- package/docs/installRun.md +91 -10
- package/docs/quickstart.md +64 -9
- package/package.json +1 -1
- package/production.md +83 -19
- package/scripts/install.ps1 +25 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bir update` — put the newest version where THIS copy of the runner is.
|
|
3
|
+
*
|
|
4
|
+
* There are two ways the runner gets onto a machine, and each keeps it in a
|
|
5
|
+
* different place:
|
|
6
|
+
*
|
|
7
|
+
* - the console's install line (scripts/install.sh, install.ps1) installs it
|
|
8
|
+
* into `~/.baseinstrunner/npm`, with the npm that sits beside the Node its
|
|
9
|
+
* launchers name. `npm i -g` never writes there: typed by hand it adds a
|
|
10
|
+
* second copy in npm's own prefix and leaves the one the launchers start
|
|
11
|
+
* as old as before, and which copy a shell then finds depends on its PATH;
|
|
12
|
+
* - `npm install -g @basein/runner`, into npm's own prefix.
|
|
13
|
+
*
|
|
14
|
+
* So the command that updates the runner depends on where the runner is, and
|
|
15
|
+
* the copy that is running is the one that knows. A copy under
|
|
16
|
+
* `~/.baseinstrunner/npm` repeats what the install line did: the same Node,
|
|
17
|
+
* the npm beside it, `--prefix` that folder. Any other installed copy runs
|
|
18
|
+
* `npm install -g`, after asking npm where it keeps its global packages: when
|
|
19
|
+
* this copy is not there (pnpm, yarn, another Node's prefix), npm would write
|
|
20
|
+
* the second copy this command exists to prevent, so nothing is installed.
|
|
21
|
+
*
|
|
22
|
+
* It replaces the package and nothing else. `bir setup` in each project is
|
|
23
|
+
* what moves the wiring and replaces an older recorder, and it is the next
|
|
24
|
+
* thing the output names.
|
|
25
|
+
*
|
|
26
|
+
* NOT TESTED on Windows against a live session: npm replaces the folder this
|
|
27
|
+
* process and the recorder were loaded from. Neither keeps a file of it open,
|
|
28
|
+
* and the install line has always done the same replacement, but if npm ends
|
|
29
|
+
* with EPERM or EBUSY the answer is to close Claude Code and run it again.
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { PACKAGE_NAME } from "../config/generate.js";
|
|
37
|
+
import { normalizePath } from "../control/paths.js";
|
|
38
|
+
import { packageVersion } from "../util/version.js";
|
|
39
|
+
/** What `bir update` installs. */
|
|
40
|
+
const SPEC = `${PACKAGE_NAME}@latest`;
|
|
41
|
+
/**
|
|
42
|
+
* The one Node flag the install scripts hand to npm. `--use-env-proxy` is not
|
|
43
|
+
* carried: npm reads HTTPS_PROXY itself.
|
|
44
|
+
*/
|
|
45
|
+
const NPM_NODE_FLAGS = ["--use-system-ca"];
|
|
46
|
+
/** The folder of this package: the nearest `package.json` that names it. */
|
|
47
|
+
export function packageRoot(from = fileURLToPath(import.meta.url)) {
|
|
48
|
+
let dir = dirname(from);
|
|
49
|
+
for (let i = 0; i < 5; i += 1) {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
52
|
+
if (pkg.name === PACKAGE_NAME)
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* keep walking */
|
|
57
|
+
}
|
|
58
|
+
dir = dirname(dir);
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
/** Where a global install into `prefix` keeps this package: npm adds `lib` everywhere but on Windows. */
|
|
63
|
+
export function packageDirIn(prefix, platform = process.platform) {
|
|
64
|
+
const modules = platform === "win32" ? join(prefix, "node_modules") : join(prefix, "lib", "node_modules");
|
|
65
|
+
return join(modules, ...PACKAGE_NAME.split("/"));
|
|
66
|
+
}
|
|
67
|
+
/** The `npm-cli.js` that ships beside a Node binary, where the install scripts look for it. */
|
|
68
|
+
export function npmCliBeside(execPath, platform = process.platform) {
|
|
69
|
+
const dir = dirname(execPath);
|
|
70
|
+
return platform === "win32"
|
|
71
|
+
? join(dir, "node_modules", "npm", "bin", "npm-cli.js")
|
|
72
|
+
: join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
|
73
|
+
}
|
|
74
|
+
function samePath(a, b) {
|
|
75
|
+
const real = (p) => {
|
|
76
|
+
try {
|
|
77
|
+
return realpathSync(p);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return p;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return normalizePath(real(a)) === normalizePath(real(b));
|
|
84
|
+
}
|
|
85
|
+
/** Decide what `bir update` runs. Changes nothing. */
|
|
86
|
+
export function planUpdate(deps = {}) {
|
|
87
|
+
const root = deps.packageRoot ?? packageRoot();
|
|
88
|
+
const platform = deps.platform ?? process.platform;
|
|
89
|
+
if (!root)
|
|
90
|
+
return { kind: "refused", why: "this bir cannot tell where its own package is, so it cannot update it." };
|
|
91
|
+
if (/[\\/]_npx[\\/]/.test(root)) {
|
|
92
|
+
return {
|
|
93
|
+
kind: "refused",
|
|
94
|
+
why: "this bir runs from npx's cache, so there is no installed copy to update. " +
|
|
95
|
+
`Install it (npm install -g ${PACKAGE_NAME}), then run \`bir setup\` in the project.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (!/[\\/]node_modules[\\/]/.test(root)) {
|
|
99
|
+
return {
|
|
100
|
+
kind: "refused",
|
|
101
|
+
why: `this bir runs from a checkout (${root}), not from an installed package: \`git pull\` and \`npm run build\` there update it.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const prefix = join(deps.home ?? homedir(), ".baseinstrunner", "npm");
|
|
105
|
+
if (samePath(root, packageDirIn(prefix, platform))) {
|
|
106
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
107
|
+
const npmCli = deps.npmCli ?? npmCliBeside(execPath, platform);
|
|
108
|
+
if (!existsSync(npmCli)) {
|
|
109
|
+
return {
|
|
110
|
+
kind: "refused",
|
|
111
|
+
why: `the Node this bir runs on (${execPath}) has no npm beside it any more (${npmCli} is missing). ` +
|
|
112
|
+
"Paste the install line from the console again: it puts both back.",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const flags = (deps.execArgv ?? process.execArgv).filter((a) => NPM_NODE_FLAGS.includes(a));
|
|
116
|
+
return {
|
|
117
|
+
kind: "private",
|
|
118
|
+
root,
|
|
119
|
+
command: execPath,
|
|
120
|
+
args: [...flags, npmCli, "install", "-g", "--prefix", prefix, "--no-fund", "--no-audit", SPEC],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { kind: "npm", root, command: "npm", args: ["install", "-g", SPEC] };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `npm` on PATH is `npm.cmd` on Windows, which Node starts only through a
|
|
127
|
+
* shell. The whole line is passed as one string then (every word of it is
|
|
128
|
+
* ours), and an absolute path — the private copy's Node — never needs it.
|
|
129
|
+
*/
|
|
130
|
+
function needsShell(command) {
|
|
131
|
+
return process.platform === "win32" && command === "npm";
|
|
132
|
+
}
|
|
133
|
+
function defaultRun(command, args) {
|
|
134
|
+
const r = needsShell(command)
|
|
135
|
+
? spawnSync([command, ...args].join(" "), { stdio: "inherit", shell: true, windowsHide: true })
|
|
136
|
+
: spawnSync(command, args, { stdio: "inherit", windowsHide: true });
|
|
137
|
+
return r.error ? null : r.status;
|
|
138
|
+
}
|
|
139
|
+
function defaultCapture(command, args) {
|
|
140
|
+
const opts = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true };
|
|
141
|
+
const r = needsShell(command)
|
|
142
|
+
? spawnSync([command, ...args].join(" "), { ...opts, shell: true })
|
|
143
|
+
: spawnSync(command, args, opts);
|
|
144
|
+
return r.error || r.status !== 0 ? undefined : r.stdout;
|
|
145
|
+
}
|
|
146
|
+
function versionAt(root) {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** `bir update`. Returns the exit code: 1 when nothing was updated. */
|
|
155
|
+
export function runUpdate(deps) {
|
|
156
|
+
const plan = planUpdate(deps);
|
|
157
|
+
if (plan.kind === "refused") {
|
|
158
|
+
deps.err(plan.why);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
const run = deps.run ?? defaultRun;
|
|
162
|
+
if (plan.kind === "npm") {
|
|
163
|
+
// Where would npm put it? Somewhere else means a second copy, and the
|
|
164
|
+
// `bir` a shell finds would stay this one. An npm that cannot say is not
|
|
165
|
+
// held against the update: npm itself reports what is wrong with it.
|
|
166
|
+
const answer = (deps.capture ?? defaultCapture)("npm", ["root", "-g"]);
|
|
167
|
+
const globalModules = answer?.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
|
|
168
|
+
if (globalModules && !samePath(join(globalModules, ...PACKAGE_NAME.split("/")), plan.root)) {
|
|
169
|
+
deps.err(`npm keeps its global packages in ${globalModules}, and this bir is not there (it runs from ${plan.root}): ` +
|
|
170
|
+
"`npm install -g` would add a second copy and leave this one as it is. " +
|
|
171
|
+
"Update it with the tool that installed it, then run `bir setup` in each project.");
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const before = versionAt(plan.root) ?? packageVersion();
|
|
176
|
+
deps.out(`Updating bir ${before} in ${plan.root}`);
|
|
177
|
+
deps.out(` ${[plan.command, ...plan.args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ")}`);
|
|
178
|
+
const code = run(plan.command, plan.args);
|
|
179
|
+
if (code !== 0) {
|
|
180
|
+
deps.err(code === null
|
|
181
|
+
? `${plan.command} could not be started, so nothing was updated.`
|
|
182
|
+
: `npm ended with exit code ${code}, so bir was not updated; the lines above say why.`);
|
|
183
|
+
if (process.platform === "win32") {
|
|
184
|
+
deps.err("If they name EPERM or EBUSY, a running program holds the runner's files: close Claude Code and run `bir update` again.");
|
|
185
|
+
}
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
const after = versionAt(plan.root);
|
|
189
|
+
deps.out(after && after !== before ? `bir is now ${after} (it was ${before}).` : `bir ${after ?? before} is the newest version; nothing changed.`);
|
|
190
|
+
deps.out("Next: `bir setup` in each project that uses it. It moves the wiring to this version and replaces an older recorder;");
|
|
191
|
+
deps.out("one that is serving a Claude Code session mid-turn stays until `bir up --restart`, once that session ends.");
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=update.js.map
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* committed and would carry this machine's node and runner paths to every
|
|
21
21
|
* clone, and never the user scope, which applies to every project.
|
|
22
22
|
*/
|
|
23
|
+
import { type InstalledSidecar } from "../generate.js";
|
|
23
24
|
import { type McpServerConfig, type Scope } from "../resolve.js";
|
|
24
25
|
export interface ClaudeCodePaths {
|
|
25
26
|
/** `~/.claude.json` (user + local scopes). */
|
|
@@ -106,6 +107,22 @@ export declare function addAllowRules(settingsPath: string, rules: string[]): {
|
|
|
106
107
|
export declare function removeAllowRules(settingsPath: string, rules: string[]): string | undefined;
|
|
107
108
|
/** True when `settingsPath` holds any hook of ours. */
|
|
108
109
|
export declare function hasBirHooks(settingsPath: string): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Whether `cwd` is set up for Claude Code: an install wrapped a server for
|
|
112
|
+
* it, or hooks of ours are in its settings files.
|
|
113
|
+
*/
|
|
114
|
+
export declare function claudeCodeSetUp(cwd: string, sidecar: InstalledSidecar): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* The hosts other than Claude Code that `cwd` was set up for (`bir setup
|
|
117
|
+
* --host langchain`), or an empty list.
|
|
118
|
+
*/
|
|
119
|
+
export declare function otherHosts(cwd: string, sidecar: InstalledSidecar): string[];
|
|
120
|
+
/**
|
|
121
|
+
* Whether `cwd` is set up for another host and NOT for Claude Code. Asked
|
|
122
|
+
* afresh each time rather than stored: a later `bir setup` for Claude Code in
|
|
123
|
+
* the same folder must be seen at once.
|
|
124
|
+
*/
|
|
125
|
+
export declare function otherHostOnly(cwd: string, sidecar: InstalledSidecar): boolean;
|
|
109
126
|
/** Which file holds a given scope's `mcpServers` for this cwd. */
|
|
110
127
|
export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): string;
|
|
111
128
|
export declare function sha256(text: string): string;
|
|
@@ -165,7 +182,7 @@ export declare function setLocalEntries(claudeJson: string, keys: string[], name
|
|
|
165
182
|
* to its `timeout`, so each number here is how long a slow or blackholed
|
|
166
183
|
* service could make a person wait — at every prompt, every tool call, every
|
|
167
184
|
* `/exit`. The control server keeps its own per-route deadlines well inside
|
|
168
|
-
* them (prompt ≤
|
|
185
|
+
* them (prompt ≤ 8.5 s, tool/pre ≤ 12 s, tool/post and stop ≤ 2 s, end ≤ 1 s,
|
|
169
186
|
* start ≤ 2 s) and answers `{}` on overrun, so these are the second line of
|
|
170
187
|
* defence, not a budget to spend. SessionEnd matters twice: Claude Code's own
|
|
171
188
|
* default for it is 1.5 s, and whatever is written here RAISES that — the 30 it
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
24
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
25
25
|
import { dirname, join, parse as parsePath, resolve as resolvePath } from "node:path";
|
|
26
|
+
import { normalizePath } from "../../control/paths.js";
|
|
26
27
|
import { writeFileAtomic } from "../../util/atomic-write.js";
|
|
27
|
-
import { FIRST_PROJECT_PORT, PACKAGE_NAME, SCENARIO_SERVER_KEY } from "../generate.js";
|
|
28
|
+
import { FIRST_PROJECT_PORT, PACKAGE_NAME, SCENARIO_SERVER_KEY, projectRecord, } from "../generate.js";
|
|
28
29
|
import { claudeJsonPath, parseJsonFile, parseJsonText, projectKeyFor, } from "../resolve.js";
|
|
29
30
|
export function claudeCodePaths(cwd, claudeJsonOverride) {
|
|
30
31
|
return {
|
|
@@ -258,6 +259,32 @@ export function hasBirHooks(settingsPath) {
|
|
|
258
259
|
return false;
|
|
259
260
|
return Object.values(hooks).some((matchers) => (matchers ?? []).some((m) => (m.hooks ?? []).some((h) => isBirHook(h))));
|
|
260
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Whether `cwd` is set up for Claude Code: an install wrapped a server for
|
|
264
|
+
* it, or hooks of ours are in its settings files.
|
|
265
|
+
*/
|
|
266
|
+
export function claudeCodeSetUp(cwd, sidecar) {
|
|
267
|
+
const here = normalizePath(cwd);
|
|
268
|
+
if (Object.values(sidecar.servers ?? {}).some((record) => normalizePath(record.cwd) === here))
|
|
269
|
+
return true;
|
|
270
|
+
const paths = claudeCodePaths(cwd);
|
|
271
|
+
return hasBirHooks(paths.settingsLocal) || hasBirHooks(paths.settings);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The hosts other than Claude Code that `cwd` was set up for (`bir setup
|
|
275
|
+
* --host langchain`), or an empty list.
|
|
276
|
+
*/
|
|
277
|
+
export function otherHosts(cwd, sidecar) {
|
|
278
|
+
return projectRecord(sidecar, cwd)?.hosts ?? [];
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Whether `cwd` is set up for another host and NOT for Claude Code. Asked
|
|
282
|
+
* afresh each time rather than stored: a later `bir setup` for Claude Code in
|
|
283
|
+
* the same folder must be seen at once.
|
|
284
|
+
*/
|
|
285
|
+
export function otherHostOnly(cwd, sidecar) {
|
|
286
|
+
return otherHosts(cwd, sidecar).length > 0 && !claudeCodeSetUp(cwd, sidecar);
|
|
287
|
+
}
|
|
261
288
|
/** Which file holds a given scope's `mcpServers` for this cwd. */
|
|
262
289
|
export function fileForScope(scope, paths) {
|
|
263
290
|
switch (scope) {
|
|
@@ -421,7 +448,7 @@ export function setLocalEntries(claudeJson, keys, name, entry, shouldTouch = ()
|
|
|
421
448
|
* to its `timeout`, so each number here is how long a slow or blackholed
|
|
422
449
|
* service could make a person wait — at every prompt, every tool call, every
|
|
423
450
|
* `/exit`. The control server keeps its own per-route deadlines well inside
|
|
424
|
-
* them (prompt ≤
|
|
451
|
+
* them (prompt ≤ 8.5 s, tool/pre ≤ 12 s, tool/post and stop ≤ 2 s, end ≤ 1 s,
|
|
425
452
|
* start ≤ 2 s) and answers `{}` on overrun, so these are the second line of
|
|
426
453
|
* defence, not a budget to spend. SessionEnd matters twice: Claude Code's own
|
|
427
454
|
* default for it is 1.5 s, and whatever is written here RAISES that — the 30 it
|
|
@@ -433,7 +460,7 @@ export const HOOK_ROUTES = [
|
|
|
433
460
|
{ event: "SessionStart", route: "/session/start", matcher: "", timeout: 5 },
|
|
434
461
|
// Awaits the similar-prompt match and the derivation before it can return a
|
|
435
462
|
// steering directive (docs/calculatedReplay.md §10); the server caps the
|
|
436
|
-
// whole route at
|
|
463
|
+
// whole route at 8.5 s.
|
|
437
464
|
//
|
|
438
465
|
// It must NEVER become `async`. An async hook is fire-and-forget, so it cannot
|
|
439
466
|
// return `additionalContext` at all — replay would silently never arm.
|
|
@@ -484,6 +484,20 @@ export interface ProjectRecord {
|
|
|
484
484
|
* approved by the runner) was shown for this project; it is said once.
|
|
485
485
|
*/
|
|
486
486
|
approvalNoteShown?: boolean;
|
|
487
|
+
/**
|
|
488
|
+
* Hosts other than Claude Code this folder was set up for, with
|
|
489
|
+
* `bir setup --host <name>` (`langchain`). Such a host needs no hooks and
|
|
490
|
+
* no wrapped servers — its adapter runs inside the application and finds
|
|
491
|
+
* the recorder by its control file — so the entry is all there is: the
|
|
492
|
+
* folder's own port and token, and its replay switch.
|
|
493
|
+
*
|
|
494
|
+
* A folder that is set up for such a host and not for Claude Code gets its
|
|
495
|
+
* recorder started with a small environment (control/daemon.ts,
|
|
496
|
+
* `smallEnv`): the application's keys and passwords are not the recorder's
|
|
497
|
+
* to hold. One set up for Claude Code too keeps the whole environment, as
|
|
498
|
+
* Claude Code's own steps may need it.
|
|
499
|
+
*/
|
|
500
|
+
hosts?: string[];
|
|
487
501
|
}
|
|
488
502
|
export interface InstalledSidecar {
|
|
489
503
|
version: 1;
|
package/dist/control/daemon.d.ts
CHANGED
|
@@ -206,6 +206,12 @@ export interface EnsureOptions {
|
|
|
206
206
|
* instead of clearing it, and throw {@link RecorderStoppedError} when found.
|
|
207
207
|
*/
|
|
208
208
|
revive?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Start the recorder with the small environment ({@link smallEnv}) or with
|
|
211
|
+
* the whole one. Unset, the folder decides: small when it is set up for
|
|
212
|
+
* another host and not for Claude Code. Tests pass it.
|
|
213
|
+
*/
|
|
214
|
+
smallEnv?: boolean;
|
|
209
215
|
}
|
|
210
216
|
export interface EnsureResult {
|
|
211
217
|
status: DaemonStatus;
|
|
@@ -358,8 +364,27 @@ export declare function reviveDaemon(cwd: string, opts?: Pick<EnsureOptions, "ho
|
|
|
358
364
|
* happened to start it switched all of them off for the recorder's life. That
|
|
359
365
|
* window's SessionStart hook tells the recorder its own session is off
|
|
360
366
|
* instead (ensure-hook.ts); the flag files still switch everything.
|
|
367
|
+
*
|
|
368
|
+
* `small` starts from {@link smallEnv} rather than from everything the
|
|
369
|
+
* caller has: for a folder set up for another host and not for Claude Code.
|
|
370
|
+
*/
|
|
371
|
+
export declare function daemonEnv(base: NodeJS.ProcessEnv, cwd: string, logFile: string, small?: boolean): NodeJS.ProcessEnv;
|
|
372
|
+
/**
|
|
373
|
+
* The small environment: what a recorder gets in a folder that is set up for
|
|
374
|
+
* a host other than Claude Code, and not for Claude Code.
|
|
375
|
+
*
|
|
376
|
+
* {@link daemonEnv} otherwise copies everything but three names, so a model
|
|
377
|
+
* key or a database password in the window that started the recorder reaches
|
|
378
|
+
* it — and step code runs inside the recorder, with no sandbox. With Claude
|
|
379
|
+
* Code that window is the developer's own. In such a folder it is the window
|
|
380
|
+
* of an application, and the recordings its plans are written from hold what
|
|
381
|
+
* that application's users typed. The same list as the LangChain adapter's
|
|
382
|
+
* own, which starts the recorder with it in every folder.
|
|
383
|
+
*
|
|
384
|
+
* A plan's step that needs a variable outside the list fails for it. That is
|
|
385
|
+
* by design: step code must not depend on the application's secrets.
|
|
361
386
|
*/
|
|
362
|
-
export declare function
|
|
387
|
+
export declare function smallEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
363
388
|
/**
|
|
364
389
|
* Stop the control server for `cwd`.
|
|
365
390
|
*
|
package/dist/control/daemon.js
CHANGED
|
@@ -64,6 +64,7 @@ import { ControlClient } from "./client.js";
|
|
|
64
64
|
import { listSupersededRecorders, pidAlive, readDiscovery, removeDiscovery, removeDiscoveryIfOwned, removeSupersededRecord, writeDiscovery, writeSupersededRecord, } from "./discovery.js";
|
|
65
65
|
import { configDir, controlDir, controlKey, daemonLogPath, downMarkerPath, ensureDir, logsDir, normalizePath, portProblemPath, } from "./paths.js";
|
|
66
66
|
import { nodeFlagsToCarry, projectRecord, projectToken, readSidecar, } from "../config/generate.js";
|
|
67
|
+
import { otherHostOnly } from "../config/adapters/claude-code.js";
|
|
67
68
|
import { writeFileAtomic } from "../util/atomic-write.js";
|
|
68
69
|
import { timeoutSignal } from "../util/service-health.js";
|
|
69
70
|
import { errText, logLine } from "../util/log.js";
|
|
@@ -686,7 +687,7 @@ export async function ensureDaemon(cwd, opts = {}) {
|
|
|
686
687
|
const script = opts.hooksScript ?? hooksScriptPath();
|
|
687
688
|
ensureDir(logsDir());
|
|
688
689
|
rotateLog(logFile);
|
|
689
|
-
const childEnv = daemonEnv(env, cwd, logFile);
|
|
690
|
+
const childEnv = daemonEnv(env, cwd, logFile, opts.smallEnv ?? startsSmall(cwd));
|
|
690
691
|
const launchedAt = Date.now();
|
|
691
692
|
const launched = process.platform === "win32" && childEnv[WINDOWS_LAUNCH_ENV] !== "spawn"
|
|
692
693
|
? launchWindows(script, childEnv, clamp(remaining(deadline) - 1_000, 2_000, 8_000))
|
|
@@ -816,9 +817,12 @@ export async function reviveDaemon(cwd, opts = {}) {
|
|
|
816
817
|
* happened to start it switched all of them off for the recorder's life. That
|
|
817
818
|
* window's SessionStart hook tells the recorder its own session is off
|
|
818
819
|
* instead (ensure-hook.ts); the flag files still switch everything.
|
|
820
|
+
*
|
|
821
|
+
* `small` starts from {@link smallEnv} rather than from everything the
|
|
822
|
+
* caller has: for a folder set up for another host and not for Claude Code.
|
|
819
823
|
*/
|
|
820
|
-
export function daemonEnv(base, cwd, logFile) {
|
|
821
|
-
const env = { ...base };
|
|
824
|
+
export function daemonEnv(base, cwd, logFile, small = false) {
|
|
825
|
+
const env = small ? smallEnv(base) : { ...base };
|
|
822
826
|
deleteEnv(env, SETUP_TOKEN_ENV);
|
|
823
827
|
if (base[USE_API_KEY_ENV] !== "1")
|
|
824
828
|
deleteEnv(env, "ANTHROPIC_API_KEY");
|
|
@@ -835,6 +839,52 @@ export function daemonEnv(base, cwd, logFile) {
|
|
|
835
839
|
env[DAEMON_CWD_ENV] = isAbsolute(cwd) ? cwd : resolvePath(cwd);
|
|
836
840
|
return env;
|
|
837
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* What {@link smallEnv} lets through besides the `BIR_*` variables: the path,
|
|
844
|
+
* the home and temp folders, what the system needs to start a process (on
|
|
845
|
+
* Windows, Node does not start without `SystemRoot`), and the proxy and
|
|
846
|
+
* certificate settings the recorder's own calls to the service need.
|
|
847
|
+
*/
|
|
848
|
+
const SMALL_ENV_KEPT = new Set([
|
|
849
|
+
"PATH", "HOME", "TMPDIR", "TEMP", "TMP",
|
|
850
|
+
"USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA",
|
|
851
|
+
"SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT",
|
|
852
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY",
|
|
853
|
+
"NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
|
|
854
|
+
]);
|
|
855
|
+
/**
|
|
856
|
+
* The small environment: what a recorder gets in a folder that is set up for
|
|
857
|
+
* a host other than Claude Code, and not for Claude Code.
|
|
858
|
+
*
|
|
859
|
+
* {@link daemonEnv} otherwise copies everything but three names, so a model
|
|
860
|
+
* key or a database password in the window that started the recorder reaches
|
|
861
|
+
* it — and step code runs inside the recorder, with no sandbox. With Claude
|
|
862
|
+
* Code that window is the developer's own. In such a folder it is the window
|
|
863
|
+
* of an application, and the recordings its plans are written from hold what
|
|
864
|
+
* that application's users typed. The same list as the LangChain adapter's
|
|
865
|
+
* own, which starts the recorder with it in every folder.
|
|
866
|
+
*
|
|
867
|
+
* A plan's step that needs a variable outside the list fails for it. That is
|
|
868
|
+
* by design: step code must not depend on the application's secrets.
|
|
869
|
+
*/
|
|
870
|
+
export function smallEnv(base) {
|
|
871
|
+
const env = {};
|
|
872
|
+
for (const [key, value] of Object.entries(base)) {
|
|
873
|
+
const name = key.toUpperCase();
|
|
874
|
+
if (SMALL_ENV_KEPT.has(name) || name.startsWith("BIR_"))
|
|
875
|
+
env[key] = value;
|
|
876
|
+
}
|
|
877
|
+
return env;
|
|
878
|
+
}
|
|
879
|
+
/** Whether `cwd`'s recorder is started with {@link smallEnv}. Never throws: unreadable settings mean the whole environment, as before. */
|
|
880
|
+
function startsSmall(cwd) {
|
|
881
|
+
try {
|
|
882
|
+
return otherHostOnly(cwd, readSidecar());
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
838
888
|
/** Delete `name` from an env copy — case-insensitively on Windows, where env names are. */
|
|
839
889
|
function deleteEnv(env, name) {
|
|
840
890
|
for (const key of Object.keys(env)) {
|
|
@@ -78,6 +78,11 @@ export declare function commandShape(command: string, depth?: number, escape?: s
|
|
|
78
78
|
/**
|
|
79
79
|
* Whether the model's call `modelArgs` to `toolName` may be pinned to the
|
|
80
80
|
* step's `pinnedArgs`. See the file comment for the rule.
|
|
81
|
+
*
|
|
82
|
+
* `otherHost` is a session whose host is not Claude Code. Every rule above
|
|
83
|
+
* the last one reads a tool's name as Claude Code's (`Bash`, `Write`, `Read`,
|
|
84
|
+
* an `mcp__` name), and another host's tool of the same name is another tool:
|
|
85
|
+
* only the general rule is applied there.
|
|
81
86
|
*/
|
|
82
|
-
export declare function pinCompatible(toolName: string, modelArgs: unknown, pinnedArgs: unknown): PinVerdict;
|
|
87
|
+
export declare function pinCompatible(toolName: string, modelArgs: unknown, pinnedArgs: unknown, otherHost?: boolean): PinVerdict;
|
|
83
88
|
//# sourceMappingURL=pin-guard.d.ts.map
|
|
@@ -487,12 +487,26 @@ function statementVerdict(model, step) {
|
|
|
487
487
|
/**
|
|
488
488
|
* Whether the model's call `modelArgs` to `toolName` may be pinned to the
|
|
489
489
|
* step's `pinnedArgs`. See the file comment for the rule.
|
|
490
|
+
*
|
|
491
|
+
* `otherHost` is a session whose host is not Claude Code. Every rule above
|
|
492
|
+
* the last one reads a tool's name as Claude Code's (`Bash`, `Write`, `Read`,
|
|
493
|
+
* an `mcp__` name), and another host's tool of the same name is another tool:
|
|
494
|
+
* only the general rule is applied there.
|
|
490
495
|
*/
|
|
491
|
-
export function pinCompatible(toolName, modelArgs, pinnedArgs) {
|
|
496
|
+
export function pinCompatible(toolName, modelArgs, pinnedArgs, otherHost = false) {
|
|
492
497
|
const model = record(modelArgs);
|
|
493
498
|
const step = record(pinnedArgs);
|
|
494
499
|
if (stableStringify(model) === stableStringify(step))
|
|
495
500
|
return { ok: true };
|
|
501
|
+
if (otherHost) {
|
|
502
|
+
const modelKeys = Object.keys(model);
|
|
503
|
+
const stepKeys = Object.keys(step);
|
|
504
|
+
if (modelKeys.length === 0 || stepKeys.length === 0)
|
|
505
|
+
return { ok: true };
|
|
506
|
+
return modelKeys.some((k) => stepKeys.includes(k))
|
|
507
|
+
? { ok: true }
|
|
508
|
+
: { ok: false, why: "it shares no argument with the step" };
|
|
509
|
+
}
|
|
496
510
|
if (toolName === "Bash" || toolName === "PowerShell") {
|
|
497
511
|
const mine = typeof model.command === "string" ? model.command.trim() : "";
|
|
498
512
|
const theirs = typeof step.command === "string" ? step.command.trim() : "";
|
|
@@ -47,6 +47,7 @@ import { SwitchableRecorder } from "../record/switchable-recorder.js";
|
|
|
47
47
|
import { configPath } from "./paths.js";
|
|
48
48
|
import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal } from "../util/service-health.js";
|
|
49
49
|
import { isRunnerOff } from "../util/off-switch.js";
|
|
50
|
+
import { serviceHeaders } from "../util/service-headers.js";
|
|
50
51
|
import { errText, logDetail, logLine } from "../util/log.js";
|
|
51
52
|
/** What `authenticate()` hands out under `BIR_AUTH_DISABLE=1` (auth/client.ts). */
|
|
52
53
|
const LOCAL_DEV_SESSION = {
|
|
@@ -187,7 +188,7 @@ export async function refreshSession(baseUrl, session, fetchImpl = fetch, timeou
|
|
|
187
188
|
try {
|
|
188
189
|
res = await fetchImpl(`${trimUrl(baseUrl)}/auth/refresh`, {
|
|
189
190
|
method: "POST",
|
|
190
|
-
headers: { "content-type": "application/json" },
|
|
191
|
+
headers: { "content-type": "application/json", ...serviceHeaders() },
|
|
191
192
|
body: JSON.stringify({ refreshToken: session.refreshToken }),
|
|
192
193
|
signal: timeoutSignal(timeoutMs),
|
|
193
194
|
});
|