@north-light/crouter 0.3.302 → 0.3.303
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/dist/core/__tests__/bash-guard.test.d.ts +1 -0
- package/dist/core/__tests__/bash-guard.test.js +190 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
- package/dist/core/bash-guard.d.ts +6 -0
- package/dist/core/bash-guard.js +393 -0
- package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
- package/dist/core/runtime/broker/fault-retry.js +66 -6
- package/dist/core/shell-segments.d.ts +42 -0
- package/dist/core/shell-segments.js +169 -0
- package/dist/core/substrate/surface-match.d.ts +0 -9
- package/dist/core/substrate/surface-match.js +4 -160
- package/dist/pi-extensions/canvas-bash-valve.d.ts +0 -3
- package/dist/pi-extensions/canvas-bash-valve.js +2 -34
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// bash-guard.ts — the refusals the bash valve applies to a command's TEXT
|
|
2
|
+
// before any of it runs. Pure: command text and the node's cwd in, a refusal
|
|
3
|
+
// message or null out.
|
|
4
|
+
//
|
|
5
|
+
// Two refusals live here, for the same reason: both are things no correct
|
|
6
|
+
// agent command ever does, and both are cheaper to stop than to recover from.
|
|
7
|
+
//
|
|
8
|
+
// - A long leading `sleep` burns a live window on a clock the runtime already
|
|
9
|
+
// owns a cheaper primitive for.
|
|
10
|
+
// - A destructive command aimed at the home directory, a system root, or a
|
|
11
|
+
// directory above the node's own working directory is unrecoverable.
|
|
12
|
+
//
|
|
13
|
+
// The second exists because of a real incident. A node ran:
|
|
14
|
+
//
|
|
15
|
+
// cd star-theory-2 && HOME="$(mktemp -d …)" godot … ; rm -rf "$HOME" …
|
|
16
|
+
//
|
|
17
|
+
// A `VAR=value cmd` prefix applies only to that ONE command, so the trailing
|
|
18
|
+
// `rm -rf "$HOME"` used the real home directory and deleted for 108 seconds
|
|
19
|
+
// before another node killed it. A memory doc telling agents not to do this
|
|
20
|
+
// already exists; it is advisory and did not survive contact. This is the
|
|
21
|
+
// block.
|
|
22
|
+
//
|
|
23
|
+
// What this is NOT: a sandbox. Command text is read best-effort — expansions,
|
|
24
|
+
// subshells, `eval`, and a script the command invokes are all out of reach. It
|
|
25
|
+
// stops the plausible mistake a competent agent makes, not an agent trying to
|
|
26
|
+
// get around it. Anything it cannot resolve, it allows: a guard that guesses
|
|
27
|
+
// would refuse ordinary work, and an agent that learns the guard is noisy
|
|
28
|
+
// starts routing around it.
|
|
29
|
+
import { homedir, userInfo } from 'node:os';
|
|
30
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
31
|
+
import { commandSegments, stripHeredocs } from './shell-segments.js';
|
|
32
|
+
// Long-sleep guard. A command that opens with `sleep <long>` is a node burning
|
|
33
|
+
// a live window to wait on a clock — the runtime already has waiting primitives
|
|
34
|
+
// that cost nothing (`crtr cron` for a scheduled wake, `crtr node wait deadline`
|
|
35
|
+
// for an inbox-vs-deadline race). Anything over 10s is refused before it runs.
|
|
36
|
+
const MAX_INLINE_SLEEP_SECONDS = 10;
|
|
37
|
+
const SLEEP_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
38
|
+
/** Seconds a leading `sleep ...` will block for, or null when the command does
|
|
39
|
+
* not open with a sleep (or its duration isn't statically knowable). */
|
|
40
|
+
export function leadingSleepSeconds(command) {
|
|
41
|
+
const match = /^\s*sleep(\s+[^;&|\n]+)/.exec(command);
|
|
42
|
+
if (!match)
|
|
43
|
+
return null;
|
|
44
|
+
// `sleep 1 2m 3` sums its operands.
|
|
45
|
+
let total = 0;
|
|
46
|
+
for (const operand of match[1].trim().split(/\s+/)) {
|
|
47
|
+
const parsed = /^([0-9]*\.?[0-9]+)([smhd]?)$/.exec(operand);
|
|
48
|
+
if (!parsed)
|
|
49
|
+
return null; // variable/expansion/flag — not statically knowable
|
|
50
|
+
total += Number(parsed[1]) * SLEEP_UNIT_SECONDS[parsed[2] || 's'];
|
|
51
|
+
}
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
function longSleepRefusal(command) {
|
|
55
|
+
const seconds = leadingSleepSeconds(command);
|
|
56
|
+
if (seconds === null || seconds <= MAX_INLINE_SLEEP_SECONDS)
|
|
57
|
+
return null;
|
|
58
|
+
return (`Refused: this command starts by sleeping ${seconds}s, over the ${MAX_INLINE_SLEEP_SECONDS}s inline limit.\n` +
|
|
59
|
+
`Holding a live window on a clock is never how you wait here. Instead:\n` +
|
|
60
|
+
` - schedule the wake and end your turn: crtr cron -h (one-shot at a time, or recurring)\n` +
|
|
61
|
+
` - race an inbox message against a deadline: crtr node wait deadline -h\n` +
|
|
62
|
+
` - waiting on a child, a reply from the user, or any spine event: just stop — the runtime wakes you.\n` +
|
|
63
|
+
`If you truly need a short pause inline, sleep ${MAX_INLINE_SLEEP_SECONDS}s or less.`);
|
|
64
|
+
}
|
|
65
|
+
// Protected paths
|
|
66
|
+
/** System directories whose removal, relocation, or recursive permission
|
|
67
|
+
* change breaks the machine rather than the task. Matched EXACTLY after
|
|
68
|
+
* resolution — `/tmp` is protected, `/tmp/build-1234` is ordinary work. */
|
|
69
|
+
const PROTECTED_SYSTEM_PATHS = [
|
|
70
|
+
'/',
|
|
71
|
+
'/Applications',
|
|
72
|
+
'/bin',
|
|
73
|
+
'/dev',
|
|
74
|
+
'/etc',
|
|
75
|
+
'/home',
|
|
76
|
+
'/Library',
|
|
77
|
+
'/opt',
|
|
78
|
+
'/private',
|
|
79
|
+
'/private/etc',
|
|
80
|
+
'/private/tmp',
|
|
81
|
+
'/private/var',
|
|
82
|
+
'/sbin',
|
|
83
|
+
'/System',
|
|
84
|
+
'/tmp',
|
|
85
|
+
'/usr',
|
|
86
|
+
'/Users',
|
|
87
|
+
'/var',
|
|
88
|
+
'/Volumes',
|
|
89
|
+
];
|
|
90
|
+
/** Why a resolved target is protected, in the words the refusal uses. `null`
|
|
91
|
+
* when the target is ordinary and the command may run. */
|
|
92
|
+
function protectedReason(target, home, cwd) {
|
|
93
|
+
if (PROTECTED_SYSTEM_PATHS.includes(target))
|
|
94
|
+
return 'a system directory';
|
|
95
|
+
if (target === home)
|
|
96
|
+
return 'your home directory';
|
|
97
|
+
if (target === `${home}/.crouter`)
|
|
98
|
+
return 'the canvas home — every node\u2019s conversation and state';
|
|
99
|
+
// Strict ancestors only: a node may delete its OWN working directory (a
|
|
100
|
+
// merged worktree is a real case), just nothing that contains it.
|
|
101
|
+
if (cwd === target || !cwd.startsWith(target.endsWith(sep) ? target : target + sep))
|
|
102
|
+
return null;
|
|
103
|
+
return `a directory containing your working directory (${cwd})`;
|
|
104
|
+
}
|
|
105
|
+
function initialVariables(home, cwd) {
|
|
106
|
+
const user = userInfo().username;
|
|
107
|
+
return new Map([['HOME', home], ['PWD', cwd], ['USER', user], ['LOGNAME', user]]);
|
|
108
|
+
}
|
|
109
|
+
/** The text with `~` and every known `$VAR` substituted, or null when what is
|
|
110
|
+
* left still depends on something the guard cannot see. */
|
|
111
|
+
function expandText(text, vars) {
|
|
112
|
+
let t = text;
|
|
113
|
+
if (t === '~' || t.startsWith('~/')) {
|
|
114
|
+
const home = vars.get('HOME');
|
|
115
|
+
if (home === undefined)
|
|
116
|
+
return null;
|
|
117
|
+
t = home + t.slice(1);
|
|
118
|
+
}
|
|
119
|
+
else if (t.startsWith('~')) {
|
|
120
|
+
return null; // ~otheruser — not ours to resolve
|
|
121
|
+
}
|
|
122
|
+
t = t.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced, bare) => vars.get(braced ?? bare) ?? whole);
|
|
123
|
+
// Anything still carrying an expansion or a glob is not statically knowable.
|
|
124
|
+
return /[$`*?[]/.test(t) ? null : t;
|
|
125
|
+
}
|
|
126
|
+
/** The absolute path an operand names, or null when it is not statically
|
|
127
|
+
* knowable. A trailing `/*` or `/.` is stripped first: `rm -rf ~/*` empties
|
|
128
|
+
* the same tree as `rm -rf ~`. A relative operand resolves against `base` —
|
|
129
|
+
* null when an earlier `cd` in the same line went somewhere the guard cannot
|
|
130
|
+
* follow. */
|
|
131
|
+
function resolveTarget(text, vars, base) {
|
|
132
|
+
const expanded = expandText(text.replace(/\/+\*?\.?$/, '') || '/', vars);
|
|
133
|
+
if (expanded === null)
|
|
134
|
+
return null;
|
|
135
|
+
if (isAbsolute(expanded))
|
|
136
|
+
return resolve(expanded);
|
|
137
|
+
return base === null ? null : resolve(base, expanded);
|
|
138
|
+
}
|
|
139
|
+
/** Fold `VAR=value` assignments into a variable map. A value the guard cannot
|
|
140
|
+
* expand deletes the variable rather than leaving a stale one behind: reading
|
|
141
|
+
* a later `$HOME` as the real home when the line reassigned it is exactly the
|
|
142
|
+
* false refusal that would teach agents to route around this guard. */
|
|
143
|
+
function applyAssignments(vars, assignments) {
|
|
144
|
+
for (const assignment of assignments) {
|
|
145
|
+
const eq = assignment.indexOf('=');
|
|
146
|
+
if (eq <= 0)
|
|
147
|
+
continue;
|
|
148
|
+
const name = assignment.slice(0, eq);
|
|
149
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
150
|
+
continue;
|
|
151
|
+
const value = expandText(assignment.slice(eq + 1), vars);
|
|
152
|
+
if (value === null)
|
|
153
|
+
vars.delete(name);
|
|
154
|
+
else
|
|
155
|
+
vars.set(name, value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// The destructive operations
|
|
159
|
+
/** A redirection, whole (`2>/dev/null`) or as its bare operator (`>`), which
|
|
160
|
+
* takes the next word as its file. Neither is an operand of the command. */
|
|
161
|
+
const REDIRECTION = /^[0-9]*(&?>>?|<<?|>&|<&)/;
|
|
162
|
+
/** Operands of a segment, minus flags, the `--` end-of-flags marker, and
|
|
163
|
+
* redirections. */
|
|
164
|
+
function operands(tokens) {
|
|
165
|
+
const out = [];
|
|
166
|
+
let flagsEnded = false;
|
|
167
|
+
let skipNext = false;
|
|
168
|
+
for (const token of tokens.slice(1)) {
|
|
169
|
+
if (skipNext) {
|
|
170
|
+
skipNext = false;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const redirection = REDIRECTION.exec(token.text);
|
|
174
|
+
if (redirection !== null && !token.opaque) {
|
|
175
|
+
skipNext = token.text === redirection[0]; // bare operator — its file is the next word
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!flagsEnded && token.text === '--') {
|
|
179
|
+
flagsEnded = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!flagsEnded && token.text.startsWith('-') && token.text !== '-')
|
|
183
|
+
continue;
|
|
184
|
+
out.push(token.text);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/** Short and long flags of a segment, short combinations split into letters. */
|
|
189
|
+
function flagLetters(tokens) {
|
|
190
|
+
const letters = new Set();
|
|
191
|
+
const long = new Set();
|
|
192
|
+
for (const token of tokens.slice(1)) {
|
|
193
|
+
if (token.text === '--')
|
|
194
|
+
break;
|
|
195
|
+
if (token.text.startsWith('--'))
|
|
196
|
+
long.add(token.text.slice(2));
|
|
197
|
+
else if (token.text.startsWith('-') && token.text.length > 1)
|
|
198
|
+
for (const ch of token.text.slice(1))
|
|
199
|
+
letters.add(ch);
|
|
200
|
+
}
|
|
201
|
+
return { letters, long };
|
|
202
|
+
}
|
|
203
|
+
function isRecursive(tokens) {
|
|
204
|
+
const { letters, long } = flagLetters(tokens);
|
|
205
|
+
return letters.has('r') || letters.has('R') || long.has('recursive');
|
|
206
|
+
}
|
|
207
|
+
/** A block device holding a filesystem. `/dev/null`, `/dev/stdout`, and
|
|
208
|
+
* `/dev/zero` are ordinary output; a disk node is not. */
|
|
209
|
+
const RAW_DISK_DEVICE = /^\/dev\/r?(disk|sd[a-z]|nvme|hd[a-z]|vd[a-z]|mmcblk|md)/;
|
|
210
|
+
/** `find`'s start paths: the words between its leading global options and the
|
|
211
|
+
* first predicate of the expression. Those options are why the paths cannot
|
|
212
|
+
* simply be "everything before the first `-` word" — `find -H ~ -delete` and
|
|
213
|
+
* `find -- ~ -delete` both walk the home tree. */
|
|
214
|
+
function findPaths(tokens) {
|
|
215
|
+
const paths = [];
|
|
216
|
+
let i = 1;
|
|
217
|
+
while (i < tokens.length) {
|
|
218
|
+
const text = tokens[i].text;
|
|
219
|
+
if (text === '--' || /^-[HLPEdsx]$/.test(text))
|
|
220
|
+
i += 1;
|
|
221
|
+
else if (text === '-D' || text === '-O')
|
|
222
|
+
i += 2; // GNU debug/optimisation, each with a value
|
|
223
|
+
else if (text === '-f') { // BSD's explicit start path
|
|
224
|
+
if (i + 1 < tokens.length)
|
|
225
|
+
paths.push(tokens[i + 1].text);
|
|
226
|
+
i += 2;
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
for (; i < tokens.length; i += 1) {
|
|
232
|
+
if (tokens[i].text.startsWith('-'))
|
|
233
|
+
break;
|
|
234
|
+
paths.push(tokens[i].text);
|
|
235
|
+
}
|
|
236
|
+
return paths;
|
|
237
|
+
}
|
|
238
|
+
/** Command names that stand in front of the real command and must be stepped
|
|
239
|
+
* over before it can be read. Their own flags are skipped with them. */
|
|
240
|
+
const COMMAND_PREFIXES = new Set(['sudo', 'doas', 'command', 'env', 'nice', 'nohup', 'time', 'stdbuf', 'xargs', 'exec', 'builtin']);
|
|
241
|
+
/** Strip every leading prefix command and its flags, leaving the real command
|
|
242
|
+
* as `tokens[0]`. `sudo -E nice -n 10 rm -rf ~` reads as `rm -rf ~`. */
|
|
243
|
+
function stripCommandPrefixes(tokens) {
|
|
244
|
+
let rest = tokens;
|
|
245
|
+
while (rest.length > 0 && !rest[0].opaque && COMMAND_PREFIXES.has(rest[0].text)) {
|
|
246
|
+
let i = 1;
|
|
247
|
+
while (i < rest.length && rest[i].text.startsWith('-')) {
|
|
248
|
+
// `nice -n 10` and `env -u VAR` take a value; a lone flag does not.
|
|
249
|
+
const takesValue = /^-[nu]$/.test(rest[i].text);
|
|
250
|
+
i += takesValue ? 2 : 1;
|
|
251
|
+
}
|
|
252
|
+
// `env FOO=bar cmd` — the assignments belong to env, not to the command.
|
|
253
|
+
while (i < rest.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[i].text))
|
|
254
|
+
i += 1;
|
|
255
|
+
if (i >= rest.length)
|
|
256
|
+
return [];
|
|
257
|
+
rest = rest.slice(i);
|
|
258
|
+
}
|
|
259
|
+
return rest;
|
|
260
|
+
}
|
|
261
|
+
/** Targets this segment would destroy: the operands whose loss the guard
|
|
262
|
+
* cares about, or `[]` when the segment is not a destructive operation. A
|
|
263
|
+
* verb appears here only when ONE invocation of it can take out a whole tree.
|
|
264
|
+
* A device or subcommand named directly (`mkfs.ext4`, `diskutil eraseDisk`)
|
|
265
|
+
* has no path operand to resolve, so it reports itself. */
|
|
266
|
+
function destructiveTargets(tokens) {
|
|
267
|
+
const none = { targets: [], named: null };
|
|
268
|
+
const head = tokens[0];
|
|
269
|
+
if (head === undefined || head.opaque)
|
|
270
|
+
return none;
|
|
271
|
+
const name = head.text.slice(head.text.lastIndexOf('/') + 1);
|
|
272
|
+
const args = operands(tokens);
|
|
273
|
+
switch (name) {
|
|
274
|
+
// `rm` without a recursive flag cannot remove a directory at all.
|
|
275
|
+
case 'rm':
|
|
276
|
+
return isRecursive(tokens) ? { targets: args, named: null } : none;
|
|
277
|
+
// Every operand but the destination is a source that stops existing.
|
|
278
|
+
case 'mv':
|
|
279
|
+
return args.length >= 2 ? { targets: args.slice(0, -1), named: null } : none;
|
|
280
|
+
// A recursive mode or ownership sweep over a system tree is as final as a
|
|
281
|
+
// deletion. The first operand is the mode/owner, not a path.
|
|
282
|
+
case 'chmod':
|
|
283
|
+
case 'chown':
|
|
284
|
+
case 'chgrp':
|
|
285
|
+
return isRecursive(tokens) && args.length >= 2 ? { targets: args.slice(1), named: null } : none;
|
|
286
|
+
// `find <path> … -delete` and `-exec` walk the tree and act on every hit.
|
|
287
|
+
case 'find': {
|
|
288
|
+
const acts = tokens.some((t) => !t.opaque && (t.text === '-delete' || t.text === '-exec' || t.text === '-execdir'));
|
|
289
|
+
return acts ? { targets: findPaths(tokens), named: null } : none;
|
|
290
|
+
}
|
|
291
|
+
// `dd of=…` overwrites its output in place; a raw disk is unrecoverable,
|
|
292
|
+
// while `of=/dev/null` and friends are ordinary output redirection.
|
|
293
|
+
case 'dd': {
|
|
294
|
+
const of = tokens.find((t) => t.text.startsWith('of='));
|
|
295
|
+
if (of === undefined)
|
|
296
|
+
return none;
|
|
297
|
+
const path = of.text.slice(3);
|
|
298
|
+
if (RAW_DISK_DEVICE.test(path))
|
|
299
|
+
return { targets: [], named: path };
|
|
300
|
+
return path.startsWith('/dev/') ? none : { targets: [path], named: null };
|
|
301
|
+
}
|
|
302
|
+
default:
|
|
303
|
+
// Filesystem creation and disk erasure destroy a volume outright; there
|
|
304
|
+
// is no safe target for them inside an agent's task.
|
|
305
|
+
if (/^(mkfs|newfs)([.-].+)?$/.test(name))
|
|
306
|
+
return { targets: [], named: name };
|
|
307
|
+
if (name === 'diskutil') {
|
|
308
|
+
const verb = args[0];
|
|
309
|
+
if (verb !== undefined && /^(erase|reformat|partition|zeroDisk|randomDisk|secureErase)/i.test(verb)) {
|
|
310
|
+
return { targets: [], named: `diskutil ${verb}` };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return none;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Walk the line segment by segment, carrying the variable values and working
|
|
317
|
+
* directory the shell itself would carry, and refuse the first destructive
|
|
318
|
+
* segment aimed at a protected path.
|
|
319
|
+
*
|
|
320
|
+
* Carrying that state IS the guard's core distinction. `export HOME=/tmp/x`
|
|
321
|
+
* as its own command changes `$HOME` for everything after it, so a later
|
|
322
|
+
* `rm -rf "$HOME"` is a scratch directory and runs. A `HOME=/tmp/x cmd`
|
|
323
|
+
* PREFIX applies to that one command only, so a later `rm -rf "$HOME"` is the
|
|
324
|
+
* real home — which is precisely the mistake that destroyed one. */
|
|
325
|
+
function destructiveCommandRefusal(command, cwd, home) {
|
|
326
|
+
const vars = initialVariables(home, cwd);
|
|
327
|
+
// Every directory a relative operand might be resolved from. A `cd` ADDS its
|
|
328
|
+
// destination rather than replacing the previous one, because the guard
|
|
329
|
+
// cannot know whether the shell's `cd` succeeded: after `cd missing-dir`,
|
|
330
|
+
// bash stays put and `rm -rf ..` really is the ancestor. Refusing when ANY
|
|
331
|
+
// candidate is protected keeps that case caught; the refusal names the
|
|
332
|
+
// resolved path, and an absolute operand is never ambiguous.
|
|
333
|
+
let bases = [cwd];
|
|
334
|
+
for (const segment of commandSegments(stripHeredocs(command))) {
|
|
335
|
+
const tokens = stripCommandPrefixes(segment.tokens);
|
|
336
|
+
const head = tokens[0];
|
|
337
|
+
if (head !== undefined) {
|
|
338
|
+
// This segment's own prefix assignments apply to this command only.
|
|
339
|
+
const local = new Map(vars);
|
|
340
|
+
applyAssignments(local, segment.assignments);
|
|
341
|
+
const innermost = bases[bases.length - 1];
|
|
342
|
+
if (innermost !== undefined)
|
|
343
|
+
local.set('PWD', innermost);
|
|
344
|
+
const { targets, named } = destructiveTargets(tokens);
|
|
345
|
+
if (named !== null)
|
|
346
|
+
return refusalMessage(segment.text, named, 'a whole disk or volume — never a task boundary');
|
|
347
|
+
for (const target of targets) {
|
|
348
|
+
for (const base of bases.length === 0 ? [null] : bases) {
|
|
349
|
+
const resolved = resolveTarget(target, local, base);
|
|
350
|
+
if (resolved === null)
|
|
351
|
+
continue;
|
|
352
|
+
const reason = protectedReason(resolved, home, cwd);
|
|
353
|
+
if (reason !== null)
|
|
354
|
+
return refusalMessage(segment.text, resolved, reason);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Then carry this segment's lasting effects into the rest of the line. A
|
|
359
|
+
// backgrounded segment runs in a subshell, so it has none.
|
|
360
|
+
if (segment.background)
|
|
361
|
+
continue;
|
|
362
|
+
if (head === undefined)
|
|
363
|
+
applyAssignments(vars, segment.assignments);
|
|
364
|
+
else if (!head.opaque && SHELL_VARIABLE_BUILTINS.has(head.text)) {
|
|
365
|
+
// A prefix assignment to a special builtin persists in the shell.
|
|
366
|
+
applyAssignments(vars, segment.assignments);
|
|
367
|
+
applyAssignments(vars, operands(tokens));
|
|
368
|
+
}
|
|
369
|
+
else if (!head.opaque && head.text === 'cd') {
|
|
370
|
+
const destination = operands(tokens)[0];
|
|
371
|
+
const next = destination === undefined ? vars.get('HOME') ?? null : resolveTarget(destination, vars, bases[bases.length - 1] ?? null);
|
|
372
|
+
// An unfollowable `cd` leaves no knowable base at all; a relative operand
|
|
373
|
+
// after it is unresolvable, and unresolvable always means allowed.
|
|
374
|
+
bases = next === null ? [] : [...bases, next];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/** Builtins that set shell variables for everything after them. */
|
|
380
|
+
const SHELL_VARIABLE_BUILTINS = new Set(['export', 'declare', 'typeset', 'readonly']);
|
|
381
|
+
function refusalMessage(segment, target, reason) {
|
|
382
|
+
return (`Refused: \`${segment}\` targets ${target}, which is ${reason}.\n` +
|
|
383
|
+
`Nothing an agent is asked to do requires destroying that path, and it cannot be undone.\n` +
|
|
384
|
+
`If you meant somewhere else, name that path explicitly and run the command again. Two traps to check first:\n` +
|
|
385
|
+
` - a \`VAR=value cmd\` prefix applies ONLY to that one command, so a later \`$VAR\` in the same line is the SHELL's value — this is exactly how a node deleted a home directory.\n` +
|
|
386
|
+
` - \`~\` and \`$HOME\` are the real home directory, never a scratch dir; build scratch paths with mktemp -d and hold them in a variable of your own.\n` +
|
|
387
|
+
`If you believe this path genuinely must be destroyed, ask the user — do not work around this refusal.`);
|
|
388
|
+
}
|
|
389
|
+
/** The refusal this command earns before it runs, or null when it may run.
|
|
390
|
+
* `cwd` is the directory the command will execute in. */
|
|
391
|
+
export function bashCommandRefusal(command, cwd, home = homedir()) {
|
|
392
|
+
return longSleepRefusal(command) ?? destructiveCommandRefusal(command, cwd, home);
|
|
393
|
+
}
|
|
@@ -25,6 +25,7 @@ export declare class FaultRetry {
|
|
|
25
25
|
private readonly notFoundModels;
|
|
26
26
|
private providerFallbackInFlight;
|
|
27
27
|
private notFoundFallbackInFlight;
|
|
28
|
+
private stagedCapacityFailure;
|
|
28
29
|
constructor(deps: FaultRetryDeps);
|
|
29
30
|
clearTimer(): void;
|
|
30
31
|
reset(): void;
|
|
@@ -54,6 +55,9 @@ export declare class FaultRetry {
|
|
|
54
55
|
schedule(): void;
|
|
55
56
|
private maybeSwitchProviderForRetry;
|
|
56
57
|
private maybeFallbackOnUnusableModel;
|
|
58
|
+
private nextEligibleFallbackRoute;
|
|
59
|
+
private targetCannotFitContext;
|
|
60
|
+
private stageCapacityFailure;
|
|
57
61
|
private recordPendingProviderFault;
|
|
58
62
|
private sessionFile;
|
|
59
63
|
private pendingEpisodeMatches;
|
|
@@ -22,6 +22,11 @@ export class FaultRetry {
|
|
|
22
22
|
notFoundModels = new Set();
|
|
23
23
|
providerFallbackInFlight = null;
|
|
24
24
|
notFoundFallbackInFlight = null;
|
|
25
|
+
// A fallback can learn its target cannot hold this session before the
|
|
26
|
+
// current turn settles. Keep that outcome here rather than on the engine's
|
|
27
|
+
// agent_end staging field: message_end precedes agent_end, which resets that
|
|
28
|
+
// field for the ordinary provider result.
|
|
29
|
+
stagedCapacityFailure = null;
|
|
25
30
|
constructor(deps) {
|
|
26
31
|
this.deps = deps;
|
|
27
32
|
}
|
|
@@ -35,6 +40,7 @@ export class FaultRetry {
|
|
|
35
40
|
this.clearTimer();
|
|
36
41
|
this.providerFallbackInFlight = null;
|
|
37
42
|
this.notFoundFallbackInFlight = null;
|
|
43
|
+
this.stagedCapacityFailure = null;
|
|
38
44
|
}
|
|
39
45
|
onEvent(event) {
|
|
40
46
|
this.maybeSwitchProviderForRetry(event);
|
|
@@ -54,6 +60,8 @@ export class FaultRetry {
|
|
|
54
60
|
const watchdogAbort = generation.stagedWatchdogAbort;
|
|
55
61
|
const refreshAbort = generation.stagedRefreshAbort;
|
|
56
62
|
const overflowFailure = generation.stagedOverflowFailure;
|
|
63
|
+
const capacityFailure = this.stagedCapacityFailure?.generation === generation ? this.stagedCapacityFailure : null;
|
|
64
|
+
this.stagedCapacityFailure = null;
|
|
57
65
|
generation.stagedProviderAgentEnd = null;
|
|
58
66
|
generation.stagedWatchdogAbort = false;
|
|
59
67
|
generation.stagedRefreshAbort = false;
|
|
@@ -76,12 +84,12 @@ export class FaultRetry {
|
|
|
76
84
|
: { since: episodeFault.since, anchorEntryId: episodeFault.anchorEntryId, attempt: episodeFault.retry.attempt };
|
|
77
85
|
const messages = Array.isArray(agentEnd?.messages) ? agentEnd.messages : [];
|
|
78
86
|
const last = [...messages].reverse().find((message) => typeof message === 'object' && message !== null && message.role === 'assistant');
|
|
79
|
-
if (overflowFailure !== null) {
|
|
87
|
+
if (capacityFailure !== null || overflowFailure !== null) {
|
|
80
88
|
clearFault(this.deps.nodeId, { link: 'pi→provider' });
|
|
81
89
|
clearProviderRetryEpisode(this.deps.nodeId);
|
|
82
90
|
recordFault(this.deps.nodeId, {
|
|
83
91
|
link: 'pi→provider', op: 'context overflow recovery', kind: 'context-overflow', retry: { disposition: 'fatal' },
|
|
84
|
-
message: overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
|
|
92
|
+
message: capacityFailure?.errorMessage ?? overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
|
|
85
93
|
});
|
|
86
94
|
return;
|
|
87
95
|
}
|
|
@@ -250,9 +258,23 @@ export class FaultRetry {
|
|
|
250
258
|
const routes = expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId());
|
|
251
259
|
const current = parseModelSpec(currentSpec);
|
|
252
260
|
const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
|
|
253
|
-
const
|
|
254
|
-
|
|
261
|
+
const fallback = liveRank === undefined
|
|
262
|
+
? { route: undefined, capacityExhausted: false }
|
|
263
|
+
: this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.failedRetryRoutes.has(candidate.routeId));
|
|
264
|
+
if (fallback.route === undefined) {
|
|
265
|
+
if (fallback.capacityExhausted) {
|
|
266
|
+
this.stageCapacityFailure(generation, candidateSession);
|
|
267
|
+
// Pi creates its retry AbortController immediately after it emits this
|
|
268
|
+
// event. Defer the abort so it cancels that controller, then lets the
|
|
269
|
+
// normal agent_settled path publish the staged fatal outcome.
|
|
270
|
+
queueMicrotask(() => {
|
|
271
|
+
if (this.deps.installedGeneration() === generation && this.deps.currentSession() === candidateSession)
|
|
272
|
+
candidateSession.abortRetry();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
255
275
|
return;
|
|
276
|
+
}
|
|
277
|
+
const nextRoute = fallback.route;
|
|
256
278
|
const fallbackPromise = (async () => {
|
|
257
279
|
const target = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
|
|
258
280
|
if (!target)
|
|
@@ -290,9 +312,15 @@ export class FaultRetry {
|
|
|
290
312
|
const routeRequest = modelRequestFromConfig(this.deps.cfg.model, envModelIntent(), false);
|
|
291
313
|
const routes = routeRequest ? expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId()) : [];
|
|
292
314
|
const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
|
|
293
|
-
const
|
|
294
|
-
|
|
315
|
+
const fallback = liveRank === undefined
|
|
316
|
+
? { route: undefined, capacityExhausted: false }
|
|
317
|
+
: this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`));
|
|
318
|
+
if (fallback.route === undefined) {
|
|
319
|
+
if (fallback.capacityExhausted)
|
|
320
|
+
this.stageCapacityFailure(generation, candidateSession);
|
|
295
321
|
return;
|
|
322
|
+
}
|
|
323
|
+
const nextRoute = fallback.route;
|
|
296
324
|
const target = `${nextRoute.providerId}/${nextRoute.modelId}${nextRoute.thinkingLevel ? `:${nextRoute.thinkingLevel}` : ''}`;
|
|
297
325
|
const fallbackPromise = (async () => {
|
|
298
326
|
const model = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
|
|
@@ -323,6 +351,38 @@ export class FaultRetry {
|
|
|
323
351
|
});
|
|
324
352
|
this.notFoundFallbackInFlight = fallbackPromise;
|
|
325
353
|
}
|
|
354
|
+
nextEligibleFallbackRoute(routes, registry, contextTokens, include) {
|
|
355
|
+
let available = 0;
|
|
356
|
+
let tooSmall = 0;
|
|
357
|
+
for (const candidate of routes) {
|
|
358
|
+
if (!include(candidate))
|
|
359
|
+
continue;
|
|
360
|
+
if (resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registry), automaticFallback: true }]).candidates.length === 0)
|
|
361
|
+
continue;
|
|
362
|
+
const target = registry.find(candidate.providerId, candidate.modelId);
|
|
363
|
+
if (target === undefined)
|
|
364
|
+
continue;
|
|
365
|
+
available++;
|
|
366
|
+
if (this.targetCannotFitContext(target.contextWindow, contextTokens)) {
|
|
367
|
+
tooSmall++;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
return { route: candidate, capacityExhausted: false };
|
|
371
|
+
}
|
|
372
|
+
return { route: undefined, capacityExhausted: available > 0 && available === tooSmall };
|
|
373
|
+
}
|
|
374
|
+
targetCannotFitContext(contextWindow, contextTokens) {
|
|
375
|
+
return Number.isFinite(contextWindow) && Number.isFinite(contextTokens) && contextWindow < contextTokens;
|
|
376
|
+
}
|
|
377
|
+
stageCapacityFailure(generation, session) {
|
|
378
|
+
const tokens = session.getContextUsage()?.tokens;
|
|
379
|
+
this.stagedCapacityFailure = {
|
|
380
|
+
generation,
|
|
381
|
+
errorMessage: Number.isFinite(tokens)
|
|
382
|
+
? `All available fallback models have context windows smaller than the current conversation (${tokens} tokens).`
|
|
383
|
+
: 'All available fallback models have context windows smaller than the current conversation.',
|
|
384
|
+
};
|
|
385
|
+
}
|
|
326
386
|
recordPendingProviderFault(session, input) {
|
|
327
387
|
const sessionFile = this.sessionFile(session);
|
|
328
388
|
// A pathless session cannot be crash-recovered, but its live broker still
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** One shell word. `opaque` marks a token whose text came from inside quotes:
|
|
2
|
+
* it is data, so it must never be treated as an executable or a matched
|
|
3
|
+
* command word. */
|
|
4
|
+
export type CommandToken = {
|
|
5
|
+
text: string;
|
|
6
|
+
opaque: boolean;
|
|
7
|
+
};
|
|
8
|
+
/** One shell segment: the raw text as the author wrote it, its leading
|
|
9
|
+
* `VAR=value` assignments, and the remaining words — so `tokens[0]` is the
|
|
10
|
+
* executable. An assignment-only segment (`export`-less `FOO=bar`) has
|
|
11
|
+
* assignments and no tokens; the distinction matters, because a leading
|
|
12
|
+
* assignment applies ONLY to the command in its own segment.
|
|
13
|
+
*
|
|
14
|
+
* `background` marks a segment ended by a single `&`. It runs in a subshell,
|
|
15
|
+
* so nothing it assigns or `cd`s reaches the commands after it. */
|
|
16
|
+
export type CommandSegment = {
|
|
17
|
+
text: string;
|
|
18
|
+
assignments: string[];
|
|
19
|
+
tokens: CommandToken[];
|
|
20
|
+
background: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** One raw segment and whether a single `&` — not `&&` — ended it. */
|
|
23
|
+
export type RawSegment = {
|
|
24
|
+
text: string;
|
|
25
|
+
background: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
|
|
28
|
+
* `|`, and a single `&`. Subshell parentheses and backticks are left inside
|
|
29
|
+
* their segment. Heredoc bodies are a separate pre-read concern (see
|
|
30
|
+
* `stripHeredocs`). */
|
|
31
|
+
export declare function splitCommandSegments(command: string): RawSegment[];
|
|
32
|
+
/** Tokenize one segment into shell words, marking quoted spans opaque. */
|
|
33
|
+
export declare function tokenizeCommandSegment(segment: string): CommandToken[];
|
|
34
|
+
/** Every non-empty command segment, paired with its raw text. */
|
|
35
|
+
export declare function commandSegments(command: string): CommandSegment[];
|
|
36
|
+
/** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
|
|
37
|
+
* `DELIM` line) from a command before it is read. The opening line is kept —
|
|
38
|
+
* `crtr push final <<'EOF'` is still a real `push final` invocation — but the
|
|
39
|
+
* body lines, which are data fed on stdin and never executed as commands, are
|
|
40
|
+
* dropped. Without this, a doc-writing command whose body merely MENTIONS a
|
|
41
|
+
* matched invocation would have its whole call held or refused. */
|
|
42
|
+
export declare function stripHeredocs(command: string): string;
|