@north-light/crouter 0.3.302 → 0.3.304

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 (85) hide show
  1. package/dist/api/__tests__/integration/client.test.js +37 -36
  2. package/dist/api/client.d.ts +32 -55
  3. package/dist/api/client.js +102 -98
  4. package/dist/api/dto/messages.d.ts +6 -5
  5. package/dist/api/errors.d.ts +3 -0
  6. package/dist/api/errors.js +10 -0
  7. package/dist/api/index.d.ts +1 -1
  8. package/dist/api/index.js +1 -1
  9. package/dist/builtin-memory/internal/plugins.md +1 -1
  10. package/dist/clients/attach/viewer.js +532 -532
  11. package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
  12. package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
  13. package/dist/commands/api-client.d.ts +3 -3
  14. package/dist/commands/api-client.js +3 -3
  15. package/dist/commands/memory/read.js +2 -1
  16. package/dist/commands/node/bash.js +6 -6
  17. package/dist/commands/node/message.js +3 -3
  18. package/dist/commands/sys/daemon.js +11 -13
  19. package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
  20. package/dist/core/__tests__/bash-guard.test.js +190 -0
  21. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
  22. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  23. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  24. package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
  25. package/dist/core/__tests__/helpers/harness.d.ts +2 -0
  26. package/dist/core/__tests__/helpers/harness.js +9 -0
  27. package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
  28. package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
  29. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
  30. package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
  31. package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
  32. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
  33. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
  34. package/dist/core/bash-guard.d.ts +6 -0
  35. package/dist/core/bash-guard.js +393 -0
  36. package/dist/core/bash-jobs.d.ts +20 -8
  37. package/dist/core/bash-jobs.js +40 -21
  38. package/dist/core/canvas/types.d.ts +4 -2
  39. package/dist/core/command.js +1 -1
  40. package/dist/core/fault-classifier.d.ts +1 -1
  41. package/dist/core/runtime/broker/event-projection.d.ts +0 -3
  42. package/dist/core/runtime/broker/event-projection.js +2 -15
  43. package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
  44. package/dist/core/runtime/broker/fault-retry.js +74 -6
  45. package/dist/core/runtime/broker-persona-guidance.js +12 -0
  46. package/dist/core/runtime/broker.js +0 -5
  47. package/dist/core/runtime/fault.js +1 -1
  48. package/dist/core/runtime/host.js +10 -1
  49. package/dist/core/runtime/spawn.js +5 -6
  50. package/dist/core/shell-segments.d.ts +42 -0
  51. package/dist/core/shell-segments.js +169 -0
  52. package/dist/core/substrate/surface-match.d.ts +0 -9
  53. package/dist/core/substrate/surface-match.js +4 -160
  54. package/dist/core/worktree-close.d.ts +4 -0
  55. package/dist/core/worktree-close.js +225 -0
  56. package/dist/core/worktree-containment.d.ts +14 -0
  57. package/dist/core/worktree-containment.js +48 -0
  58. package/dist/core/worktree-mutation-async.d.ts +13 -0
  59. package/dist/core/worktree-mutation-async.js +281 -0
  60. package/dist/core/worktree-sweep.js +17 -79
  61. package/dist/core/worktree.d.ts +1 -0
  62. package/dist/core/worktree.js +1 -1
  63. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
  64. package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
  65. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  66. package/dist/daemon/api/handlers/messages.js +7 -5
  67. package/dist/daemon/api/handlers/reports.js +3 -2
  68. package/dist/daemon/api/handlers/worktree.js +7 -6
  69. package/dist/daemon/fleet.d.ts +1 -1
  70. package/dist/daemon/fleet.js +30 -12
  71. package/dist/daemon/manage.d.ts +8 -5
  72. package/dist/daemon/manage.js +63 -40
  73. package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
  74. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
  75. package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
  76. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
  77. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
  78. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
  79. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
  80. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
  81. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -3
  82. package/dist/pi-extensions/canvas-bash-valve.js +75 -77
  83. package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
  84. package/package.json +1 -1
  85. package/runtime.lock.json +5 -5
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ test('daemon status reports its failed probe without telling the operator to stop a live process', () => {
8
+ const home = mkdtempSync(join(tmpdir(), 'crtr-daemon-status-'));
9
+ const pidfile = join(home, 'crtrd.pid');
10
+ const owner = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
11
+ try {
12
+ assert.ok(owner.pid !== undefined, 'fixture process has a pid');
13
+ writeFileSync(pidfile, String(owner.pid));
14
+ const result = spawnSync(process.execPath, [join(process.cwd(), 'dist', 'cli.js'), '--json', 'sys', 'daemon', 'status'], {
15
+ encoding: 'utf8',
16
+ env: { ...process.env, CRTR_HOME: home, CRTR_PIDFILE: pidfile, CRTR_NO_DAEMON_AUTOSTART: '1' },
17
+ timeout: 10_000,
18
+ });
19
+ assert.equal(result.status, 0, result.stderr);
20
+ const output = JSON.parse(result.stdout);
21
+ assert.deepEqual({ running: output.running, serving: output.serving }, { running: true, serving: false });
22
+ assert.match(output.probe_error ?? '', /ENOENT|ECONNREFUSED/);
23
+ assert.equal(output.next, undefined, 'a single failed health probe is not a reason to tell the operator to stop the owner');
24
+ }
25
+ finally {
26
+ owner.kill('SIGKILL');
27
+ rmSync(home, { recursive: true, force: true });
28
+ }
29
+ });
@@ -38,9 +38,9 @@ export declare function readColdStartDiagnostic(): string | undefined;
38
38
  * default: on a cold socket the injected `onColdSocket` hook fires
39
39
  * `ensureDaemon()` (spawns crtrd detached via the branded host), then the
40
40
  * client polls `/healthz` to a bounded deadline and retries once (the poll
41
- * lives inside `CrtrClient`, A-2). If crtrd never becomes reachable — or
42
- * autostart is disabled — the client throws `daemon_unavailable` (spec §7.1,
43
- * §8). This is the ONLY CLI reference to `ensureDaemon`.
41
+ * lives inside `CrtrClient`, A-2). If crtrd never becomes reachable, the
42
+ * client surfaces the final observed transport failure. This is the ONLY CLI
43
+ * reference to `ensureDaemon`.
44
44
  *
45
45
  * `onColdSocket` is fire-and-forget — `ensureDaemon()` spawns crtrd and
46
46
  * returns immediately, so the client's OWN `/healthz` poll is the only
@@ -104,9 +104,9 @@ export function readColdStartDiagnostic() {
104
104
  * default: on a cold socket the injected `onColdSocket` hook fires
105
105
  * `ensureDaemon()` (spawns crtrd detached via the branded host), then the
106
106
  * client polls `/healthz` to a bounded deadline and retries once (the poll
107
- * lives inside `CrtrClient`, A-2). If crtrd never becomes reachable — or
108
- * autostart is disabled — the client throws `daemon_unavailable` (spec §7.1,
109
- * §8). This is the ONLY CLI reference to `ensureDaemon`.
107
+ * lives inside `CrtrClient`, A-2). If crtrd never becomes reachable, the
108
+ * client surfaces the final observed transport failure. This is the ONLY CLI
109
+ * reference to `ensureDaemon`.
110
110
  *
111
111
  * `onColdSocket` is fire-and-forget — `ensureDaemon()` spawns crtrd and
112
112
  * returns immediately, so the client's OWN `/healthz` poll is the only
@@ -22,7 +22,8 @@ async function fetchSubject(nodeId) {
22
22
  if (nodeId === undefined)
23
23
  return null;
24
24
  try {
25
- return await CrtrClient.forLocalSocket({ autostart: false }).nodeSubject(nodeId);
25
+ // This optional gate lookup must not hold a memory read while a daemon is unavailable: an absent subject simply skips gated docs.
26
+ return await CrtrClient.forLocalSocket({ autostart: false, coldStartPollWindowMs: 0 }).nodeSubject(nodeId);
26
27
  }
27
28
  catch {
28
29
  return null;
@@ -117,7 +117,7 @@ const nodeBashKill = defineLeaf({
117
117
  tier: 'hidden',
118
118
  help: {
119
119
  name: 'node bash kill',
120
- summary: 'terminate a background bash job’s process group and tell the owning node it was canceled',
120
+ summary: 'best-effort stop a background bash job and tell the owning node it was canceled',
121
121
  params: [
122
122
  { kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list.' },
123
123
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
@@ -125,11 +125,11 @@ const nodeBashKill = defineLeaf({
125
125
  ],
126
126
  output: [
127
127
  { name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
128
- { name: 'signaled', type: 'boolean', required: true, constraint: 'True when the process group was still alive and received SIGTERM.' },
128
+ { name: 'signaled', type: 'boolean', required: true, constraint: 'True when at least one observed supervisor-tree process received SIGTERM.' },
129
129
  ],
130
130
  outputKind: 'object',
131
131
  effects: [
132
- 'Sends SIGTERM (then SIGKILL) to the job’s whole process group, stopping the command and anything it forked.',
132
+ 'Snapshots the supervisor’s observable process tree, sends SIGTERM then SIGKILL to those processes, and cannot stop a descendant that detached and reparented before the snapshot.',
133
133
  'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was stopped — which steers that node mid-turn.',
134
134
  ],
135
135
  },
@@ -159,8 +159,8 @@ const nodeBashKill = defineLeaf({
159
159
  if (stopped.kind === 'not-stoppable') {
160
160
  throw new InputError({
161
161
  error: 'not_stoppable',
162
- message: `job ${jobId} recorded no process group`,
163
- next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
162
+ message: `job ${jobId} recorded no trusted process identity`,
163
+ next: `It started before crtr persisted its supervisor identity. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
164
164
  });
165
165
  }
166
166
  await cliClient().sendMessage(node.node_id, {
@@ -169,7 +169,7 @@ const nodeBashKill = defineLeaf({
169
169
  });
170
170
  return { node_id: node.node_id, signaled: stopped.signaled };
171
171
  },
172
- render: (result) => `Canceled a background bash job for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
172
+ render: (result) => `Canceled a background bash job for ${result['node_id']}${result['signaled'] === true ? '' : ' (no observed job process was still running)'}.`,
173
173
  });
174
174
  const nodeBashExtend = defineLeaf({
175
175
  name: 'extend',
@@ -38,7 +38,7 @@ async function resolveMsgTarget(input, hasBody) {
38
38
  }
39
39
  function messageOutput() {
40
40
  return [
41
- { name: 'revived', type: 'boolean', required: false, constraint: 'True when the target was revived.' },
41
+ { name: 'revived', type: 'boolean', required: false, constraint: 'True when this request synchronously revived the target; ordinary durable delivery to an active or idle dormant target queues lifecycle revival instead.' },
42
42
  { name: 'guidance', type: 'string', required: true, constraint: 'Immediate action confirmation.' },
43
43
  ];
44
44
  }
@@ -67,7 +67,7 @@ function messageRequestParams() {
67
67
  { kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. The schema alone is a complete request.', positionalStdinConflict: missingMessageTargetForBody },
68
68
  { kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
69
69
  { kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
70
- { kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal'], required: false, default: 'normal', constraint: 'Delivery urgency. Every accepted tier may revive a dormant target, because the request must be answered; deferred is rejected for that reason.' },
70
+ { kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal'], required: false, default: 'normal', constraint: 'Delivery urgency. Every accepted tier queues lifecycle revival for an active or idle dormant target because the request must be answered; deferred is rejected for that reason.' },
71
71
  { kind: 'flag', name: 'output-schema', type: 'string', required: true, constraint: `JSON Schema the result must satisfy. ${OUTPUT_SCHEMA_TRANSPORT} The target answers with \`crtr push result\` (or declines with \`crtr push result --decline "<reason>" --code <token>\`), then keeps working. ${OUTPUT_SCHEMA_CONSTRAINTS}` },
72
72
  { kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Non-empty hidden ambient context delivered beside the message, or alone.' },
73
73
  { kind: 'flag', name: 'reopen', type: 'bool', required: false, hidden: true, constraint: 'Clear a finalized target latch before delivery or fresh revive.' },
@@ -125,7 +125,7 @@ const nodeMessageSend = defineLeaf({
125
125
  params: messageSendParams(),
126
126
  output: messageOutput(),
127
127
  outputKind: 'object',
128
- effects: ['Appends one inbox entry. Critical, urgent, and normal delivery may revive a dormant target; deferred waits for its next natural cycle.', '--reopen commits the target resident and clears any finalization latch before delivery, allowing it to take a new mandate; it is valid for live, parked, and finalized targets. Without it, a finalized target rejects delivery before an inbox entry is appended or a revive is attempted.'],
128
+ effects: ['Appends one inbox entry. Critical, urgent, and normal delivery queues lifecycle revival for an active or idle dormant target; deferred waits for its next natural cycle.', '--reopen commits the target resident and clears any finalization latch before delivery, allowing it to take a new mandate; it is valid for live, parked, and finalized targets. Without it, a finalized target rejects delivery before an inbox entry is appended or a revive is attempted.'],
129
129
  },
130
130
  run: (input) => runMessageEngine(input),
131
131
  render: (r) => String(r['guidance']),
@@ -8,7 +8,7 @@
8
8
  // This subtree starts, checks, and stops the daemon process.
9
9
  import { defineLeaf, defineBranch } from '../../core/command.js';
10
10
  import { InputError } from '../../core/io.js';
11
- import { isDaemonServing, spawnDaemon, stopDaemonProcess, sweepStrayDaemons } from '../../daemon/manage.js';
11
+ import { probeDaemonServing, spawnDaemon, stopDaemonProcess, sweepStrayDaemons } from '../../daemon/manage.js';
12
12
  import { isDaemonRunning, readPidfile, isPidAlive } from '../../daemon/pidfile.js';
13
13
  import { cliClient } from '../api-client.js';
14
14
  // daemon start
@@ -26,7 +26,7 @@ const daemonStart = defineLeaf({
26
26
  { name: 'existing_pid', type: 'number', required: false, constraint: 'PID of the already-running daemon (when started=false).' },
27
27
  { name: 'running', type: 'boolean', required: false, constraint: 'True only when a live pidfile owner does not serve its Unix API socket.' },
28
28
  { name: 'serving', type: 'boolean', required: false, constraint: 'False only when a live pidfile owner does not answer /healthz.' },
29
- { name: 'next', type: 'string', required: false, constraint: 'Recovery instruction when running=true and serving=false.' },
29
+ { name: 'probe_error', type: 'string', required: false, constraint: 'The final /healthz probe failure when a live owner missed readiness.' },
30
30
  ],
31
31
  outputKind: 'object',
32
32
  effects: [
@@ -50,9 +50,9 @@ const daemonStatus = defineLeaf({
50
50
  params: [],
51
51
  output: [
52
52
  { name: 'running', type: 'boolean', required: true, constraint: 'True when the daemon pidfile owner is alive.' },
53
- { name: 'serving', type: 'boolean', required: false, constraint: 'True when the live daemon answers /healthz; false when it does not.' },
53
+ { name: 'serving', type: 'boolean', required: false, constraint: 'True when the live daemon answers /healthz; false when one probe does not.' },
54
54
  { name: 'pid', type: 'number', required: false, constraint: 'PID of the running daemon.' },
55
- { name: 'next', type: 'string', required: false, constraint: 'Recovery instruction when running=true and serving=false.' },
55
+ { name: 'probe_error', type: 'string', required: false, constraint: 'The failed /healthz observation when the live owner did not answer.' },
56
56
  ],
57
57
  outputKind: 'object',
58
58
  effects: ['Read-only: reads the pidfile, probes the pid, and requests /healthz without autostart.'],
@@ -62,15 +62,13 @@ const daemonStatus = defineLeaf({
62
62
  const running = pid !== null && isPidAlive(pid);
63
63
  if (!running)
64
64
  return { running: false };
65
- const serving = await isDaemonServing();
66
- return serving
67
- ? { running: true, serving: true, pid }
68
- : {
69
- running: true,
70
- serving: false,
71
- pid,
72
- next: 'Run `crtr sys daemon stop` to release the non-serving daemon before starting it again.',
73
- };
65
+ try {
66
+ await probeDaemonServing();
67
+ return { running: true, serving: true, pid };
68
+ }
69
+ catch (error) {
70
+ return { running: true, serving: false, pid, probe_error: error.message };
71
+ }
74
72
  },
75
73
  });
76
74
  // daemon restart
@@ -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
+ });
@@ -23,8 +23,6 @@ function projectionFor(session, generation) {
23
23
  notifyTurnAccepted: () => undefined,
24
24
  emitStartupMilestone: () => undefined,
25
25
  formatModelSpec: () => null,
26
- refreshIntent: async () => false,
27
- onRefreshIntentError: () => undefined,
28
26
  });
29
27
  }
30
28
  test('agent_start arms the provider watchdog before another engine event arrives', (t) => {
@@ -294,7 +294,7 @@ test('verifyDaemonStartup throws after its tiny startup window expires', async (
294
294
  sleepMs: (ms) => {
295
295
  now += ms;
296
296
  },
297
- }), /did not become ready within 25ms/);
297
+ }), /has no live pidfile owner/);
298
298
  });
299
299
  // REGRESSION (bug #102): the verifier polls with a real async sleep, NOT a
300
300
  // synchronous Atomics.wait, so the event loop stays free to dispatch the spawned
@@ -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.
@@ -101,6 +101,8 @@ export interface Harness {
101
101
  inbox(nodeId: string): InboxEntry[];
102
102
  injected(nodeId: string): Injected[];
103
103
  fakeCmd(nodeId: string, cmd: Record<string, unknown>): void;
104
+ /** Stop only this harness's API server to exercise a broker daemon-read failure. */
105
+ stopApiServer(): Promise<void>;
104
106
  dialogResults(nodeId: string): {
105
107
  resolved: unknown;
106
108
  ms: number;
@@ -637,6 +637,15 @@ export async function createHarness(opts = {}) {
637
637
  fakeCmd(nodeId, cmd) {
638
638
  sendCmd(nodeId, cmd);
639
639
  },
640
+ async stopApiServer() {
641
+ try {
642
+ apiServerProc.kill('SIGTERM');
643
+ }
644
+ catch {
645
+ /* already gone */
646
+ }
647
+ await waitFor(() => apiServerProc.exitCode === null ? null : true, { label: 'harness API server exit' });
648
+ },
640
649
  dialogResults(nodeId) {
641
650
  return readLines(join(nodeDir(nodeId), 'fake-pi.dialog.jsonl'))
642
651
  .map((l) => {