@basein/runner 0.2.12 → 0.3.1

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 (142) hide show
  1. package/README.md +540 -369
  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 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  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 +2787 -302
  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/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -0,0 +1,72 @@
1
+ /**
2
+ * safety — values a plan must not template, unattended, into a command.
3
+ *
4
+ * A plan's parameters are read from the turn, and the turn includes the last
5
+ * few tool results (segmented.md R-PARAM-2): a web page the agent fetched, an
6
+ * email, a file it read. Those values then flow through LLM-written
7
+ * `toolInputLogic` — typically plain concatenation, `"… WHERE region = '" +
8
+ * parameters.region + "'"` — into calls that nobody approves one by one: a
9
+ * steered step is answered `allow`, and a direct step runs through the proxy
10
+ * with no prompt at all. A page that says `EMEA'; DROP TABLE orders; --` or
11
+ * `x; curl … | sh` in the right place would be executed on the user's database
12
+ * or shell by the replay, not by anything the user asked for.
13
+ *
14
+ * The runner cannot know how a body quotes. It can refuse to guess: when a plan
15
+ * has a step whose input is a command — a shell, an editor that writes files,
16
+ * an MCP tool named for SQL or execution — every value derived for *this* turn
17
+ * must be plain text, free of the characters that end a quoted string or start
18
+ * a new command. A value equal to its recorded sample is the user's own and
19
+ * passes. Anything else stops the plan, and the agent does the task itself, in
20
+ * the open, where the user's own permission rules apply. A false positive
21
+ * costs one replay; a false negative runs someone else's command.
22
+ *
23
+ * Quoting is not the only way in. A body that templates a value *unquoted* —
24
+ * `"… WHERE user_id = " + parameters.userId`, `"find . -name " +
25
+ * parameters.pattern`, `"ssh " + parameters.host` — is broken open by plain
26
+ * text: `1 OR 1=1`, `x -exec rm -rf {} +`, `-oProxyCommand=…`. So a value for a
27
+ * command also keeps the *shape* of its recorded sample: a number stays a
28
+ * number and a boolean a boolean; nothing starts with `-` (an option, not a
29
+ * value); and whitespace, a glob, a brace or a parenthesis the sample did not
30
+ * have is not let in. Where the sample itself had spaces — a name like `EMEA
31
+ * West`, which the recorded body must have quoted to work at all — the value
32
+ * may too, but not together with boolean SQL (`OR`/`AND`/`UNION`), a `+`, or a
33
+ * word that starts with `-`.
34
+ *
35
+ * A plan whose only sink is a file edit is held to less: no line break, no
36
+ * NUL. A file is not a shell, and a quote or a backslash — every Windows path —
37
+ * is text there.
38
+ */
39
+ import type { FlatEntry } from "./flatten.js";
40
+ import type { ParamsSchema } from "./types.js";
41
+ /**
42
+ * How a step consumes its input, strictest first: `command` is run by a shell
43
+ * or a database, `write` is only written into a file.
44
+ */
45
+ export type SinkKind = "command" | "write";
46
+ /** Whether this tool's input is run by a shell. */
47
+ export declare function isShellTool(toolName: string | null | undefined): boolean;
48
+ /** How this tool consumes its input, or undefined when it is neither run nor written. */
49
+ export declare function sinkOf(toolName: string | null | undefined): SinkKind | undefined;
50
+ /** Whether this tool's input is run or written: a shell, a file edit, a query. */
51
+ export declare function isCommandSink(toolName: string | null | undefined): boolean;
52
+ /** The strictest sink among the planned steps, or undefined when there is none. */
53
+ export declare function sinkOfPlan(entries: readonly FlatEntry[]): SinkKind | undefined;
54
+ /** Whether any planned step runs its input through a shell. */
55
+ export declare function hasShellStep(entries: readonly FlatEntry[]): boolean;
56
+ /** Whether any planned step's input is run or written. */
57
+ export declare function hasCommandSink(entries: readonly FlatEntry[]): boolean;
58
+ /**
59
+ * The first value derived for this turn that is not safe to template into a
60
+ * command, or undefined when every one is.
61
+ *
62
+ * `derived` is the derivation's own answer, *before* `paramsLogic`: the
63
+ * scenario's logic may well build a quoted SQL string on purpose, and that is
64
+ * the recorded chain's doing, not the turn's.
65
+ */
66
+ export declare function unsafeParameter(derived: Readonly<Record<string, unknown>>, schema: ParamsSchema | null | undefined,
67
+ /** The plan's strictest sink ({@link sinkOfPlan}); `command` when omitted. */
68
+ sink?: SinkKind): {
69
+ key: string;
70
+ value: string;
71
+ } | undefined;
72
+ //# sourceMappingURL=safety.d.ts.map
@@ -0,0 +1,303 @@
1
+ /**
2
+ * safety — values a plan must not template, unattended, into a command.
3
+ *
4
+ * A plan's parameters are read from the turn, and the turn includes the last
5
+ * few tool results (segmented.md R-PARAM-2): a web page the agent fetched, an
6
+ * email, a file it read. Those values then flow through LLM-written
7
+ * `toolInputLogic` — typically plain concatenation, `"… WHERE region = '" +
8
+ * parameters.region + "'"` — into calls that nobody approves one by one: a
9
+ * steered step is answered `allow`, and a direct step runs through the proxy
10
+ * with no prompt at all. A page that says `EMEA'; DROP TABLE orders; --` or
11
+ * `x; curl … | sh` in the right place would be executed on the user's database
12
+ * or shell by the replay, not by anything the user asked for.
13
+ *
14
+ * The runner cannot know how a body quotes. It can refuse to guess: when a plan
15
+ * has a step whose input is a command — a shell, an editor that writes files,
16
+ * an MCP tool named for SQL or execution — every value derived for *this* turn
17
+ * must be plain text, free of the characters that end a quoted string or start
18
+ * a new command. A value equal to its recorded sample is the user's own and
19
+ * passes. Anything else stops the plan, and the agent does the task itself, in
20
+ * the open, where the user's own permission rules apply. A false positive
21
+ * costs one replay; a false negative runs someone else's command.
22
+ *
23
+ * Quoting is not the only way in. A body that templates a value *unquoted* —
24
+ * `"… WHERE user_id = " + parameters.userId`, `"find . -name " +
25
+ * parameters.pattern`, `"ssh " + parameters.host` — is broken open by plain
26
+ * text: `1 OR 1=1`, `x -exec rm -rf {} +`, `-oProxyCommand=…`. So a value for a
27
+ * command also keeps the *shape* of its recorded sample: a number stays a
28
+ * number and a boolean a boolean; nothing starts with `-` (an option, not a
29
+ * value); and whitespace, a glob, a brace or a parenthesis the sample did not
30
+ * have is not let in. Where the sample itself had spaces — a name like `EMEA
31
+ * West`, which the recorded body must have quoted to work at all — the value
32
+ * may too, but not together with boolean SQL (`OR`/`AND`/`UNION`), a `+`, or a
33
+ * word that starts with `-`.
34
+ *
35
+ * A plan whose only sink is a file edit is held to less: no line break, no
36
+ * NUL. A file is not a shell, and a quote or a backslash — every Windows path —
37
+ * is text there.
38
+ */
39
+ import { parseQualifiedName } from "../control/correlation.js";
40
+ /** Built-ins whose input is run by a shell. */
41
+ const SHELL_BUILTINS = new Set(["Bash", "PowerShell"]);
42
+ /** Built-ins whose input is written into a file. */
43
+ const WRITE_BUILTINS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
44
+ /**
45
+ * An MCP tool whose name says its input is run: a shell, a query, a script.
46
+ * Matched against the name split into words ({@link nameWords}), so
47
+ * `run_command`, `run-command` and `runCommand` are the same tool — and against
48
+ * the server's name as well as the tool's, because `mcp__shell__run` says it
49
+ * with the server.
50
+ */
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;
53
+ /**
54
+ * A tool or server name as delimited words: camelCase, PascalCase and acronym
55
+ * runs are split with `_`, so `executeSql` reads as `execute_Sql`, `runSQL` as
56
+ * `run_SQL` and `SQLQuery` as `SQL_Query`. Without this every camelCase server —
57
+ * a common naming convention — slipped past the guard.
58
+ */
59
+ function nameWords(name) {
60
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2");
61
+ }
62
+ /** Whether an MCP tool's own name, or its server's, matches `words`. */
63
+ function mcpNameSays(toolName, words) {
64
+ const mcp = parseQualifiedName(toolName);
65
+ if (!mcp)
66
+ return false;
67
+ return words.test(nameWords(mcp.toolName)) || words.test(nameWords(mcp.serverName));
68
+ }
69
+ /**
70
+ * Characters and sequences that end a quoted string or begin another command
71
+ * in a POSIX shell, PowerShell or SQL. Deliberately wide: an apostrophe in a
72
+ * name is legitimate text and still stops the plan, because the runner cannot
73
+ * see whether the body quotes it.
74
+ */
75
+ const UNSAFE = /[`$;&|<>\\"'\r\n\u0000]|--|\/\*/;
76
+ /**
77
+ * What a value written into a file must not carry: a line break or a NUL,
78
+ * which is how a value meant for one line or one path becomes another.
79
+ *
80
+ * Deliberately narrower than {@link UNSAFE}. A file is not parsed by a shell,
81
+ * so a quote or a backslash is text there — every Windows path has one — and
82
+ * checking for them caught none of the actual risk (hostile file *content*)
83
+ * while handing over every replay that edits `C:\Users\…` or a name with an
84
+ * apostrophe, at the cost of a denied call and a model round trip each time.
85
+ */
86
+ const WRITE_UNSAFE = /[\r\n\u0000]/;
87
+ /** Whether this tool's input is run by a shell. */
88
+ export function isShellTool(toolName) {
89
+ const name = toolName ?? "";
90
+ if (SHELL_BUILTINS.has(name))
91
+ return true;
92
+ return mcpNameSays(name, MCP_SHELL_WORD);
93
+ }
94
+ /** How this tool consumes its input, or undefined when it is neither run nor written. */
95
+ export function sinkOf(toolName) {
96
+ const name = toolName ?? "";
97
+ if (SHELL_BUILTINS.has(name))
98
+ return "command";
99
+ if (WRITE_BUILTINS.has(name))
100
+ return "write";
101
+ return mcpNameSays(name, MCP_COMMAND_WORD) ? "command" : undefined;
102
+ }
103
+ /** Whether this tool's input is run or written: a shell, a file edit, a query. */
104
+ export function isCommandSink(toolName) {
105
+ return sinkOf(toolName) !== undefined;
106
+ }
107
+ /** The strictest sink among the planned steps, or undefined when there is none. */
108
+ export function sinkOfPlan(entries) {
109
+ let found;
110
+ for (const e of entries) {
111
+ const sink = sinkOf(e.step.toolName);
112
+ if (sink === "command")
113
+ return sink;
114
+ found ??= sink;
115
+ }
116
+ return found;
117
+ }
118
+ /** Whether any planned step runs its input through a shell. */
119
+ export function hasShellStep(entries) {
120
+ return entries.some((e) => isShellTool(e.step.toolName));
121
+ }
122
+ /** Whether any planned step's input is run or written. */
123
+ export function hasCommandSink(entries) {
124
+ return entries.some((e) => isCommandSink(e.step.toolName));
125
+ }
126
+ /**
127
+ * The first value derived for this turn that is not safe to template into a
128
+ * command, or undefined when every one is.
129
+ *
130
+ * `derived` is the derivation's own answer, *before* `paramsLogic`: the
131
+ * scenario's logic may well build a quoted SQL string on purpose, and that is
132
+ * the recorded chain's doing, not the turn's.
133
+ */
134
+ export function unsafeParameter(derived, schema,
135
+ /** The plan's strictest sink ({@link sinkOfPlan}); `command` when omitted. */
136
+ sink = "command") {
137
+ const unsafe = sink === "write" ? WRITE_UNSAFE : UNSAFE;
138
+ for (const [key, value] of Object.entries(derived)) {
139
+ if (value === null || value === undefined)
140
+ continue;
141
+ const sample = schema?.[key]?.sampleValue;
142
+ // The recorded sample is the user's own value from their own run.
143
+ if (sameValue(value, sample))
144
+ continue;
145
+ const bad = firstUnsafeString(value, 0, unsafe);
146
+ if (bad !== undefined)
147
+ return { key, value: bad };
148
+ // A file is not parsed, so only a command is held to its sample's shape.
149
+ if (sink === "command") {
150
+ const off = offShape(value, sample);
151
+ if (off !== undefined)
152
+ return { key, value: off };
153
+ }
154
+ }
155
+ return undefined;
156
+ }
157
+ /** A number as text: what a numeric sample's value may be when it arrives as a string. */
158
+ const NUMERIC = /^-?\d+(?:\.\d+)?$/;
159
+ /** Boolean text, for a boolean sample's value that arrives as a string. */
160
+ const BOOLEAN_TEXT = /^(?:true|false)$/i;
161
+ /**
162
+ * Characters that change what an *unquoted* value is to a shell or a
163
+ * database — a word break, a glob, a brace expansion (`{-rf,/}` is two
164
+ * arguments), a sub-expression. Let in only when the sample had them.
165
+ */
166
+ const SHAPE_CHARS = ["\\s", "*", "?", "[", "]", "{", "}", "(", ")"];
167
+ const SHAPE_TESTS = SHAPE_CHARS.map((char) => ({
168
+ char,
169
+ re: char === "\\s" ? /\s/ : new RegExp(`\\${char}`),
170
+ }));
171
+ /** Boolean SQL: what turns `WHERE id = <value>` into every row. */
172
+ const SQL_BOOLEAN_WORD = /\b(?:or|and|union)\b/gi;
173
+ /**
174
+ * Where a value derived for a `command` sink leaves its recorded sample's
175
+ * shape (see the file comment), as the text to report; undefined when it
176
+ * keeps it.
177
+ */
178
+ function offShape(value, sample) {
179
+ // A number stays a number: `1 OR 1=1` is not a user id.
180
+ if (typeof sample === "number") {
181
+ if (typeof value === "number")
182
+ return Number.isFinite(value) ? undefined : String(value);
183
+ if (typeof value !== "string" || !NUMERIC.test(value))
184
+ return display(value);
185
+ return undefined;
186
+ }
187
+ if (typeof sample === "boolean") {
188
+ if (typeof value === "boolean")
189
+ return undefined;
190
+ if (typeof value === "string" && BOOLEAN_TEXT.test(value))
191
+ return undefined;
192
+ return display(value);
193
+ }
194
+ const shape = sampleText(sample);
195
+ return firstOffShapeString(value, 0, shape);
196
+ }
197
+ /** Every string a sample holds — values and keys — for its shape. */
198
+ function sampleText(sample) {
199
+ const out = [];
200
+ const walk = (v, depth) => {
201
+ if (typeof v === "string")
202
+ out.push(v);
203
+ else if (typeof v === "number" || typeof v === "boolean")
204
+ out.push(String(v));
205
+ else if (v && typeof v === "object" && depth <= 8) {
206
+ if (!Array.isArray(v))
207
+ out.push(...Object.keys(v));
208
+ for (const item of Array.isArray(v) ? v : Object.values(v)) {
209
+ walk(item, depth + 1);
210
+ }
211
+ }
212
+ };
213
+ walk(sample, 0);
214
+ // NUL never matches `\s`, and is a word boundary: joined, the parts neither
215
+ // gain whitespace nor merge into a word.
216
+ return out.join("\u0000");
217
+ }
218
+ function firstOffShapeString(value, depth, shape) {
219
+ if (typeof value === "string")
220
+ return offShapeText(value, shape) ? value : undefined;
221
+ if (depth > 8 || value === null || typeof value !== "object")
222
+ return undefined;
223
+ const items = Array.isArray(value) ? value : Object.values(value);
224
+ for (const item of items) {
225
+ const bad = firstOffShapeString(item, depth + 1, shape);
226
+ if (bad !== undefined)
227
+ return bad;
228
+ }
229
+ if (!Array.isArray(value)) {
230
+ for (const k of Object.keys(value)) {
231
+ if (offShapeText(k, shape))
232
+ return k;
233
+ }
234
+ }
235
+ return undefined;
236
+ }
237
+ /** Whether one string breaks the sample's shape (see the file comment). */
238
+ function offShapeText(text, shape) {
239
+ // An option, not a value: `-oProxyCommand=…`, `--upload-pack=…`, `-exec`.
240
+ if (text.trimStart().startsWith("-"))
241
+ return true;
242
+ for (const { re } of SHAPE_TESTS) {
243
+ if (re.test(text) && !re.test(shape))
244
+ return true;
245
+ }
246
+ if (/\s/.test(text)) {
247
+ // Words the sample had spaces for — but not a new option among them,
248
+ if (/(?:^|\s)-/.test(text))
249
+ return true;
250
+ // a `+` (`-exec … {} +`),
251
+ if (text.includes("+") && !shape.includes("+"))
252
+ return true;
253
+ // or boolean SQL the sample did not say itself.
254
+ const had = new Set((shape.match(SQL_BOOLEAN_WORD) ?? []).map((w) => w.toLowerCase()));
255
+ for (const word of text.match(SQL_BOOLEAN_WORD) ?? []) {
256
+ if (!had.has(word.toLowerCase()))
257
+ return true;
258
+ }
259
+ }
260
+ return false;
261
+ }
262
+ /** A value as text for the audit line. */
263
+ function display(value) {
264
+ if (typeof value === "string")
265
+ return value;
266
+ try {
267
+ return JSON.stringify(value) ?? String(value);
268
+ }
269
+ catch {
270
+ return String(value);
271
+ }
272
+ }
273
+ function firstUnsafeString(value, depth, unsafe) {
274
+ if (typeof value === "string")
275
+ return unsafe.test(value) ? value : undefined;
276
+ if (depth > 8 || value === null || typeof value !== "object")
277
+ return undefined;
278
+ const items = Array.isArray(value) ? value : Object.values(value);
279
+ for (const item of items) {
280
+ const bad = firstUnsafeString(item, depth + 1, unsafe);
281
+ if (bad !== undefined)
282
+ return bad;
283
+ }
284
+ // A key is text too, and an object's keys reach a body just as its values do.
285
+ if (!Array.isArray(value)) {
286
+ for (const k of Object.keys(value)) {
287
+ if (unsafe.test(k))
288
+ return k;
289
+ }
290
+ }
291
+ return undefined;
292
+ }
293
+ function sameValue(a, b) {
294
+ if (a === b)
295
+ return true;
296
+ try {
297
+ return JSON.stringify(a) === JSON.stringify(b);
298
+ }
299
+ catch {
300
+ return false;
301
+ }
302
+ }
303
+ //# sourceMappingURL=safety.js.map
@@ -27,6 +27,16 @@
27
27
  * (segmented.md R-CALL-31). So the rows are cached per run id, and the cursors
28
28
  * are kept per frame — a segment called twice in one chain replays from the
29
29
  * start of its range both times, as two separate replays of it would.
30
+ *
31
+ * OFF BY DEFAULT NOW. A recorded output shown to the model as a step's result
32
+ * is last week's answer standing in for a call that did not happen here — and
33
+ * for a write ("INSERT 0 1", "file updated") that is a lie the model repeats to
34
+ * the user. A plan therefore stops at a step that cannot run and hands the rest
35
+ * over (plan.ts). `BIR_REPLAY_RECORDED_OUTPUTS=1` brings the stand-in back for
36
+ * steps that could not be *sent* at all — a built-in, a server with no proxy
37
+ * here — never for one that was sent and failed or did not answer; `bir replay`
38
+ * keeps it on, because its operator reads a per-step trace. Nothing else asks
39
+ * for these rows.
30
40
  */
31
41
  import type { FlatEntry } from "./flatten.js";
32
42
  export interface SourceRunOptions {
@@ -27,8 +27,19 @@
27
27
  * (segmented.md R-CALL-31). So the rows are cached per run id, and the cursors
28
28
  * are kept per frame — a segment called twice in one chain replays from the
29
29
  * start of its range both times, as two separate replays of it would.
30
+ *
31
+ * OFF BY DEFAULT NOW. A recorded output shown to the model as a step's result
32
+ * is last week's answer standing in for a call that did not happen here — and
33
+ * for a write ("INSERT 0 1", "file updated") that is a lie the model repeats to
34
+ * the user. A plan therefore stops at a step that cannot run and hands the rest
35
+ * over (plan.ts). `BIR_REPLAY_RECORDED_OUTPUTS=1` brings the stand-in back for
36
+ * steps that could not be *sent* at all — a built-in, a server with no proxy
37
+ * here — never for one that was sent and failed or did not answer; `bir replay`
38
+ * keeps it on, because its operator reads a per-step trace. Nothing else asks
39
+ * for these rows.
30
40
  */
31
41
  import { logDetail, logLine, errText } from "../util/log.js";
42
+ import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
32
43
  export class SourceRunCache {
33
44
  opts;
34
45
  /** run id → every recorded output of that run, in order. */
@@ -82,47 +93,71 @@ export class SourceRunCache {
82
93
  return pending;
83
94
  }
84
95
  async fetchRun(runId) {
96
+ // Waited on inside a step of a replay the user is watching, so it obeys the
97
+ // same breaker as every other service wait (util/service-health.ts): while
98
+ // the service is cooling down this is not attempted at all, and the step
99
+ // simply has no recorded output — for this plan, like any failed fetch.
100
+ if (!serviceHealth.canWait())
101
+ throw new Error("the service is not answering right now");
102
+ // The token is read here, at use, never kept: the recorder refreshes it as
103
+ // a session outlives it, and a copy taken at arming goes stale.
104
+ const token = this.opts.token();
105
+ if (!token)
106
+ throw new Error("not signed in");
85
107
  const doFetch = this.opts.fetchImpl ?? fetch;
86
- const controller = new AbortController();
87
- const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs ?? 10_000);
88
- timer.unref?.();
108
+ const startedAt = Date.now();
109
+ let res;
89
110
  try {
90
- const res = await doFetch(`${this.opts.baseUrl.replace(/\/+$/, "")}/recordings/runs/${runId}`, {
91
- headers: { authorization: `Bearer ${this.opts.token()}` },
92
- signal: controller.signal,
111
+ res = await doFetch(`${this.opts.baseUrl.replace(/\/+$/, "")}/recordings/runs/${runId}`, {
112
+ headers: { authorization: `Bearer ${token}` },
113
+ signal: timeoutSignal(this.opts.timeoutMs ?? serviceTimeoutMs()),
93
114
  });
94
- if (!res.ok)
95
- throw new Error(`HTTP ${res.status}`);
96
- const body = (await res.json());
97
- const outputs = [];
98
- // A `tool_response` belongs to the `tool_selected` before it, and it is
99
- // *that* row's position the range is cut on: a response row may sit just
100
- // outside a range whose call sits inside it.
101
- const lastCall = new Map();
102
- for (const row of body.steps ?? []) {
103
- if (!row.tool_name)
104
- continue;
105
- if (row.type === "tool_selected") {
106
- lastCall.set(row.tool_name, row.step_index ?? -1);
107
- continue;
108
- }
109
- if (row.type !== "tool_response")
110
- continue;
111
- outputs.push({
112
- toolName: row.tool_name,
113
- output: row.tool_output ?? "{}",
114
- position: lastCall.get(row.tool_name) ?? row.step_index ?? -1,
115
- });
115
+ }
116
+ catch (err) {
117
+ serviceHealth.fail(`source run: ${errText(err)}`);
118
+ throw err;
119
+ }
120
+ if (!res.ok) {
121
+ if (isUnhealthyStatus(res.status))
122
+ serviceHealth.fail(`source run: HTTP ${res.status}`);
123
+ else
124
+ serviceOk(startedAt);
125
+ throw new Error(`HTTP ${res.status}`);
126
+ }
127
+ let body;
128
+ try {
129
+ body = (await res.json());
130
+ }
131
+ catch (err) {
132
+ serviceHealth.fail("source run: the answer was not JSON");
133
+ throw err;
134
+ }
135
+ serviceOk(startedAt);
136
+ const outputs = [];
137
+ // A `tool_response` belongs to the `tool_selected` before it, and it is
138
+ // *that* row's position the range is cut on: a response row may sit just
139
+ // outside a range whose call sits inside it.
140
+ const lastCall = new Map();
141
+ for (const row of body.steps ?? []) {
142
+ if (!row.tool_name)
143
+ continue;
144
+ if (row.type === "tool_selected") {
145
+ lastCall.set(row.tool_name, row.step_index ?? -1);
146
+ continue;
116
147
  }
117
- this.runs.set(runId, outputs);
118
- logDetail("replay.source_run_loaded", {
119
- run: runId,
120
- outputs: outputs.length,
148
+ if (row.type !== "tool_response")
149
+ continue;
150
+ outputs.push({
151
+ toolName: row.tool_name,
152
+ output: row.tool_output ?? "{}",
153
+ position: lastCall.get(row.tool_name) ?? row.step_index ?? -1,
121
154
  });
122
155
  }
123
- finally {
124
- clearTimeout(timer);
125
- }
156
+ this.runs.set(runId, outputs);
157
+ logDetail("replay.source_run_loaded", {
158
+ run: runId,
159
+ outputs: outputs.length,
160
+ });
126
161
  }
127
162
  }
128
163
  /**
@@ -130,4 +165,14 @@ export class SourceRunCache {
130
165
  * recording. Kept so nothing outside has to change its import.
131
166
  */
132
167
  export { SourceRunCache as SourceRunOutputs };
168
+ /**
169
+ * Report a service answer to the breaker with the time its request started:
170
+ * an answer to a request sent before a hot path gave up on the service does
171
+ * not end the cool-down that give-up began (util/service-health.ts). A
172
+ * breaker that takes no start time reads it as a plain `ok()`.
173
+ */
174
+ function serviceOk(startedAt) {
175
+ const ok = serviceHealth.ok.bind(serviceHealth);
176
+ ok(startedAt);
177
+ }
133
178
  //# sourceMappingURL=source-run.js.map
@@ -38,6 +38,17 @@
38
38
  * Only frame 0 is judged here. A called segment runs on the parameters its
39
39
  * call's `paramMapLogic` builds, not on these, so that body is scanned as a
40
40
  * consumer and the segment's own steps are not (R-CALL-29).
41
+ *
42
+ * WHEN IN DOUBT, IT BLOCKS. This reads source text, not a parse tree, so it can
43
+ * only prove "safe" for the shapes it recognises: `parameters.key`,
44
+ * `parameters?.key` and `parameters["key"]`. Any other use of `parameters` —
45
+ * destructured (`const { region } = parameters`), passed whole
46
+ * (`Object.entries(parameters)`), aliased (`const p = parameters`), read with a
47
+ * computed key — could reach any target, so every missing target blocks. A
48
+ * false "blocks" costs one replay; a false "safe" runs a query on `null` and
49
+ * tells the user there are no orders. `paramsLogic` is judged the same way and
50
+ * more strictly still, because it runs before any step and can rename a target
51
+ * the steps then read under another name.
41
52
  */
42
53
  import type { FlatEntry } from "./flatten.js";
43
54
  import type { ParamsSchema } from "./types.js";
@@ -47,6 +58,9 @@ import type { ParamsSchema } from "./types.js";
47
58
  * Empty means every target the turn left unnamed is one the chain finds for
48
59
  * itself. `missing` is returned unchanged whenever the schema or the plan is
49
60
  * missing, so a caller that knows less than this one is never made to guess.
61
+ *
62
+ * `paramsLogic` is the scenario's own, when it has one: it runs on these
63
+ * parameters before step 0, and what it returns is what every step reads.
50
64
  */
51
- export declare function blockingTargets(missing: readonly string[], schema: ParamsSchema | null | undefined, planned: readonly FlatEntry[]): string[];
65
+ export declare function blockingTargets(missing: readonly string[], schema: ParamsSchema | null | undefined, planned: readonly FlatEntry[], paramsLogic?: string | null): string[];
52
66
  //# sourceMappingURL=targets.d.ts.map
@@ -38,6 +38,17 @@
38
38
  * Only frame 0 is judged here. A called segment runs on the parameters its
39
39
  * call's `paramMapLogic` builds, not on these, so that body is scanned as a
40
40
  * consumer and the segment's own steps are not (R-CALL-29).
41
+ *
42
+ * WHEN IN DOUBT, IT BLOCKS. This reads source text, not a parse tree, so it can
43
+ * only prove "safe" for the shapes it recognises: `parameters.key`,
44
+ * `parameters?.key` and `parameters["key"]`. Any other use of `parameters` —
45
+ * destructured (`const { region } = parameters`), passed whole
46
+ * (`Object.entries(parameters)`), aliased (`const p = parameters`), read with a
47
+ * computed key — could reach any target, so every missing target blocks. A
48
+ * false "blocks" costs one replay; a false "safe" runs a query on `null` and
49
+ * tells the user there are no orders. `paramsLogic` is judged the same way and
50
+ * more strictly still, because it runs before any step and can rename a target
51
+ * the steps then read under another name.
41
52
  */
42
53
  /**
43
54
  * The subset of `missing` this plan must decline on.
@@ -45,8 +56,11 @@
45
56
  * Empty means every target the turn left unnamed is one the chain finds for
46
57
  * itself. `missing` is returned unchanged whenever the schema or the plan is
47
58
  * missing, so a caller that knows less than this one is never made to guess.
59
+ *
60
+ * `paramsLogic` is the scenario's own, when it has one: it runs on these
61
+ * parameters before step 0, and what it returns is what every step reads.
48
62
  */
49
- export function blockingTargets(missing, schema, planned) {
63
+ export function blockingTargets(missing, schema, planned, paramsLogic) {
50
64
  if (missing.length === 0)
51
65
  return [];
52
66
  if (!schema || planned.length === 0)
@@ -56,7 +70,48 @@ export function blockingTargets(missing, schema, planned) {
56
70
  // anything, but it is also not a shape worth reasoning about — keep it strict.
57
71
  if (bodies.length === 0)
58
72
  return [...missing];
59
- return missing.filter((key) => bodies.some((body) => readsOutright(body, key) || carriesLiteral(body, schema[key]?.sampleValue)));
73
+ // A body that uses `parameters` in a shape this file cannot follow may read
74
+ // any of them.
75
+ if (bodies.some(readsWhole))
76
+ return [...missing];
77
+ // `paramsLogic` passes a missing target on under whatever name it likes
78
+ // (`return { r: parameters.region }`), and the steps then read `parameters.r`
79
+ // — which no scan of the steps connects to `region`. So there, any mention
80
+ // of a missing target is a read, `??` or not: a fallback in paramsLogic is a
81
+ // default someone typed, which is exactly a guessed target.
82
+ const logic = paramsLogic?.trim() ? paramsLogic : undefined;
83
+ if (logic && readsWhole(withoutSpread(logic)))
84
+ return [...missing];
85
+ return missing.filter((key) => (logic !== undefined &&
86
+ (mentions(logic, key) || carriesLiteral(logic, schema[key]?.sampleValue))) ||
87
+ bodies.some((body) => readsOutright(body, key) || carriesLiteral(body, schema[key]?.sampleValue)));
88
+ }
89
+ /**
90
+ * `parameters` used in any shape other than `parameters.key`,
91
+ * `parameters?.key`, `parameters["key"]` or `parameters?.["key"]` — spread,
92
+ * destructured, passed or aliased.
93
+ *
94
+ * A member access *of something else* (`respParams.parameters`,
95
+ * `x?.parameters`) is not the argument and does not count; a spread
96
+ * (`...parameters`) is and does. A mention inside a string or a comment counts
97
+ * too — this reads text — and that errs in the safe direction.
98
+ */
99
+ function readsWhole(body) {
100
+ return BARE_PARAMETERS.test(body);
101
+ }
102
+ const BARE_PARAMETERS = /(?<![\w$])(?<!(?<!\.)\.)parameters\b(?!\s*(?:\?\.\s*[A-Za-z_$]|\?\.\s*\[\s*["'`]|\.\s*[A-Za-z_$]|\[\s*["'`]))/;
103
+ /**
104
+ * `paramsLogic` with its spreads of `parameters` removed. `{ ...parameters,
105
+ * extra: … }` hands every key on unchanged, which the step scan then judges;
106
+ * what the rest of the body does with `parameters` is still read.
107
+ */
108
+ function withoutSpread(body) {
109
+ return body.replace(/\.\.\.\s*parameters\b(?!\s*(?:\?\.|\.|\[))/g, "{}");
110
+ }
111
+ /** Whether `body` refers to `parameters.<key>` in any recognised form, fallback or not. */
112
+ function mentions(body, key) {
113
+ const pattern = referencePattern(key);
114
+ return pattern.test(body);
60
115
  }
61
116
  /**
62
117
  * Every logic body that runs with the caller's own `parameters`.
@@ -87,10 +142,13 @@ function callerBodies(planned) {
87
142
  }
88
143
  return bodies;
89
144
  }
90
- /** `parameters.key`, `parameters["key"]`, `parameters['key']` — all three forms. */
145
+ /**
146
+ * `parameters.key`, `parameters["key"]`, `parameters['key']`, and each of them
147
+ * through optional chaining (`parameters?.key`, `parameters?.["key"]`).
148
+ */
91
149
  function referencePattern(key) {
92
150
  const k = escapeRegExp(key);
93
- return new RegExp(`parameters\\s*(?:\\.\\s*${k}\\b|\\[\\s*["']${k}["']\\s*\\])`, "g");
151
+ return new RegExp(`parameters\\s*(?:\\?\\.\\s*${k}\\b|\\.\\s*${k}\\b|(?:\\?\\.)?\\s*\\[\\s*["'\`]${k}["'\`]\\s*\\])`, "g");
94
152
  }
95
153
  /**
96
154
  * Whether `body` reads `key` other than as a fallback.