@basein/runner 0.3.25 → 0.3.27
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 +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.d.ts +13 -0
- package/dist/bin/investigate.js +72 -2
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +75 -11
- package/dist/control/server.js +440 -77
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +15 -11
- package/dist/replay/bundle.js +167 -33
- package/dist/replay/controller.d.ts +92 -1
- package/dist/replay/controller.js +246 -47
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/plan.d.ts +41 -0
- package/dist/replay/plan.js +171 -3
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/reads-only.d.ts +48 -0
- package/dist/replay/reads-only.js +161 -0
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reads-only — whether one call of the agent's only looks, and changes nothing.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE RUNNER ASKS (B2 is11.md, 2026-11-11). A direct plan is announced
|
|
5
|
+
* with "call run_scenario before any other tool", and a first call that is
|
|
6
|
+
* not `run_scenario` used to retire it on the spot (control/server.ts). The
|
|
7
|
+
* rule is there for one danger: a model that has no `run_scenario` makes its
|
|
8
|
+
* own calls, and a divergence would then run every step, writes included,
|
|
9
|
+
* inside a hook nobody approves. But the call that retired the plan in is11
|
|
10
|
+
* was the agent reading its own notes file: it had seen another person's name
|
|
11
|
+
* in the note (Olga, the recording's; the request said Carlos) and checked
|
|
12
|
+
* first. The plan would have done the task right — its steps take the person
|
|
13
|
+
* from the request — and the turn cost a full agent run.
|
|
14
|
+
*
|
|
15
|
+
* So a call that only looks no longer retires a direct plan: it runs as the
|
|
16
|
+
* agent wrote it, and the plan waits, still announced, for `run_scenario`.
|
|
17
|
+
* The first call that may change something still retires it, running nothing
|
|
18
|
+
* (and nothing ever runs in a hook in its place): a plan run after the agent
|
|
19
|
+
* changed something itself could do the same work twice.
|
|
20
|
+
*
|
|
21
|
+
* WHEN IN DOUBT, IT IS A CHANGE. A call judged "only looks" that does change
|
|
22
|
+
* something leaves a plan armed that could then repeat that change; a change
|
|
23
|
+
* judged a change only ends a replay early, as every call did before. So:
|
|
24
|
+
*
|
|
25
|
+
* - Claude Code's built-ins by name: the reading ones only look, every other
|
|
26
|
+
* built-in (a sub-agent, an edit, an unknown new tool) is a change;
|
|
27
|
+
* - `Bash` by its command ({@link commandReadsOnly}), and `PowerShell` never;
|
|
28
|
+
* - an MCP tool by what its own server says of it (`annotations`), else by
|
|
29
|
+
* its name: a reading word and no writing word.
|
|
30
|
+
*
|
|
31
|
+
* The same rule as the service's `readsOnly` (correctionCheck.ts, AC13), kept
|
|
32
|
+
* in step by hand, stricter where a wrong "only looks" costs more here: a
|
|
33
|
+
* command substitution, a process substitution or an `awk` `system(` makes a
|
|
34
|
+
* command a change.
|
|
35
|
+
*/
|
|
36
|
+
/** Claude Code's built-ins that only read. `ToolSearch` and `TodoWrite` are housekeeping (record/housekeeping.ts). */
|
|
37
|
+
const READ_BUILTINS = new Set([
|
|
38
|
+
"Read",
|
|
39
|
+
"Grep",
|
|
40
|
+
"Glob",
|
|
41
|
+
"LS",
|
|
42
|
+
"WebFetch",
|
|
43
|
+
"WebSearch",
|
|
44
|
+
"NotebookRead",
|
|
45
|
+
"TaskOutput",
|
|
46
|
+
"BashOutput",
|
|
47
|
+
// The conversation's own: a question to the person, plan mode in or out.
|
|
48
|
+
// Nothing outside the conversation changes, and the plan may still be
|
|
49
|
+
// what the person says yes to.
|
|
50
|
+
"AskUserQuestion",
|
|
51
|
+
"EnterPlanMode",
|
|
52
|
+
"ExitPlanMode",
|
|
53
|
+
]);
|
|
54
|
+
// Not `query`, `select` or `check`: a database query, a selected option, a
|
|
55
|
+
// check-in can change something.
|
|
56
|
+
const READ_WORDS = new Set([
|
|
57
|
+
"list", "get", "search", "find", "read", "fetch", "lookup", "count", "view", "show",
|
|
58
|
+
"describe", "retrieve", "browse", "peek", "inspect", "snapshot",
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* A word that may change something: a name holding one writes, whatever else
|
|
62
|
+
* it says (`get_or_create_contact`, `find_and_replace`). Some are nouns too
|
|
63
|
+
* (`get_schedule`): such a look is then taken for a change, which only ends a
|
|
64
|
+
* replay early, never lets a change through.
|
|
65
|
+
*/
|
|
66
|
+
const WRITE_WORDS = new Set([
|
|
67
|
+
"create", "add", "set", "update", "upsert", "insert", "put", "post", "send", "delete",
|
|
68
|
+
"remove", "mark", "replace", "archive", "move", "assign", "book", "cancel", "write",
|
|
69
|
+
"edit", "modify", "save", "submit", "approve", "reject", "close", "open", "merge",
|
|
70
|
+
"push", "publish", "upload", "invite", "schedule", "reply", "forward", "transfer",
|
|
71
|
+
"pay", "refund", "notify", "run", "execute", "exec", "apply", "reset", "start", "stop",
|
|
72
|
+
"kill", "enable", "disable", "grant", "revoke", "rename", "tag", "label", "comment",
|
|
73
|
+
]);
|
|
74
|
+
/** A tool name's words: `getJiraIssue` → get, jira, issue; `calendar_search_events` → calendar, search, events. */
|
|
75
|
+
function nameWords(name) {
|
|
76
|
+
return name
|
|
77
|
+
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
|
78
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.split(/[^a-z\d]+/)
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether this call only looks. `toolInput` is the hook's `tool_input` (only a
|
|
85
|
+
* `Bash` call's `command` is read from it); `hints` is what the tool's own
|
|
86
|
+
* server listed for it, when it listed anything.
|
|
87
|
+
*/
|
|
88
|
+
export function readsOnlyCall(toolName, toolInput, hints) {
|
|
89
|
+
if (toolName === "Bash") {
|
|
90
|
+
const command = toolInput?.command;
|
|
91
|
+
return typeof command === "string" && commandReadsOnly(command);
|
|
92
|
+
}
|
|
93
|
+
if (READ_BUILTINS.has(toolName))
|
|
94
|
+
return true;
|
|
95
|
+
// Every other built-in — Write, Edit, PowerShell, a sub-agent, one added
|
|
96
|
+
// after this list was written — may change something.
|
|
97
|
+
if (!toolName.startsWith("mcp__"))
|
|
98
|
+
return false;
|
|
99
|
+
// A server's word beats any guess from the name.
|
|
100
|
+
if (hints?.readOnlyHint === false || hints?.destructiveHint === true)
|
|
101
|
+
return false;
|
|
102
|
+
if (hints?.readOnlyHint === true)
|
|
103
|
+
return true;
|
|
104
|
+
const words = nameWords(toolName.replace(/^mcp__.+?__/, ""));
|
|
105
|
+
if (words.some((w) => WRITE_WORDS.has(w)))
|
|
106
|
+
return false;
|
|
107
|
+
return words.some((w) => READ_WORDS.has(w));
|
|
108
|
+
}
|
|
109
|
+
/** Commands that only look (`ls`, `cat`, `grep …`). */
|
|
110
|
+
const READ_COMMANDS = new Set([
|
|
111
|
+
"ls", "dir", "cat", "type", "head", "tail", "less", "more", "grep", "rg", "egrep", "fgrep",
|
|
112
|
+
"find", "wc", "pwd", "echo", "printf", "date", "whoami", "which", "where", "stat", "file",
|
|
113
|
+
"du", "df", "tree", "jq", "sort", "uniq", "cut", "awk", "diff", "cmp", "basename", "dirname",
|
|
114
|
+
"realpath", "readlink", "true", "false", "test", "ps", "uname", "hostname", "id", "nproc",
|
|
115
|
+
"free", "uptime", "node --version", "npm --version",
|
|
116
|
+
]);
|
|
117
|
+
const READ_GIT = new Set([
|
|
118
|
+
"status", "log", "diff", "show", "branch", "rev-parse", "ls-files", "blame", "remote",
|
|
119
|
+
"describe", "config --get", "grep", "shortlog", "tag --list",
|
|
120
|
+
]);
|
|
121
|
+
/**
|
|
122
|
+
* Whether a shell command only looks: every part of it is a looking command,
|
|
123
|
+
* nothing is written to a file, and nothing runs code it does not show.
|
|
124
|
+
*/
|
|
125
|
+
export function commandReadsOnly(command) {
|
|
126
|
+
const text = command.trim();
|
|
127
|
+
if (!text)
|
|
128
|
+
return false;
|
|
129
|
+
// Code that runs out of sight: a substitution (`$(…)`, backticks), a
|
|
130
|
+
// process substitution, awk's `system(`.
|
|
131
|
+
if (/`|\$\(|<\(|>\(|\bsystem\s*\(/.test(text))
|
|
132
|
+
return false;
|
|
133
|
+
// A redirect into a file (not into /dev/null, not 2>&1), tee, or an in-place edit changes something.
|
|
134
|
+
if (/(^|[^0-9&])>{1,2}\s*(?!&|\/dev\/null)\S/.test(text) ||
|
|
135
|
+
/&>{1,2}\s*(?!\/dev\/null)\S/.test(text) ||
|
|
136
|
+
/\btee\b/.test(text) ||
|
|
137
|
+
/\bsed\b[^|;&]*\s(-i|--in-place)/.test(text)) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (/\bfind\b[^|;&]*\s-(delete|exec|execdir|ok|okdir|fprint|fprintf|fls)\b/.test(text))
|
|
141
|
+
return false;
|
|
142
|
+
// A job sent to the background (`cmd &`): not something this reads.
|
|
143
|
+
if (/(?<![>&])&(?![>&])/.test(text.replace(/&&/g, "")))
|
|
144
|
+
return false;
|
|
145
|
+
const parts = text
|
|
146
|
+
.split(/\|\||&&|[|;\n]/)
|
|
147
|
+
.map((p) => p.trim())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
if (parts.length === 0)
|
|
150
|
+
return false;
|
|
151
|
+
return parts.every((part) => {
|
|
152
|
+
const words = part.replace(/^(\w+=\S+\s+)+/, "").split(/\s+/);
|
|
153
|
+
const cmd = (words[0] ?? "").replace(/^.*[\\/]/, "");
|
|
154
|
+
if (cmd === "git")
|
|
155
|
+
return READ_GIT.has(words[1] ?? "") || READ_GIT.has(`${words[1] ?? ""} ${words[2] ?? ""}`);
|
|
156
|
+
if (cmd === "sed")
|
|
157
|
+
return !/\s(-i|--in-place)/.test(part);
|
|
158
|
+
return READ_COMMANDS.has(cmd) || READ_COMMANDS.has(`${cmd} ${words[1] ?? ""}`);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=reads-only.js.map
|
package/dist/replay/safety.js
CHANGED
|
@@ -49,7 +49,30 @@ const WRITE_BUILTINS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
|
|
|
49
49
|
* with the server.
|
|
50
50
|
*/
|
|
51
51
|
const MCP_SHELL_WORD = /(?:^|[_.-])(?:shell|bash|sh|zsh|ssh|exec|execute|command|cmd|terminal|powershell|pwsh)(?:$|[_.-])/i;
|
|
52
|
-
const MCP_COMMAND_WORD = /(?:^|[_.-])(?:sql|tsql|query|exec|execute|shell|bash|sh|zsh|ssh|command|cmd|script|eval|terminal|psql|mysql|mssql|sqlite|duckdb|postgres|postgresql|powershell|pwsh)(?:$|[_.-])/i;
|
|
52
|
+
const MCP_COMMAND_WORD = /(?:^|[_.-])(?:sql|tsql|query|exec|execute|shell|bash|sh|zsh|ssh|command|cmd|script|eval|cypher|terminal|psql|mysql|mssql|sqlite|duckdb|postgres|postgresql|powershell|pwsh)(?:$|[_.-])/i;
|
|
53
|
+
/**
|
|
54
|
+
* A tool whose input is code runs it as surely as a shell runs a command:
|
|
55
|
+
* `run_code`, `execute_python`, `code_interpreter`, `python_repl`, a
|
|
56
|
+
* `javascript_tool`, `browser_evaluate`, `evaluate_script`. Known by the words
|
|
57
|
+
* that say the code is run, and not by the language's name alone: as words of
|
|
58
|
+
* their own, `code`, `python`, `javascript` and `evaluate` made a command of
|
|
59
|
+
* `search_code`, `get_postal_code`, `send_verification_code`, `evaluate_answer`
|
|
60
|
+
* and of every tool of a `python-docs` or `claude-code` server — and with one
|
|
61
|
+
* such step every value of the plan was held to a command's rules, so a query
|
|
62
|
+
* with a quote in it handed the plan over. Whole words only, as above:
|
|
63
|
+
* `decode_token` and a `barcode` have the letters of `code` and are not it.
|
|
64
|
+
*/
|
|
65
|
+
const MCP_CODE_RUNNER = new RegExp(`(?:^|[_.-])(?:${[
|
|
66
|
+
"(?:run|exec|execute|eval)[_.-](?:code|python|py|javascript|js|typescript|ts|node)",
|
|
67
|
+
"(?:code|python|py|javascript|js|typescript|node)[_.-](?:run|runner|exec|execute|executor|eval|interpreter|repl)",
|
|
68
|
+
"(?:javascript|js)[_.-]tool",
|
|
69
|
+
"(?:browser|page|puppeteer|playwright|frame|tab|chrome|devtools)[_.-]evaluate",
|
|
70
|
+
"evaluate[_.-](?:script|js|javascript|expression|code)",
|
|
71
|
+
].join("|")})(?:$|[_.-])`, "i");
|
|
72
|
+
/** Whether a name, split into words ({@link nameWords}), says its input is run. */
|
|
73
|
+
function saysRun(words) {
|
|
74
|
+
return MCP_COMMAND_WORD.test(words) || MCP_CODE_RUNNER.test(words);
|
|
75
|
+
}
|
|
53
76
|
/**
|
|
54
77
|
* A tool or server name as delimited words: camelCase, PascalCase and acronym
|
|
55
78
|
* runs are split with `_`, so `executeSql` reads as `execute_Sql`, `runSQL` as
|
|
@@ -66,6 +89,18 @@ function mcpNameSays(toolName, words) {
|
|
|
66
89
|
return false;
|
|
67
90
|
return words.test(nameWords(mcp.toolName)) || words.test(nameWords(mcp.serverName));
|
|
68
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Whether an MCP tool's name, its server's, or the two read together say its
|
|
94
|
+
* input is run: `mcp__python__run` says it only with both (`python_run`).
|
|
95
|
+
*/
|
|
96
|
+
function mcpRunsInput(toolName) {
|
|
97
|
+
const mcp = parseQualifiedName(toolName);
|
|
98
|
+
if (!mcp)
|
|
99
|
+
return false;
|
|
100
|
+
const tool = nameWords(mcp.toolName);
|
|
101
|
+
const server = nameWords(mcp.serverName);
|
|
102
|
+
return saysRun(tool) || saysRun(server) || MCP_CODE_RUNNER.test(`${server}_${tool}`);
|
|
103
|
+
}
|
|
69
104
|
/**
|
|
70
105
|
* Characters and sequences that end a quoted string or begin another command
|
|
71
106
|
* in a POSIX shell, PowerShell or SQL. Deliberately wide: an apostrophe in a
|
|
@@ -105,13 +140,13 @@ export function sinkOf(toolName, host) {
|
|
|
105
140
|
return "command";
|
|
106
141
|
if (host.write?.includes(name))
|
|
107
142
|
return "write";
|
|
108
|
-
return
|
|
143
|
+
return saysRun(nameWords(name)) ? "command" : undefined;
|
|
109
144
|
}
|
|
110
145
|
if (SHELL_BUILTINS.has(name))
|
|
111
146
|
return "command";
|
|
112
147
|
if (WRITE_BUILTINS.has(name))
|
|
113
148
|
return "write";
|
|
114
|
-
return
|
|
149
|
+
return mcpRunsInput(name) ? "command" : undefined;
|
|
115
150
|
}
|
|
116
151
|
/** Whether this tool's input is run or written: a shell, a file edit, a query. */
|
|
117
152
|
export function isCommandSink(toolName) {
|
|
@@ -189,8 +224,11 @@ const SQL_BOOLEAN_WORD = /\b(?:or|and|union)\b/gi;
|
|
|
189
224
|
* keeps it.
|
|
190
225
|
*/
|
|
191
226
|
function offShape(value, sample) {
|
|
192
|
-
// A number stays a number: `1 OR 1=1` is not a user id.
|
|
193
|
-
|
|
227
|
+
// A number stays a number: `1 OR 1=1` is not a user id. A number kept as
|
|
228
|
+
// text (`"42"`) is a number all the same: with that sample, `"… WHERE
|
|
229
|
+
// user_id = " + parameters.v` took the value `user_id` and matched every
|
|
230
|
+
// row, where the sample 42 refused it.
|
|
231
|
+
if (typeof sample === "number" || (typeof sample === "string" && NUMERIC.test(sample))) {
|
|
194
232
|
if (typeof value === "number")
|
|
195
233
|
return Number.isFinite(value) ? undefined : String(value);
|
|
196
234
|
if (typeof value !== "string" || !NUMERIC.test(value))
|
|
@@ -41,12 +41,14 @@
|
|
|
41
41
|
* `initialize` got a 405 and the server showed as failed every session).
|
|
42
42
|
* A stream that ends — cleanly or not — is noticed, the requests riding it
|
|
43
43
|
* are answered, and it is re-established with backoff; the new session is
|
|
44
|
-
* re-initialised with the host's own `initialize` before anything else
|
|
44
|
+
* re-initialised with the host's own `initialize` before anything else,
|
|
45
|
+
* and a stream whose re-initialisation fails is a reconnect that failed.
|
|
45
46
|
* 4. **Session expiry is survivable.** A 404 to a request that carried a
|
|
46
47
|
* session means the server forgot us (a redeploy, an idle timeout). The
|
|
47
48
|
* spec says start a new session; the proxy does it for the host — replays
|
|
48
49
|
* the host's cached `initialize` under a `bir:` id, sends `initialized`,
|
|
49
|
-
* and retries the request once.
|
|
50
|
+
* and retries the request once. A replay that fails is tried again when
|
|
51
|
+
* the next request meets the same 404.
|
|
50
52
|
* 5. **No timeout shorter than the host's.** The transport (http-transport.ts)
|
|
51
53
|
* bounds the connect only; how long a tool may take is Claude Code's call,
|
|
52
54
|
* and its cancellation reaches the server immediately (rule 2). What no
|
|
@@ -200,7 +202,8 @@ export declare class HttpUpstreamClient extends BaseUpstreamClient {
|
|
|
200
202
|
/**
|
|
201
203
|
* Replay the host's `initialize` (under a `bir:` id) and `initialized`.
|
|
202
204
|
* `news`: say it on stderr when it worked — after a forgotten session or an
|
|
203
|
-
* outage; after a routine reconnect it is detail. A failure is
|
|
205
|
+
* outage; after a routine reconnect it is detail. A failure is said, unless
|
|
206
|
+
* `failureSaid`: it is one more of an outage whose first failure was.
|
|
204
207
|
*/
|
|
205
208
|
private reinitialize;
|
|
206
209
|
/**
|
|
@@ -41,12 +41,14 @@
|
|
|
41
41
|
* `initialize` got a 405 and the server showed as failed every session).
|
|
42
42
|
* A stream that ends — cleanly or not — is noticed, the requests riding it
|
|
43
43
|
* are answered, and it is re-established with backoff; the new session is
|
|
44
|
-
* re-initialised with the host's own `initialize` before anything else
|
|
44
|
+
* re-initialised with the host's own `initialize` before anything else,
|
|
45
|
+
* and a stream whose re-initialisation fails is a reconnect that failed.
|
|
45
46
|
* 4. **Session expiry is survivable.** A 404 to a request that carried a
|
|
46
47
|
* session means the server forgot us (a redeploy, an idle timeout). The
|
|
47
48
|
* spec says start a new session; the proxy does it for the host — replays
|
|
48
49
|
* the host's cached `initialize` under a `bir:` id, sends `initialized`,
|
|
49
|
-
* and retries the request once.
|
|
50
|
+
* and retries the request once. A replay that fails is tried again when
|
|
51
|
+
* the next request meets the same 404.
|
|
50
52
|
* 5. **No timeout shorter than the host's.** The transport (http-transport.ts)
|
|
51
53
|
* bounds the connect only; how long a tool may take is Claude Code's call,
|
|
52
54
|
* and its cancellation reaches the server immediately (rule 2). What no
|
|
@@ -72,7 +74,7 @@
|
|
|
72
74
|
* recovery, a session the server forgot, and a stream that keeps dropping as
|
|
73
75
|
* soon as it opens (once an hour per server at most).
|
|
74
76
|
*/
|
|
75
|
-
import { readFrames } from "../jsonrpc/framing.js";
|
|
77
|
+
import { readFrames, receivedText, rememberText } from "../jsonrpc/framing.js";
|
|
76
78
|
import { paramsObject } from "../jsonrpc/types.js";
|
|
77
79
|
import { logDetail, logEvery, logLine, logOnce, errText } from "../util/log.js";
|
|
78
80
|
import { BaseUpstreamClient, BIR_ID_PREFIX, isBirId } from "./client.js";
|
|
@@ -341,7 +343,11 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
|
|
|
341
343
|
accept: "application/json, text/event-stream",
|
|
342
344
|
...this.sessionHeaders(method === "initialize"),
|
|
343
345
|
...this.headers,
|
|
344
|
-
},
|
|
346
|
+
},
|
|
347
|
+
// The host's own line when this is the message as the relay read it
|
|
348
|
+
// (jsonrpc/framing.ts): serialised again, an integer past 2^53 in a
|
|
349
|
+
// tool's arguments reached the server rounded.
|
|
350
|
+
receivedText(msg) ?? JSON.stringify(msg), controller.signal);
|
|
345
351
|
}
|
|
346
352
|
catch (err) {
|
|
347
353
|
clearTimeout(abandonTimer);
|
|
@@ -516,8 +522,16 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
|
|
|
516
522
|
if (Array.isArray(parsed))
|
|
517
523
|
for (const item of parsed)
|
|
518
524
|
this.deliver(item);
|
|
519
|
-
else
|
|
525
|
+
else {
|
|
526
|
+
// One message, and this is its text: the relay writes it to the host as
|
|
527
|
+
// the server wrote it rather than as JavaScript read it (framing.ts).
|
|
528
|
+
// Without the white space around it — a body often ends in a newline —
|
|
529
|
+
// and only when it is one line; a batch's members have no text of their
|
|
530
|
+
// own, and are serialised as before.
|
|
531
|
+
if (parsed && typeof parsed === "object")
|
|
532
|
+
rememberText(parsed, raw.trim());
|
|
520
533
|
this.deliver(parsed);
|
|
534
|
+
}
|
|
521
535
|
return true;
|
|
522
536
|
}
|
|
523
537
|
/** Hand one inbound JSON-RPC message to the relay (or to a `bir:` waiter). */
|
|
@@ -601,7 +615,18 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
|
|
|
601
615
|
if (this.transport === "http") {
|
|
602
616
|
this.sessionId = undefined;
|
|
603
617
|
this.resetStandingStream();
|
|
604
|
-
|
|
618
|
+
if (await this.reinitialize())
|
|
619
|
+
return true;
|
|
620
|
+
// No new session: a 503 from an instance still starting, a network
|
|
621
|
+
// error, no answer in time. Left without an id, every later request
|
|
622
|
+
// went out bare, the server answered 400 or 404, and none of that
|
|
623
|
+
// read as "forgotten" any more — errors for the rest of the host's
|
|
624
|
+
// session, with the proxy up. Holding the forgotten id again makes
|
|
625
|
+
// the next request's 404 start another attempt. An id this attempt
|
|
626
|
+
// was handed is dropped with it: its handshake never completed.
|
|
627
|
+
if (!this.dead)
|
|
628
|
+
this.sessionId = staleKey;
|
|
629
|
+
return false;
|
|
605
630
|
}
|
|
606
631
|
// Legacy: the session *is* the event stream. Drop it and open a new one;
|
|
607
632
|
// reconnect() replays the handshake. A reconnect already under way (the
|
|
@@ -634,9 +659,10 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
|
|
|
634
659
|
/**
|
|
635
660
|
* Replay the host's `initialize` (under a `bir:` id) and `initialized`.
|
|
636
661
|
* `news`: say it on stderr when it worked — after a forgotten session or an
|
|
637
|
-
* outage; after a routine reconnect it is detail. A failure is
|
|
662
|
+
* outage; after a routine reconnect it is detail. A failure is said, unless
|
|
663
|
+
* `failureSaid`: it is one more of an outage whose first failure was.
|
|
638
664
|
*/
|
|
639
|
-
async reinitialize(news = true) {
|
|
665
|
+
async reinitialize(news = true, failureSaid = false) {
|
|
640
666
|
if (this.initParams === undefined || this.dead)
|
|
641
667
|
return false;
|
|
642
668
|
this.reinitCount += 1;
|
|
@@ -663,7 +689,10 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
|
|
|
663
689
|
return false;
|
|
664
690
|
if (!reply || !("result" in reply)) {
|
|
665
691
|
const error = reply ? paramsObject(reply.error).message : "no answer";
|
|
666
|
-
logLine("upstream.reinitialize_failed", {
|
|
692
|
+
(failureSaid ? logDetail : logLine)("upstream.reinitialize_failed", {
|
|
693
|
+
server: this.label,
|
|
694
|
+
error: String(error ?? "error"),
|
|
695
|
+
});
|
|
667
696
|
return false;
|
|
668
697
|
}
|
|
669
698
|
this.noteProtocolVersion(reply.result);
|
|
@@ -856,15 +885,27 @@ export class HttpUpstreamClient extends BaseUpstreamClient {
|
|
|
856
885
|
return false;
|
|
857
886
|
}
|
|
858
887
|
const recovered = this.reconnectFailures > 0;
|
|
888
|
+
// A new event stream is a new, uninitialised session: replay the host's
|
|
889
|
+
// handshake before anything else is let through. Until that has worked
|
|
890
|
+
// the reconnect has not: counted as done on the stream alone, a replay
|
|
891
|
+
// the server refused (it was still starting) left every later request
|
|
892
|
+
// posted to a session nobody had initialised, and nothing ever tried
|
|
893
|
+
// again. So the stream is dropped and the attempt counts as failed —
|
|
894
|
+
// backoff, and the next request pulls a new one forward.
|
|
895
|
+
if (this.initParams !== undefined && !(await this.reinitialize(recovered || afterExpiry, recovered))) {
|
|
896
|
+
if (this.dead)
|
|
897
|
+
return false;
|
|
898
|
+
this.reconnectFailures += 1;
|
|
899
|
+
this.onSseStreamLost(this.generation, "re-initialise failed", true);
|
|
900
|
+
this.streamAbort?.abort();
|
|
901
|
+
this.scheduleReconnect();
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
859
904
|
(recovered ? logLine : logDetail)("upstream.stream_reconnected", {
|
|
860
905
|
server: this.label,
|
|
861
906
|
failedAttempts: this.reconnectFailures,
|
|
862
907
|
});
|
|
863
908
|
this.reconnectFailures = 0;
|
|
864
|
-
// A new event stream is a new, uninitialised session: replay the host's
|
|
865
|
-
// handshake before anything else is let through.
|
|
866
|
-
if (this.initParams !== undefined)
|
|
867
|
-
await this.reinitialize(recovered || afterExpiry);
|
|
868
909
|
return true;
|
|
869
910
|
})();
|
|
870
911
|
this.connecting = attempt;
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* {@link StdioUpstreamClient.shutdown} closes stdin first (the MCP-spec way to
|
|
23
23
|
* ask a server to exit), waits briefly, and only then kills the whole tree.
|
|
24
24
|
*/
|
|
25
|
+
import { type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
25
26
|
import { type JsonRpcMessage } from "../jsonrpc/types.js";
|
|
26
27
|
import { BaseUpstreamClient } from "./client.js";
|
|
27
28
|
export interface StdioUpstreamOptions {
|
|
@@ -80,6 +81,12 @@ export declare function whichSync(command: string, env?: NodeJS.ProcessEnv): str
|
|
|
80
81
|
* On POSIX this is the identity. On Windows a batch file is wrapped in `cmd.exe`.
|
|
81
82
|
*/
|
|
82
83
|
export declare function resolveCommand(command: string, args: string[], env?: NodeJS.ProcessEnv): ResolvedCommand;
|
|
84
|
+
/**
|
|
85
|
+
* Kill `child` and everything it started. On Windows that takes `taskkill /T`
|
|
86
|
+
* (a plain kill terminates one process: `cmd.exe`, not the server behind it);
|
|
87
|
+
* elsewhere SIGTERM, then SIGKILL. Bounded; never throws.
|
|
88
|
+
*/
|
|
89
|
+
export declare function killTree(child: ChildProcessWithoutNullStreams): Promise<void>;
|
|
83
90
|
export declare class StdioUpstreamClient extends BaseUpstreamClient {
|
|
84
91
|
readonly ready: Promise<void>;
|
|
85
92
|
private child?;
|
|
@@ -161,7 +161,7 @@ function waitForExit(child, ms) {
|
|
|
161
161
|
* (a plain kill terminates one process: `cmd.exe`, not the server behind it);
|
|
162
162
|
* elsewhere SIGTERM, then SIGKILL. Bounded; never throws.
|
|
163
163
|
*/
|
|
164
|
-
async function killTree(child) {
|
|
164
|
+
export async function killTree(child) {
|
|
165
165
|
const pid = child.pid;
|
|
166
166
|
if (pid === undefined)
|
|
167
167
|
return;
|
|
@@ -19,11 +19,19 @@
|
|
|
19
19
|
* - a symlinked target (dotfile managers do this to ~/.claude.json) must keep
|
|
20
20
|
* being a symlink, so the rename goes to the link's real target.
|
|
21
21
|
*
|
|
22
|
-
* The existing file's mode is kept; `mode` applies to a new file
|
|
22
|
+
* The existing file's mode is kept; `mode` applies to a new file, and to an
|
|
23
|
+
* existing one too with `replaceMode`.
|
|
23
24
|
*/
|
|
24
25
|
export interface AtomicWriteOptions {
|
|
25
26
|
/** Mode for a file that does not exist yet. An existing file keeps its own. */
|
|
26
27
|
mode?: number;
|
|
28
|
+
/**
|
|
29
|
+
* `mode` for an existing file as well: a file a credential goes in. Kept,
|
|
30
|
+
* the old file's mode went to the new one, and when the old file was not
|
|
31
|
+
* ours to chmod first (root's 0644, after `sudo claude`), the token went
|
|
32
|
+
* into a file that is ours and readable by every account.
|
|
33
|
+
*/
|
|
34
|
+
replaceMode?: boolean;
|
|
27
35
|
}
|
|
28
36
|
export declare function writeFileAtomic(path: string, data: string | Uint8Array, opts?: AtomicWriteOptions): void;
|
|
29
37
|
//# sourceMappingURL=atomic-write.d.ts.map
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* - a symlinked target (dotfile managers do this to ~/.claude.json) must keep
|
|
20
20
|
* being a symlink, so the rename goes to the link's real target.
|
|
21
21
|
*
|
|
22
|
-
* The existing file's mode is kept; `mode` applies to a new file
|
|
22
|
+
* The existing file's mode is kept; `mode` applies to a new file, and to an
|
|
23
|
+
* existing one too with `replaceMode`.
|
|
23
24
|
*/
|
|
24
25
|
import { chmodSync, lstatSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
25
26
|
import { randomBytes } from "node:crypto";
|
|
@@ -42,11 +43,13 @@ function resolveTarget(path) {
|
|
|
42
43
|
export function writeFileAtomic(path, data, opts = {}) {
|
|
43
44
|
const target = resolveTarget(path);
|
|
44
45
|
let mode = opts.mode;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
if (!(opts.replaceMode && mode !== undefined)) {
|
|
47
|
+
try {
|
|
48
|
+
mode = statSync(target).mode & 0o777;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* new file */
|
|
52
|
+
}
|
|
50
53
|
}
|
|
51
54
|
const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
52
55
|
try {
|
|
@@ -35,6 +35,13 @@ export declare const MAX_COOLDOWN_MS = 60000;
|
|
|
35
35
|
* and never answers, holding a socket and a chain slot the whole time.
|
|
36
36
|
*/
|
|
37
37
|
export declare const DEFAULT_SERVICE_TIMEOUT_MS = 10000;
|
|
38
|
+
/**
|
|
39
|
+
* The longest delay a Node timer takes. Given more, `setTimeout` warns and
|
|
40
|
+
* waits 1 ms instead — so `BIR_SERVICE_TIMEOUT_MS=3000000000`, set to switch
|
|
41
|
+
* the timeout off, ended every request at once. Every delay that comes from
|
|
42
|
+
* the environment or from a caller is held to this.
|
|
43
|
+
*/
|
|
44
|
+
export declare const MAX_TIMER_MS = 2147483647;
|
|
38
45
|
export declare function serviceTimeoutMs(): number;
|
|
39
46
|
export interface HealthSnapshot {
|
|
40
47
|
/** False while a cool-down is running: hot paths do not wait. */
|
|
@@ -36,9 +36,16 @@ export const MAX_COOLDOWN_MS = 60_000;
|
|
|
36
36
|
* and never answers, holding a socket and a chain slot the whole time.
|
|
37
37
|
*/
|
|
38
38
|
export const DEFAULT_SERVICE_TIMEOUT_MS = 10_000;
|
|
39
|
+
/**
|
|
40
|
+
* The longest delay a Node timer takes. Given more, `setTimeout` warns and
|
|
41
|
+
* waits 1 ms instead — so `BIR_SERVICE_TIMEOUT_MS=3000000000`, set to switch
|
|
42
|
+
* the timeout off, ended every request at once. Every delay that comes from
|
|
43
|
+
* the environment or from a caller is held to this.
|
|
44
|
+
*/
|
|
45
|
+
export const MAX_TIMER_MS = 2_147_483_647;
|
|
39
46
|
export function serviceTimeoutMs() {
|
|
40
47
|
const n = Number(process.env.BIR_SERVICE_TIMEOUT_MS);
|
|
41
|
-
return Number.isFinite(n) && n > 0 ? n : DEFAULT_SERVICE_TIMEOUT_MS;
|
|
48
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_TIMER_MS) : DEFAULT_SERVICE_TIMEOUT_MS;
|
|
42
49
|
}
|
|
43
50
|
export class ServiceHealth {
|
|
44
51
|
failures = 0;
|
|
@@ -170,7 +177,7 @@ export async function awaitWithin(pending, budgetMs, what, health = serviceHealt
|
|
|
170
177
|
const winner = await Promise.race([
|
|
171
178
|
pending,
|
|
172
179
|
new Promise((resolve) => {
|
|
173
|
-
timer = setTimeout(() => resolve(timedOut), budgetMs);
|
|
180
|
+
timer = setTimeout(() => resolve(timedOut), Math.min(budgetMs, MAX_TIMER_MS));
|
|
174
181
|
timer.unref?.();
|
|
175
182
|
}),
|
|
176
183
|
]);
|
|
@@ -197,7 +204,7 @@ export async function awaitWithin(pending, budgetMs, what, health = serviceHealt
|
|
|
197
204
|
*/
|
|
198
205
|
export function timeoutSignal(ms = serviceTimeoutMs(), parent) {
|
|
199
206
|
const controller = new AbortController();
|
|
200
|
-
const timer = setTimeout(() => controller.abort(new DOMException(`timed out after ${ms} ms`, "TimeoutError")), ms);
|
|
207
|
+
const timer = setTimeout(() => controller.abort(new DOMException(`timed out after ${ms} ms`, "TimeoutError")), Math.min(ms, MAX_TIMER_MS));
|
|
201
208
|
timer.unref?.();
|
|
202
209
|
if (parent) {
|
|
203
210
|
if (parent.aborted) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.27",
|
|
4
4
|
"description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/production.md
CHANGED
|
@@ -523,7 +523,7 @@ Over 2-minute runs with one turn every 8 s (92 hooks):
|
|
|
523
523
|
|
|
524
524
|
- **Backlog.** The backlog is bounded: 500 sends or 32 MiB, oldest steps dropped first. A queued send still facing a cool-down after 60 s is dropped unsent, not sent into the outage.
|
|
525
525
|
- **Flush.** A flush waits at most 2 s (`BIR_FLUSH_TIMEOUT_MS`), and not at all while the service is cooling down.
|
|
526
|
-
- **Retries.** A retry happens once, only after 429, 503, or a connection error that proves nothing reached the service. A 409 on the retry counts as "already landed". There is no retry after 502, 504 or a mid-request reset, until the service confirms it deduplicates ids (§10). What a 429 costs is in §3.3. The service's new build answers a report whose write failed with `503 report_not_booked`, so this one retry sends the report once more (built, not deployed yet).
|
|
526
|
+
- **Retries.** A retry happens once, only after 429, 503, or a connection error that proves nothing reached the service. A 409 on the retry counts as "already landed". There is no retry after 502, 504 or a mid-request reset, until the service confirms it deduplicates ids (§10). What a 429 costs is in §3.3. The service's new build answers a report whose write failed with `503 report_not_booked`, so this one retry sends the report once more (built, not deployed yet). Three sends are different, because the service takes a second copy of each without harm: a run's finish and a patch (an update that keeps what it has), and an execution report that carries its ticket (a second copy answers `200 duplicate`). From 0.3.26 each of these goes back to the head of its lane when it fails with no answer or an unhealthy one (502, 504, a reset), up to 3 sends in all, and it is exempt from the 60 s give-up. Before, a run whose finish was lost never ended (never embedded, matched or calculated), and a turn whose report was lost was never booked. Any other 4xx stays final.
|
|
527
527
|
|
|
528
528
|
**Size caps on uploads.**
|
|
529
529
|
|
|
@@ -1174,7 +1174,7 @@ Defaults were checked in `src/`.
|
|
|
1174
1174
|
- **Two sessions in one project.** Direct replays for a session whose own proxy has not yet bound hand over instead of running.
|
|
1175
1175
|
- **Revive gaps.** `bir-scenario` does not revive in its first 12 s; those first hooks are left to the SessionStart hook. A session with no wrapped server and no `bir` server is not revived until `/clear` or a new session. The revive window was not measured with real Claude Code.
|
|
1176
1176
|
- **Direct-run worst case.** The worst case by budget is 120 s: 8 s of derivation, 100 s of plan, and 12 s of slack. It is reached only if every logic body hits its limit, and sits at Claude Code's 2-minute MCP auto-background. Setting `BIR_REPLAY_BUDGET_MS=120000` (the old default) makes it 140 s.
|
|
1177
|
-
- **Retries are conservative.** A
|
|
1177
|
+
- **Retries are conservative.** A step or run create lost to a 502, 504 or mid-request reset is not retried until the service deduplicates ids. (A finish, a patch and a ticketed report are sent up to 3 times from 0.3.26: the service takes a second copy of each without harm, §6.5. The service now answers a repeated run or step id with 409.)
|
|
1178
1178
|
- **Healthy-service prompt cost.** With an instant local service the prompt hook is about 6 ms slower than 0.2.12 (about 17 vs 11 ms median); the cause was not isolated. Under 503 or slow answers the recorder also keeps more idle keep-alive sockets. Both are small, but visible in measurements.
|
|
1179
1179
|
- **A host other than Claude Code is not checked end to end.** What §3.2 describes ran on a bench only: a real LangChain agent against the real recorder, with a stand-in service and a fake model (`examples/langchain-proof/`). A real model, the real service and the production server have not been tried. The service's part is built and not deployed, and the Python package is not on PyPI. What that means for a LangChain application today is in `langChainGuide.md`, part 5.
|
|
1180
1180
|
- **An interrupted or lossy Claude Code run is not marked.** Only a run of another host is finished with an `error` or with the `incomplete` mark today. A Claude Code run that was interrupted or lost steps is finished as before.
|
package/scripts/install.ps1
CHANGED
|
@@ -65,6 +65,11 @@ function Invoke-BirInstall {
|
|
|
65
65
|
# child's environment only, set right before the spawn and removed after.
|
|
66
66
|
$setupToken = $env:BIR_SETUP_TOKEN
|
|
67
67
|
if ($setupToken) { Remove-Item -Path Env:BIR_SETUP_TOKEN -ErrorAction SilentlyContinue }
|
|
68
|
+
# The uninstall switch the same way: read once and taken out of the session,
|
|
69
|
+
# so pasting the ordinary install line later in this window installs, and
|
|
70
|
+
# does not uninstall again.
|
|
71
|
+
$uninstall = ($env:BIR_UNINSTALL -eq '1')
|
|
72
|
+
if ($null -ne $env:BIR_UNINSTALL) { Remove-Item -Path Env:BIR_UNINSTALL -ErrorAction SilentlyContinue }
|
|
68
73
|
|
|
69
74
|
# Inside the function on purpose: under `irm | iex` these would otherwise
|
|
70
75
|
# change the session the person keeps working in.
|
|
@@ -262,7 +267,14 @@ function Invoke-BirInstall {
|
|
|
262
267
|
Write-Note "node v$Version already at $exe"
|
|
263
268
|
return $exe
|
|
264
269
|
}
|
|
265
|
-
|
|
270
|
+
}
|
|
271
|
+
# Half an extraction, a foreign file, or a folder whose node.exe is gone
|
|
272
|
+
# (an antivirus quarantine takes just that): removed, never extracted
|
|
273
|
+
# over. Whenever the folder is there, not only when node.exe is - a
|
|
274
|
+
# Move-Item onto a folder that exists moves the new one INSIDE it, so
|
|
275
|
+
# node.exe never appeared where it is looked for and every retry
|
|
276
|
+
# failed, 80 MB heavier. install.sh removes it the same way.
|
|
277
|
+
if (Test-Path -LiteralPath $nodeDir) {
|
|
266
278
|
if ($dryRun) { Write-Would "remove the unusable $nodeDir" }
|
|
267
279
|
else {
|
|
268
280
|
try { Remove-Item -LiteralPath $nodeDir -Recurse -Force }
|
|
@@ -459,7 +471,7 @@ public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wP
|
|
|
459
471
|
# before its files go. Every wrapped MCP server and every hook names a
|
|
460
472
|
# file under ~\.baseinstrunner; deleting that first would break them
|
|
461
473
|
# all, in every project, with no `bir` left to repair it.
|
|
462
|
-
if ($
|
|
474
|
+
if ($uninstall) {
|
|
463
475
|
Write-Step 'Removing the runner from every project on this machine'
|
|
464
476
|
$birCmd = Join-Path $birHome 'bin\bir.cmd'
|
|
465
477
|
if (-not (Test-Path -LiteralPath $birCmd -PathType Leaf)) {
|