@north-light/crouter 0.3.301 → 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.
Files changed (45) hide show
  1. package/dist/api/dto/reports.d.ts +8 -8
  2. package/dist/clients/attach/viewer.js +394 -394
  3. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
  4. package/dist/commands/memory/read.js +12 -14
  5. package/dist/commands/node/lifecycle.js +7 -6
  6. package/dist/commands/push.js +4 -4
  7. package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
  8. package/dist/core/__tests__/bash-guard.test.js +190 -0
  9. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  10. package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
  11. package/dist/core/__tests__/integration/revive.test.js +3 -3
  12. package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
  13. package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
  14. package/dist/core/__tests__/stop-guard.test.js +64 -0
  15. package/dist/core/bash-guard.d.ts +6 -0
  16. package/dist/core/bash-guard.js +393 -0
  17. package/dist/core/canvas/crons.d.ts +20 -4
  18. package/dist/core/canvas/crons.js +54 -7
  19. package/dist/core/memory-resolver.d.ts +3 -1
  20. package/dist/core/memory-resolver.js +3 -3
  21. package/dist/core/nested-stores.d.ts +1 -1
  22. package/dist/core/nested-stores.js +3 -3
  23. package/dist/core/runtime/bearings-render.js +1 -1
  24. package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
  25. package/dist/core/runtime/broker/fault-retry.js +66 -6
  26. package/dist/core/runtime/close.js +8 -6
  27. package/dist/core/runtime/stop-guard.js +6 -6
  28. package/dist/core/shell-segments.d.ts +42 -0
  29. package/dist/core/shell-segments.js +169 -0
  30. package/dist/core/substrate/surface-match.d.ts +0 -9
  31. package/dist/core/substrate/surface-match.js +4 -160
  32. package/dist/core/worktree.js +3 -6
  33. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
  34. package/dist/daemon/api/handlers/reports.js +1 -1
  35. package/dist/daemon/api/map.d.ts +1 -1
  36. package/dist/daemon/api/map.js +2 -2
  37. package/dist/daemon/cron/sinks.d.ts +1 -1
  38. package/dist/daemon/cron/sinks.js +2 -2
  39. package/dist/daemon/reconcilers/live-obligation.js +2 -2
  40. package/dist/pi-extensions/canvas-bash-valve.d.ts +0 -3
  41. package/dist/pi-extensions/canvas-bash-valve.js +2 -34
  42. package/package.json +1 -1
  43. package/runtime.lock.json +5 -5
  44. package/dist/daemon/cron-sink.d.ts +0 -26
  45. package/dist/daemon/cron-sink.js +0 -43
@@ -14,7 +14,7 @@
14
14
  // Run: node --conditions=crtr-src --import tsx/esm --test --test-concurrency=4 --test-timeout=10000 src/commands/memory/__tests__/command-selector-and-mutation-guards.test.ts
15
15
  import { test, beforeEach, after } from 'node:test';
16
16
  import assert from 'node:assert/strict';
17
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
  import { resetScopeCache } from '../../../core/scope.js';
@@ -23,6 +23,7 @@ import { createProfile, profileMemoryDir } from '../../../core/profiles/manifest
23
23
  import { openProjectMemoryStore } from '../../../core/memory-resolver.js';
24
24
  import { appendHistoryRecord, buildHistoryRecord, readHistoryRecords } from '../../../core/memory/history.js';
25
25
  import { CrtrError } from '../../../core/errors.js';
26
+ import { gitSync } from '../../../core/git.js';
26
27
  import { deleteLeaf } from '../delete.js';
27
28
  import { editLeaf } from '../edit.js';
28
29
  import { historyLeaf } from '../history.js';
@@ -319,6 +320,34 @@ test('a targeted read suppresses malformed descendant-store warnings', async ()
319
320
  }
320
321
  assert.equal(writes.some((line) => line.includes('memory store not mounted')), false, writes.join(''));
321
322
  });
323
+ test('a truncated nested-store read reports that its corpus was incomplete', async () => {
324
+ assert.equal(gitSync(['init', '-q'], root).status, 0, 'fixture repository initializes');
325
+ const fakeBin = join(base, 'slow-git');
326
+ mkdirSync(fakeBin);
327
+ const originalPath = process.env['PATH'] ?? '';
328
+ const fakeGit = join(fakeBin, 'git');
329
+ writeFileSync(fakeGit, `#!/bin/sh\nfor arg in "$@"; do\n if [ "$arg" = "ls-files" ]; then sleep 1; exit 0; fi\ndone\nPATH=${JSON.stringify(originalPath)}\nexport PATH\nexec git "$@"\n`, 'utf8');
330
+ chmodSync(fakeGit, 0o755);
331
+ process.chdir(root);
332
+ resetScopeCache();
333
+ const stderr = process.stderr;
334
+ const originalWrite = stderr.write;
335
+ const writes = [];
336
+ stderr.write = ((chunk) => {
337
+ writes.push(String(chunk));
338
+ return true;
339
+ });
340
+ process.env['PATH'] = `${fakeBin}:${originalPath}`;
341
+ try {
342
+ const error = await thrownByAsync(() => readLeaf.run({ name: 'acme/child/foo', kind: undefined, frontmatter: false, scope: undefined, dir: undefined }));
343
+ assert.equal(error.code, 'not_found');
344
+ }
345
+ finally {
346
+ process.env['PATH'] = originalPath;
347
+ stderr.write = originalWrite;
348
+ }
349
+ assert.match(writes.join(''), /nested-store discovery.*result may omit nested stores.*--dir <owner>/s, `a timed-out scan must tell a failed read how to bypass discovery; got:\n${writes.join('')}`);
350
+ });
322
351
  test('deleted history points at the revision holding the retained text, never a read that cannot answer', async () => {
323
352
  await deleteLeaf.run({ name: 'acme/disposable', dir: root });
324
353
  const result = (await historyLeaf.run({ name: 'acme/disposable', dir: root }));
@@ -1,10 +1,9 @@
1
1
  import { envNodeId } from '../../shared/env.js';
2
2
  import { CrtrClient } from '../../api/index.js';
3
3
  import { defineLeaf } from '../../core/command.js';
4
- import { CrtrError } from '../../core/errors.js';
5
4
  import { memoryExtensionEffectiveCatalog, projectEffectiveMemoryExtensions } from '../../core/memory/extensions.js';
6
5
  import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
7
- import { ambientMemoryTarget, createMemoryDocSnapshot, listAllMemoryDocs, loadMemoryStoreView, readMemoryDocContent, resolveMemoryCandidates, resolveMemoryDocs, } from '../../core/memory-resolver.js';
6
+ import { ambientMemoryTarget, createMemoryDocSnapshot, listAllMemoryDocs, loadMemoryStoreView, loadMemoryTargetView, readMemoryDocContent, resolveMemoryDocs, } from '../../core/memory-resolver.js';
8
7
  import { expandShellBlocks, hasShellBlocks, makeNodeShellRunner } from '../../core/runtime/shell-expansion.js';
9
8
  import { emptyContextExposureState, exposureTarget, loadContextExposureState, registerDocumentExposure, registerExposure, saveContextExposureState, } from '../../core/substrate/injected-store.js';
10
9
  import { dirDedupKey, docsByName, isDirName, renderDirListing } from '../../core/substrate/listings.js';
@@ -121,18 +120,17 @@ export const readLeaf = defineLeaf({
121
120
  ...(selector.scope === undefined ? {} : { scope: selector.scope }),
122
121
  includeDescendants: true,
123
122
  };
124
- // A named read must not turn malformed stores elsewhere in the corpus
125
- // into warnings about the requested document. `memory lint` owns that
126
- // corpus health report; resolution still includes every candidate.
127
- listingByName = linkByName = docsByName(listAllMemoryDocs(selector.scope, true, true));
128
- try {
129
- set = resolveMemoryCandidates(nameRaw, resolveOpts);
130
- }
131
- catch (e) {
132
- if (!(e instanceof CrtrError && e.code === 'not_found'))
133
- throw e;
134
- }
135
- fail = () => resolveMemoryCandidates(nameRaw, resolveOpts);
123
+ // A named read keeps unrelated malformed documents quiet, but a timed
124
+ // discovery leaves its corpus incomplete and must remain visible before
125
+ // this branch can report not_found.
126
+ const view = loadMemoryTargetView(ambientMemoryTarget(), {
127
+ ...resolveOpts,
128
+ quiet: true,
129
+ reportDiscovery: true,
130
+ });
131
+ listingByName = linkByName = docsByName(view.docs);
132
+ set = view.candidates(nameRaw, kindOpts);
133
+ fail = () => view.resolve(nameRaw, kindOpts);
136
134
  }
137
135
  const nodeId = envNodeId() || undefined;
138
136
  if (set === null) {
@@ -71,11 +71,11 @@ const nodeRecycle = defineLeaf({
71
71
  // node lifecycle close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
72
72
  const nodeClose = defineLeaf({
73
73
  name: 'close',
74
- description: 'close a node + cascade-cancel its exclusive subtree (revivable)',
75
- whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. Windows die but nothing is deleted — each closed node keeps its pi session and can be revived later (`node lifecycle revive`). Use `node lifecycle recycle` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
74
+ description: 'close a node + cascade-cancel its exclusive subtree',
75
+ whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. A node that never produced substantive assistant output is deleted and cannot be revived; streaming nodes and nodes with an open managed worktree are retained. Other closed nodes keep their pi session and can be revived later (`node lifecycle revive`). Use `node lifecycle recycle` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
76
76
  help: {
77
77
  name: 'node lifecycle close',
78
- summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark them all canceled, and leave each a notice it reads on resume. A descendant still subscribed to by a manager outside the subtree is left running. Nothing is deleted: every closed node keeps its pi session and can be revived later (`crtr node lifecycle revive`)',
78
+ summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark retained nodes canceled, and leave them a notice they read on resume. A descendant still subscribed to by a manager outside the subtree is left running. A node that never produced substantive assistant output is deleted and cannot be revived; streaming nodes and nodes with an open managed worktree are retained. Other closed nodes keep their pi session and can be revived later (`crtr node lifecycle revive`)',
79
79
  params: [
80
80
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to close. Defaults to the node occupying --pane (or your current pane).' },
81
81
  { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to close. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
@@ -88,9 +88,10 @@ const nodeClose = defineLeaf({
88
88
  ],
89
89
  outputKind: 'object',
90
90
  effects: [
91
- 'Marks the node and its exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
92
- 'Kills each closed node\'s tmux window; their pi sessions and canvas edges persist for a later revive.',
93
- 'Appends a cancellation notice to each closed node\'s inbox, surfaced on its next resume.',
91
+ 'Marks retained nodes and their exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
92
+ 'Deletes a non-streaming node with no substantive assistant output unless it has an open managed worktree; a deleted node cannot be revived.',
93
+ 'Kills each closed node\'s tmux window; retained nodes keep their pi sessions and canvas edges for a later revive.',
94
+ 'Appends a cancellation notice to each retained node\'s inbox, surfaced on its next resume.',
94
95
  ],
95
96
  },
96
97
  run: async (input) => {
@@ -160,8 +160,8 @@ function makePushLeaf(kind) {
160
160
  ...(kind === 'final'
161
161
  ? [
162
162
  { name: 'transitioned', type: 'object', required: false, constraint: 'Present only for a final that flipped the node — { from, to } lifecycle-status transition applied server-side.' },
163
- { name: 'worktree_auto_dropped', type: 'boolean', required: false, constraint: 'Present only when this node owned a managed worktree with zero commits ahead of its base and no uncommitted changes — the server auto-dropped it instead of requiring `node worktree close` first (see worktree_auto_dropped_path).' },
164
- { name: 'worktree_auto_dropped_path', type: 'string', required: false, constraint: 'Present only alongside worktree_auto_dropped: the checkout path that was dropped.' },
163
+ { name: 'worktree_auto_dropped', type: 'boolean', required: false, constraint: 'Present only when the server closed this node’s managed-worktree record without requiring `node worktree close`; cleanup of an existing checkout remains pending (see worktree_auto_dropped_path).' },
164
+ { name: 'worktree_auto_dropped_path', type: 'string', required: false, constraint: 'Present only alongside worktree_auto_dropped: the closed worktree path; an existing checkout remains until pending cleanup.' },
165
165
  ]
166
166
  : []),
167
167
  ],
@@ -172,7 +172,7 @@ function makePushLeaf(kind) {
172
172
  ...(kind === 'final'
173
173
  ? [
174
174
  'Marks the node done (status + intent) server-side; its engine shuts down on next stop.',
175
- 'When this node owns a clean managed worktree already contained in its local base, the server auto-drops it instead of blocking on `node worktree close`. A clean worktree whose exact tip exists on any branch on origin may also finalize while retaining its branch and checkout; uncommitted changes or a tip absent from origin still block with open_managed_worktree.',
175
+ 'When this node owns a clean managed worktree already contained in its local base, the server closes its record with cleanup pending instead of blocking on `node worktree close`. A clean worktree whose exact tip exists on any branch on origin may also finalize while retaining its branch and checkout; uncommitted changes or a tip absent from origin still block with open_managed_worktree.',
176
176
  ]
177
177
  : []),
178
178
  ],
@@ -242,7 +242,7 @@ function makePushLeaf(kind) {
242
242
  const line = kind === 'final'
243
243
  ? 'Result recorded — node finished; its engine shuts down on next stop. Nothing more to do here: STOP your turn immediately. Reply with just "Done." and nothing else.'
244
244
  : `Progress report fanned to ${n} subscriber(s).`;
245
- const dropNote = r['worktree_auto_dropped'] === true ? `\n\nAuto-dropped a clean, zero-commit managed worktree at ${String(r['worktree_auto_dropped_path'])} — nothing to land.` : '';
245
+ const dropNote = r['worktree_auto_dropped'] === true ? `\n\nClosed a clean managed-worktree record at ${String(r['worktree_auto_dropped_path'])} — nothing to land; cleanup remains pending if the checkout still exists.` : '';
246
246
  return `${line}\n\n- report: ${r['report_path']}${dropNote}`;
247
247
  },
248
248
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,190 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/bash-guard.test.ts
2
+ //
3
+ // BUG REGRESSION: a node ran
4
+ // cd star-theory-2 && HOME="$(mktemp -d …)" godot …; rm -rf "$HOME" …
5
+ // The `HOME=` prefix applied only to `godot`, so the trailing `rm -rf "$HOME"`
6
+ // expanded to the real home directory and deleted for ~108s before another node
7
+ // killed it. The guard refuses that command text before bash ever sees it.
8
+ import assert from 'node:assert/strict';
9
+ import { homedir, userInfo } from 'node:os';
10
+ import { test } from 'node:test';
11
+ import { bashCommandRefusal } from '../bash-guard.js';
12
+ const HOME = homedir();
13
+ const CWD = `${HOME}/Code/cli/crouter`;
14
+ function refusal(command, cwd = CWD) {
15
+ return bashCommandRefusal(command, cwd);
16
+ }
17
+ function assertRefused(command, expectedTarget, cwd = CWD) {
18
+ const message = refusal(command, cwd);
19
+ assert.notEqual(message, null, `expected refusal for: ${command}`);
20
+ assert.ok(message.includes(expectedTarget), `refusal for ${command} should name ${expectedTarget}, got: ${message}`);
21
+ }
22
+ function assertAllowed(command, cwd = CWD) {
23
+ assert.equal(refusal(command, cwd), null, `expected to allow: ${command}`);
24
+ }
25
+ // The incident
26
+ test('the incident command is refused and names the segment and resolved target', () => {
27
+ const command = 'cd star-theory-2 && HOME="$(mktemp -d /tmp/st2-legend-proof.XXXXXX)" godot --headless --path . ' +
28
+ '--script "$CRTR_CONTEXT_DIR/legend_proof.gd"; status=$?; rm -rf "$HOME" 2>/dev/null || true; exit $status';
29
+ const message = refusal(command);
30
+ assert.notEqual(message, null);
31
+ assert.ok(message.includes('rm -rf "$HOME"'), `should quote the offending segment: ${message}`);
32
+ assert.ok(message.includes(HOME), `should name the resolved target: ${message}`);
33
+ });
34
+ // Home, however it is spelled
35
+ test('every textual spelling of the home directory is refused', () => {
36
+ for (const target of ['~', '~/', '"$HOME"', '$HOME', '${HOME}', '"${HOME}/"', HOME, `${HOME}/`, '~/*', '"$HOME"/*']) {
37
+ assertRefused(`rm -rf ${target}`, HOME);
38
+ }
39
+ });
40
+ // System roots
41
+ test('system roots are refused', () => {
42
+ for (const target of ['/', '/Users', '/home', '/private', '/var', '/etc', '/tmp', '/usr', '/opt', '/System', '/Library', '/Applications', '/private/var']) {
43
+ assertRefused(`rm -rf ${target}`, target);
44
+ }
45
+ });
46
+ test('the canvas home is refused', () => {
47
+ assertRefused('rm -rf ~/.crouter', `${HOME}/.crouter`);
48
+ });
49
+ // Ancestors of the working directory
50
+ test('a strict ancestor of the working directory is refused', () => {
51
+ assertRefused(`rm -rf ${HOME}/Code/cli`, `${HOME}/Code/cli`);
52
+ assertRefused('rm -rf ..', `${HOME}/Code/cli`);
53
+ });
54
+ test('a relative target is read against every directory the line could be in', () => {
55
+ assertRefused('cd packages && rm -rf ../..', `${HOME}/Code/cli`);
56
+ // The guard cannot know a `cd` succeeded: after a failed one bash stays put,
57
+ // so `..` really is the ancestor and the refusal must still fire.
58
+ assertRefused('cd definitely-does-not-exist; rm -rf ..', `${HOME}/Code/cli`);
59
+ // Nowhere knowable to resolve from, so nothing relative is refused.
60
+ assertAllowed('cd "$SOMEWHERE" && rm -rf ..');
61
+ assertRefused('cd "$SOMEWHERE" && rm -rf ~', HOME);
62
+ });
63
+ test('the working directory itself still removes — a node may delete its own merged worktree', () => {
64
+ const worktree = `${HOME}/.crouter/canvas/worktrees/node-1`;
65
+ assertAllowed('rm -rf .', worktree);
66
+ assertAllowed(`rm -rf ${worktree}`, worktree);
67
+ });
68
+ // Recursive flag spellings
69
+ test('every recursive flag spelling is refused', () => {
70
+ for (const flags of ['-rf', '-fr', '-r', '-R', '-Rf', '-rvf', '--recursive -f', '-f --recursive', '-f -r', '-r --', '-rf --']) {
71
+ assertRefused(`rm ${flags} ~`, HOME);
72
+ }
73
+ });
74
+ test('a non-recursive rm is not this guard\u2019s business', () => {
75
+ assertAllowed('rm ~');
76
+ assertAllowed('rm -f ~/.zshrc');
77
+ });
78
+ // Prefixes that hide the verb
79
+ test('a command prefix does not hide the rm', () => {
80
+ for (const prefix of ['sudo', 'sudo -E', 'command', 'nice -n 10', 'nohup', 'time', 'env', 'doas']) {
81
+ assertRefused(`${prefix} rm -rf ~`, HOME);
82
+ }
83
+ });
84
+ test('a leading VAR=value assignment does not hide the rm', () => {
85
+ assertRefused('FOO=bar rm -rf ~', HOME);
86
+ });
87
+ // The distinction the incident turned on: an `export` changes $HOME for the
88
+ // rest of the line, a `HOME=… cmd` PREFIX changes it for that one command.
89
+ test('a reassigned HOME is honoured for the rest of the line', () => {
90
+ assertAllowed('export HOME=/tmp/st2home_5e && rm -rf "$HOME"');
91
+ assertAllowed('export HOME=/tmp/st2home_5e\nfind "$HOME" -name settings.cfg -delete');
92
+ assertAllowed('cd star-theory-2 && export HOME=/tmp/x && rm -rf "$HOME" && mkdir -p "$HOME"');
93
+ assertAllowed('HOME=/tmp/x\nrm -rf ~');
94
+ // Reassigned to something the guard cannot read: unknowable, so allowed.
95
+ assertAllowed('export HOME="$(mktemp -d)"; rm -rf "$HOME"');
96
+ });
97
+ test('a HOME= prefix on one command does not carry to the next', () => {
98
+ assertRefused('HOME=/tmp/x godot --headless; rm -rf "$HOME"', HOME);
99
+ });
100
+ test('a HOME= prefix does apply to its own command', () => {
101
+ assertAllowed('HOME=/tmp/x rm -rf "$HOME"');
102
+ });
103
+ test('a backgrounded assignment runs in a subshell and does not carry', () => {
104
+ assertRefused('HOME=/tmp/x & rm -rf "$HOME"', HOME);
105
+ assertRefused('export HOME=/tmp/x & rm -rf "$HOME"', HOME);
106
+ assertAllowed('export HOME=/tmp/x && rm -rf "$HOME"');
107
+ });
108
+ test('an assignment to a builtin that exports it persists', () => {
109
+ assertAllowed('HOME=/tmp/x export HOME; rm -rf "$HOME"');
110
+ });
111
+ test('$USER and $LOGNAME expand — a per-user path spelled that way still resolves', () => {
112
+ // Anchored on the working directory rather than the home path, whose per-user
113
+ // segment is not the username on every platform this runs on.
114
+ const user = userInfo().username;
115
+ const parent = `/srv/people/${user}`;
116
+ assertRefused('rm -rf /srv/people/$USER', parent, `${parent}/project`);
117
+ assertRefused('rm -rf "/srv/people/${LOGNAME}"', parent, `${parent}/project`);
118
+ });
119
+ test('a redirection is not a target', () => {
120
+ assertAllowed('rm -rf ./dist 2>/dev/null');
121
+ assertAllowed('rm -rf ./dist > /dev/null 2>&1');
122
+ assertRefused('rm -rf ~ 2>/dev/null', HOME);
123
+ });
124
+ // Segment separators
125
+ test('the rm is found in any shell segment', () => {
126
+ for (const command of ['ls && rm -rf ~', 'ls; rm -rf ~', 'ls || rm -rf ~', 'ls | rm -rf ~', 'ls &\nrm -rf ~']) {
127
+ assertRefused(command, HOME);
128
+ }
129
+ });
130
+ // Must still run
131
+ test('ordinary removals still run', () => {
132
+ assertAllowed('rm -rf "$d"');
133
+ assertAllowed('rm -rf node_modules');
134
+ assertAllowed('rm -rf /tmp/foo.XXXX');
135
+ assertAllowed('rm -rf "$CRTR_CONTEXT_DIR/jobs/x"');
136
+ assertAllowed('rm -rf ~/Code/cli/crouter/dist');
137
+ assertAllowed('rm -rf ./dist .dist-build-*');
138
+ assertAllowed('rm -rf "$HOME/.cache/crouter/termrender"');
139
+ assertAllowed('rm -rf ~/.crouter/canvas/scratch/old');
140
+ assertAllowed('rm -rf /tmp/crtr-build.1234');
141
+ });
142
+ test('a quoted mention of a dangerous command is data, not a command', () => {
143
+ assertAllowed('echo "rm -rf ~"');
144
+ assertAllowed("git commit -m 'never rm -rf $HOME'");
145
+ });
146
+ test('a heredoc body is data, not a command', () => {
147
+ assertAllowed('cat > note.md <<\'EOF\'\nrm -rf ~\nEOF');
148
+ });
149
+ // Other operations that can destroy a home or system tree
150
+ test('moving a protected path away is refused', () => {
151
+ assertRefused('mv ~ /tmp/old-home', HOME);
152
+ assertRefused('mv /etc /tmp/etc', '/etc');
153
+ });
154
+ test('a recursive permission or ownership change on a protected path is refused', () => {
155
+ assertRefused('chmod -R 777 /', '/');
156
+ assertRefused('sudo chown -R root ~', HOME);
157
+ assertRefused('chgrp -R staff /usr', '/usr');
158
+ assertAllowed('chmod -R 755 ./scripts');
159
+ assertAllowed('chmod 600 ~/.ssh/config');
160
+ });
161
+ test('a find that deletes or executes over a protected path is refused', () => {
162
+ assertRefused('find ~ -name "*.log" -delete', HOME);
163
+ assertRefused('find / -name core -exec rm -f {} \\;', '/');
164
+ assertAllowed('find ~ -name "*.log"');
165
+ assertAllowed('find . -name "*.log" -delete');
166
+ });
167
+ test('find’s leading global options do not hide its start path', () => {
168
+ assertRefused('find -H ~ -delete', HOME);
169
+ assertRefused('find -- ~ -delete', HOME);
170
+ assertRefused('find -L -x /etc -delete', '/etc');
171
+ assertRefused('find -f ~ -delete', HOME);
172
+ assertAllowed('find -H ./logs -delete');
173
+ });
174
+ test('writing a raw disk or reformatting one is refused', () => {
175
+ assertRefused('dd if=/dev/zero of=/dev/disk0 bs=1m', '/dev/disk0');
176
+ assertRefused('dd if=/dev/zero of=/dev/rdisk3 bs=1m', '/dev/rdisk3');
177
+ assertRefused('sudo mkfs.ext4 /dev/sda1', 'mkfs.ext4');
178
+ assertRefused('diskutil eraseDisk JHFS+ Blank /dev/disk2', 'diskutil eraseDisk');
179
+ assertAllowed('dd if=/dev/zero of=./blob bs=1m count=1');
180
+ // A character device is ordinary output, not a disk.
181
+ assertAllowed('dd if=/dev/zero of=/dev/null bs=1m count=1');
182
+ assertAllowed('dd if=./blob of=/dev/stdout');
183
+ });
184
+ // The long-sleep refusal moved here with the rest of the bash refusals.
185
+ test('a long leading sleep is still refused, a short one still runs', () => {
186
+ assert.notEqual(refusal('sleep 300'), null);
187
+ assert.notEqual(refusal('sleep 5m'), null);
188
+ assert.equal(refusal('sleep 5'), null);
189
+ assert.equal(refusal('sleep "$n"'), null);
190
+ });
@@ -148,6 +148,7 @@ declare class FakeSession {
148
148
  /** Compiled-seam graceful-teardown vehicle: a real active turn that only settles
149
149
  * after abort(), so the broker's bounded abort wait is observable. */
150
150
  private heldTurn;
151
+ private retryAbort;
151
152
  private readonly listeners;
152
153
  /** Idle watcher sends received while bindExtensions is still running. They
153
154
  * start naturally as soon as broker.ts subscribes, without a retry loop that
@@ -195,6 +196,9 @@ declare class FakeSession {
195
196
  };
196
197
  get isStreaming(): boolean;
197
198
  getSessionStats(): Record<string, unknown>;
199
+ getContextUsage(): {
200
+ tokens: number;
201
+ } | undefined;
198
202
  private syncTreeSnapshot;
199
203
  /** Reads the crashed process's last `fake-pi.tree.json` (if any) and rebuilds
200
204
  * this process's tree from it. Best-effort: a first boot (no snapshot yet)
@@ -238,6 +242,7 @@ declare class FakeSession {
238
242
  deliverAs?: string;
239
243
  }): Promise<void>;
240
244
  abort(): Promise<void>;
245
+ abortRetry(): void;
241
246
  /** In-place tree rewind (broker `navigate_tree`). Mirrors the real SDK's
242
247
  * contract: the session file is unchanged, the live history is truncated,
243
248
  * and the navigated-to user message's text comes back as `editorText`. The
@@ -274,6 +279,7 @@ interface FakeModel {
274
279
  provider: string;
275
280
  id: string;
276
281
  api?: string;
282
+ contextWindow?: number;
277
283
  }
278
284
  /** The fake `ModelRuntime` — the runtime-method subset broker.ts's
279
285
  * `registryOf` facade (and its model-less construction mask) actually reads. */
@@ -298,6 +298,7 @@ class FakeSession {
298
298
  /** Compiled-seam graceful-teardown vehicle: a real active turn that only settles
299
299
  * after abort(), so the broker's bounded abort wait is observable. */
300
300
  heldTurn;
301
+ retryAbort;
301
302
  // The broker's fan-out subscribers (broker.ts `session.subscribe(...)`) and the
302
303
  // accumulating message history the catch-up snapshot serializes (G3).
303
304
  listeners = new Set();
@@ -458,6 +459,15 @@ class FakeSession {
458
459
  getSessionStats() {
459
460
  return { messages: 0, tokens: 0 };
460
461
  }
462
+ getContextUsage() {
463
+ try {
464
+ const tokens = Number(readFileSync(join(this.dir, 'fake-pi.context-tokens'), 'utf8'));
465
+ return Number.isFinite(tokens) ? { tokens } : undefined;
466
+ }
467
+ catch {
468
+ return { tokens: 1000 };
469
+ }
470
+ }
461
471
  syncTreeSnapshot() {
462
472
  try {
463
473
  writeFileSync(join(this.dir, 'fake-pi.tree.json'), JSON.stringify({
@@ -571,6 +581,9 @@ class FakeSession {
571
581
  this.bashAbort?.abort();
572
582
  return this.bashRun ?? Promise.resolve();
573
583
  }
584
+ abortRetry() {
585
+ this.retryAbort?.();
586
+ }
574
587
  /** In-place tree rewind (broker `navigate_tree`). Mirrors the real SDK's
575
588
  * contract: the session file is unchanged, the live history is truncated,
576
589
  * and the navigated-to user message's text comes back as `editorText`. The
@@ -938,7 +951,7 @@ class FakeSession {
938
951
  getBranch: () => this.tree.getBranch(),
939
952
  getEntries: () => this.tree.getEntries(),
940
953
  },
941
- getContextUsage: () => ({ tokens: 1000 }),
954
+ getContextUsage: () => this.getContextUsage(),
942
955
  // Wired to the broker's shutdownHandler: the stophook calls ctx.shutdown()
943
956
  // in exactly its done / idle-release / refresh branches → disposeAndExit.
944
957
  shutdown: () => {
@@ -1068,8 +1081,9 @@ class FakeSession {
1068
1081
  this.emit({ type: 'agent_settled' });
1069
1082
  break;
1070
1083
  case 'stop': {
1071
- // Match pi's lifecycle: the stophook sees agent_end while the run is
1072
- // active, then the subscriber receives agent_settled after it clears.
1084
+ // Match pi's lifecycle: message_end reaches broker fallback policy
1085
+ // before the stophook sees agent_end while the run is active; the
1086
+ // subscriber receives agent_settled after it clears.
1073
1087
  this.streaming = true;
1074
1088
  const stopMessages = [
1075
1089
  {
@@ -1097,6 +1111,7 @@ class FakeSession {
1097
1111
  content: [{ type: 'text', text: cmd.text ?? '' }],
1098
1112
  },
1099
1113
  ];
1114
+ this.emit({ type: 'message_end', message: stopMessages[0] });
1100
1115
  await this.fire('agent_end', { messages: stopMessages }, ctx);
1101
1116
  // Every attempt reaches the broker subscriber while streaming; only the
1102
1117
  // following settled event may publish its fault or arm a retry timer.
@@ -1174,16 +1189,29 @@ class FakeSession {
1174
1189
  writeFileSync(join(this.dir, 'fake-pi.overflow.json'), JSON.stringify({ outcome: cmd.outcome ?? 'uncompacted' }));
1175
1190
  break;
1176
1191
  }
1177
- case 'auto_retry':
1178
- // Drive the exact broker subscription event that pi emits before its
1179
- // retry timer, so provider fallback policy runs in the live broker.
1192
+ case 'auto_retry': {
1193
+ // Pi emits agent_end for the failed attempt, then auto_retry_start, and
1194
+ // only then creates the AbortController for its delay. The capacity
1195
+ // fallback must defer abortRetry one microtask to cancel this retry.
1196
+ const retryMessages = [{
1197
+ role: 'assistant', stopReason: 'error', errorMessage: cmd.errorMessage ?? 'rate limit exceeded', content: [],
1198
+ }];
1199
+ this.streaming = true;
1200
+ await this.fire('agent_end', { messages: retryMessages }, ctx);
1201
+ this.emit({ type: 'agent_end', messages: retryMessages, willRetry: true });
1180
1202
  this.emit({ type: 'auto_retry_start', errorMessage: cmd.errorMessage ?? 'rate limit exceeded' });
1181
- // The broker begins its fallback async; the next check phase follows
1182
- // its resolved registry/setModel microtasks and is a deterministic
1183
- // receipt for a rejected target (which intentionally never changes model).
1203
+ let aborted = false;
1204
+ this.retryAbort = () => { aborted = true; };
1184
1205
  await new Promise((resolve) => setImmediate(resolve));
1185
- writeFileSync(join(this.dir, 'fake-pi.auto-retry.json'), JSON.stringify({ at: Date.now() }));
1206
+ this.retryAbort = undefined;
1207
+ if (aborted)
1208
+ this.emit({ type: 'auto_retry_end', success: false, finalError: 'Retry cancelled' });
1209
+ this.streaming = false;
1210
+ await this.fire('agent_settled', { type: 'agent_settled' }, ctx);
1211
+ this.emit({ type: 'agent_settled' });
1212
+ writeFileSync(join(this.dir, 'fake-pi.auto-retry.json'), JSON.stringify({ at: Date.now(), aborted }));
1186
1213
  break;
1214
+ }
1187
1215
  case 'display':
1188
1216
  // Paint through the broker's REAL uiContext exactly as an extension does.
1189
1217
  // These are LEVELS with no pi getter, so the broker is their only holder
@@ -1221,6 +1249,18 @@ class FakeSession {
1221
1249
  }
1222
1250
  }
1223
1251
  }
1252
+ function fakeModel(provider, id) {
1253
+ try {
1254
+ const capacities = JSON.parse(readFileSync(join(nodeDirFromEnv(), 'fake-pi.model-capacities.json'), 'utf8'));
1255
+ const contextWindow = capacities[`${provider}/${id}`];
1256
+ return typeof contextWindow === 'number' && Number.isFinite(contextWindow)
1257
+ ? { provider, id, contextWindow }
1258
+ : { provider, id };
1259
+ }
1260
+ catch {
1261
+ return { provider, id };
1262
+ }
1263
+ }
1224
1264
  /** The real pi SDK's model-less sentinel (Agent.DEFAULT_MODEL). The fake mirrors
1225
1265
  * it so `session.model` is NEVER literally `undefined` after a model-less boot,
1226
1266
  * matching prod — see the FakeSession constructor. */
@@ -1292,7 +1332,7 @@ export async function createAgentSessionServices(options) {
1292
1332
  // A minimal model stub for any spec — the set_model regression test drives
1293
1333
  // the broker's findModelSpec → session.setModel path through it. Nothing is
1294
1334
  // findable during a zero-auth window.
1295
- getModel: (provider, id) => (zeroAuth() ? undefined : { provider, id }),
1335
+ getModel: (provider, id) => (zeroAuth() ? undefined : fakeModel(provider, id)),
1296
1336
  // Always-authenticated single fake model — the M-9 no-boot-model branch
1297
1337
  // (broker.ts) picks `available[0]` and must never see an empty list here —
1298
1338
  // EXCEPT during a zero-auth window, when nothing is available/registered.
@@ -33,7 +33,7 @@ import { tmpdir } from 'node:os';
33
33
  import { join } from 'node:path';
34
34
  import { spawn, spawnSync } from 'node:child_process';
35
35
  import { createNode, deleteNode, getNode, subscribe, updateNode } from '../../canvas/canvas.js';
36
- import { armCron, hasPendingCancelOnWakeCron } from '../../canvas/crons.js';
36
+ import { armCron, getCron } from '../../canvas/crons.js';
37
37
  import { readInboxSince } from '../../feed/inbox.js';
38
38
  import { closeDb } from '../../canvas/db.js';
39
39
  import { apiSocketPath } from '../../canvas/paths.js';
@@ -304,11 +304,11 @@ test('a recovery relaunch keeps the armed deadline; a wake revive consumes it',
304
304
  const recovered = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-1' });
305
305
  armDeadline(recovered, 'cron-recovery');
306
306
  await reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
307
- assert.equal(hasPendingCancelOnWakeCron(recovered), true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
307
+ assert.equal(getCron('cron-recovery') !== null, true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
308
308
  const woken = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-2' });
309
309
  armDeadline(woken, 'cron-wake');
310
310
  await reviveNode(woken, { resume: true, capacity: 'freeze' });
311
- assert.equal(hasPendingCancelOnWakeCron(woken), false, 'a wake revive won the race and cancels the deadline');
311
+ assert.equal(getCron('cron-wake'), null, 'a wake revive won the race and cancels the deadline');
312
312
  }
313
313
  finally {
314
314
  await h.dispose();
@@ -43,6 +43,33 @@ function seedPool(provider, cooling) {
43
43
  rateLimitedUntil: cooling ? Date.now() + 60_000 : 0, lastAttemptAt: 0, lastRateLimitedAt: 0,
44
44
  }]));
45
45
  }
46
+ function modelRoute(spec) {
47
+ return { ultra: spec, strong: spec, medium: spec, light: spec };
48
+ }
49
+ function seedCapacityFallbackRoutes() {
50
+ const userConfigDir = join(h.agentHome, '.crouter');
51
+ const userConfigPath = join(userConfigDir, 'config.json');
52
+ mkdirSync(userConfigDir, { recursive: true });
53
+ writeFileSync(userConfigPath, JSON.stringify({
54
+ kinds: { 'test/capacity-fallback': { whenToUse: 'Capacity fallback regression.', model: 'anthropic/medium' } },
55
+ modelRoutes: {
56
+ primary: { family: 'anthropic', credentialSource: 'user', providerId: 'fake-primary', models: modelRoute('fake-primary/primary') },
57
+ fallback: { family: 'anthropic', credentialSource: 'northlight', providerId: 'fake-small', models: modelRoute('fake-small/small') },
58
+ largerFallback: { family: 'anthropic', credentialSource: 'third', providerId: 'fake-large', models: modelRoute('fake-large/large') },
59
+ },
60
+ modelRouting: { credentialSourceOrder: ['user', 'northlight', 'third'], familyOrder: ['anthropic'], strengthFallback: ['medium'] },
61
+ }));
62
+ return userConfigPath;
63
+ }
64
+ function seedCapacityFixture(id, largeContextWindow = 999) {
65
+ const dir = join(h.home, 'nodes', id);
66
+ writeFileSync(join(dir, 'fake-pi.context-tokens'), '1000');
67
+ writeFileSync(join(dir, 'fake-pi.model-capacities.json'), JSON.stringify({
68
+ 'fake-primary/primary': 2000,
69
+ 'fake-small/small': 999,
70
+ 'fake-large/large': largeContextWindow,
71
+ }));
72
+ }
46
73
  // The node's recorded model (fake-pi.model.json, written by the fake's
47
74
  // recordModel) — `null` for a model-less boot. A disk read, so it survives the
48
75
  // broker exiting after its kickoff turn idle-releases.
@@ -395,6 +422,36 @@ test('a recovered compact-and-retry commits nothing, while a definitive failure
395
422
  const notices = h.inbox(parent).filter((entry) => /exceeded its model context window/i.test(entry.label));
396
423
  assert.equal(notices.length, 1, 'the parent receives exactly one structured overflow notice');
397
424
  });
425
+ test('provider-retry and model-not-found fallback both terminate when every available target has a smaller known context window', { timeout: 30_000 }, async () => {
426
+ const userConfigPath = seedCapacityFallbackRoutes();
427
+ try {
428
+ const parent = h.spawnRoot('capacity fallback parent');
429
+ const providerRetry = await h.spawnHeadlessChild(parent, 'provider retry capacity fallback', { kind: 'test/capacity-fallback' });
430
+ await h.waitFor(() => agentStartCount(providerRetry) >= 1, { label: 'provider retry capacity fixture started' });
431
+ seedCapacityFixture(providerRetry, 2000);
432
+ h.fakeCmd(providerRetry, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
433
+ await h.waitFor(() => model(providerRetry)?.provider === 'fake-large' ? true : null, { label: 'provider retry skips the too-small target for the large enough target' });
434
+ assert.equal(model(providerRetry).provider, 'fake-large', 'a large enough fallback remains eligible after the smaller route is rejected');
435
+ const exhaustedProviderRetry = await h.spawnHeadlessChild(parent, 'exhausted provider retry capacity fallback', { kind: 'test/capacity-fallback' });
436
+ await h.waitFor(() => agentStartCount(exhaustedProviderRetry) >= 1, { label: 'exhausted provider retry capacity fixture started' });
437
+ seedCapacityFixture(exhaustedProviderRetry);
438
+ h.fakeCmd(exhaustedProviderRetry, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
439
+ const providerRetryFault = await h.waitFor(() => readFault(exhaustedProviderRetry)?.kind === 'context-overflow' ? readFault(exhaustedProviderRetry) : null, { label: 'provider retry rejects every too-small fallback target' });
440
+ assert.equal(providerRetryFault.retry.disposition, 'fatal', 'all known-too-small provider retry targets produce a fatal context-overflow fault');
441
+ assert.match(providerRetryFault.message, /1000 tokens/, 'the terminal fault identifies the current conversation size');
442
+ assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', exhaustedProviderRetry, 'fake-pi.auto-retry.json'), 'utf8')).aborted, true, 'the retry delay is cancelled after Pi creates its retry controller');
443
+ const modelNotFound = await h.spawnHeadlessChild(parent, 'model-not-found capacity fallback', { kind: 'test/capacity-fallback' });
444
+ await h.waitFor(() => agentStartCount(modelNotFound) >= 1, { label: 'model-not-found capacity fixture started' });
445
+ seedCapacityFixture(modelNotFound);
446
+ h.fakeCmd(modelNotFound, { cmd: 'stop', reason: 'error', errorMessage: 'Model not found' });
447
+ const modelNotFoundFault = await h.waitFor(() => readFault(modelNotFound)?.kind === 'context-overflow' ? readFault(modelNotFound) : null, { label: 'model-not-found rejects the too-small fallback target' });
448
+ assert.equal(modelNotFoundFault.retry.disposition, 'fatal', 'all known-too-small model-not-found targets produce a fatal context-overflow fault');
449
+ assert.match(modelNotFoundFault.message, /1000 tokens/, 'the terminal fault survives ordinary settlement staging');
450
+ }
451
+ finally {
452
+ rmSync(userConfigPath, { force: true });
453
+ }
454
+ });
398
455
  // Two real logical launches keep both user paths non-vacuous: the picker first
399
456
  // proves an explicit concrete choice pins and clears routing, while Alt+M runs
400
457
  // directly against still-present logical intent before Shift+Tab and replay.