@crouton-kit/crouter 0.3.66 → 0.3.68

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 (86) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  3. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  4. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  6. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  8. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  9. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  10. package/dist/clients/attach/attach-cmd.js +624 -624
  11. package/dist/clients/attach/canvas-panels.js +9 -3
  12. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  13. package/dist/clients/attach/graph-overlay.js +40 -7
  14. package/dist/clients/attach/input-controller.d.ts +9 -0
  15. package/dist/clients/attach/input-controller.js +47 -8
  16. package/dist/clients/attach/slash-commands.d.ts +11 -4
  17. package/dist/clients/attach/slash-commands.js +44 -9
  18. package/dist/clients/attach/transport-relay.d.ts +5 -5
  19. package/dist/clients/attach/transport-relay.js +3 -30
  20. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  21. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  22. package/dist/commands/canvas-browse.js +26 -1
  23. package/dist/commands/canvas-config.d.ts +2 -0
  24. package/dist/commands/canvas-config.js +210 -0
  25. package/dist/commands/canvas-use.d.ts +1 -0
  26. package/dist/commands/canvas-use.js +63 -0
  27. package/dist/commands/canvas.js +4 -2
  28. package/dist/commands/capture.d.ts +2 -0
  29. package/dist/commands/capture.js +28 -0
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  32. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  33. package/dist/core/__tests__/revive.test.js +70 -1
  34. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  35. package/dist/core/__tests__/secrets.test.js +55 -0
  36. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  38. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  40. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  42. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  44. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  46. package/dist/core/canvas/browse/app.d.ts +14 -0
  47. package/dist/core/canvas/browse/app.js +97 -15
  48. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  49. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  50. package/dist/core/canvas/browse/render.d.ts +4 -0
  51. package/dist/core/canvas/browse/render.js +3 -1
  52. package/dist/core/canvas/nav-model.d.ts +19 -10
  53. package/dist/core/canvas/nav-model.js +30 -12
  54. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  55. package/dist/core/canvas/remote-canvas-source.js +222 -0
  56. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  57. package/dist/core/canvas/remote-event-stream.js +94 -0
  58. package/dist/core/canvas/render.d.ts +13 -1
  59. package/dist/core/canvas/render.js +56 -37
  60. package/dist/core/canvas/source.d.ts +9 -0
  61. package/dist/core/canvas/source.js +15 -0
  62. package/dist/core/command.d.ts +15 -0
  63. package/dist/core/command.js +72 -0
  64. package/dist/core/config.js +4 -3
  65. package/dist/core/profiles/select.d.ts +4 -1
  66. package/dist/core/profiles/select.js +356 -81
  67. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  69. package/dist/core/runtime/nodes.js +10 -0
  70. package/dist/core/runtime/revive.js +176 -139
  71. package/dist/core/runtime/tmux.js +4 -1
  72. package/dist/core/secrets.d.ts +25 -0
  73. package/dist/core/secrets.js +55 -0
  74. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  75. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  76. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  77. package/dist/core/view/remote-canvas-target.js +12 -7
  78. package/dist/core/view/transport-local.js +12 -3
  79. package/dist/core/view/transport-remote.d.ts +2 -0
  80. package/dist/core/view/transport-remote.js +53 -0
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/dist/web-client/assets/{index-BvzxXXGU.js → index-CnF5r8ky.js} +18 -18
  84. package/dist/web-client/index.html +1 -1
  85. package/dist/web-client/sw.js +1 -1
  86. package/package.json +1 -1
@@ -0,0 +1,91 @@
1
+ // Run with: node --import tsx/esm --test src/core/runtime/__tests__/node-env.test.ts
2
+ //
3
+ // Regression lock for the 0.3.62 roll regression: `nodeEnv()` (`../nodes.ts`)
4
+ // is the ONLY place a broker child's deployment-layout path overrides
5
+ // (CRTR_SOCK_DIR, CRTR_CANVAS_DB — siblings of CRTR_HOME in paths.ts) get from
6
+ // ambient process.env into `inv.env`, the TRUSTED overlay `buildBrokerEnv`
7
+ // applies on top of the default-deny spawn-env allowlist (spawn-env.ts, which
8
+ // admits no ambient `CRTR_*`). Losing this propagation is exactly the 0.3.62
9
+ // bug: the broker boots healthy but binds view.sock at the wrong path on a
10
+ // Hearth guest (CRTR_SOCK_DIR=/run/crtr/socks, on tmpfs, separate from the
11
+ // durable-volume node dir), so nothing that watches the real socket ever sees
12
+ // it ready.
13
+ import { test, before, after, beforeEach, afterEach } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { nodeEnv } from '../nodes.js';
16
+ function node(over = {}) {
17
+ return {
18
+ node_id: 'n1',
19
+ name: 'n1',
20
+ created: new Date().toISOString(),
21
+ cwd: '/tmp/work',
22
+ kind: 'developer',
23
+ mode: 'base',
24
+ lifecycle: 'terminal',
25
+ status: 'active',
26
+ profile_id: null,
27
+ ...over,
28
+ };
29
+ }
30
+ const PATH_OVERRIDE_KEYS = ['CRTR_HOME', 'CRTR_SOCK_DIR', 'CRTR_CANVAS_DB', 'CRTR_SUBTREE'];
31
+ let saved;
32
+ before(() => {
33
+ saved = {};
34
+ for (const k of PATH_OVERRIDE_KEYS)
35
+ saved[k] = process.env[k];
36
+ });
37
+ beforeEach(() => {
38
+ for (const k of PATH_OVERRIDE_KEYS)
39
+ delete process.env[k];
40
+ });
41
+ afterEach(() => {
42
+ for (const k of PATH_OVERRIDE_KEYS)
43
+ delete process.env[k];
44
+ });
45
+ after(() => {
46
+ for (const k of PATH_OVERRIDE_KEYS) {
47
+ const v = saved[k];
48
+ if (v === undefined)
49
+ delete process.env[k];
50
+ else
51
+ process.env[k] = v;
52
+ }
53
+ });
54
+ test('CRTR_SOCK_DIR is propagated from ambient env into the broker child env', () => {
55
+ process.env.CRTR_SOCK_DIR = '/run/crtr/socks';
56
+ const env = nodeEnv(node());
57
+ assert.equal(env.CRTR_SOCK_DIR, '/run/crtr/socks');
58
+ });
59
+ test('CRTR_CANVAS_DB is propagated from ambient env into the broker child env (same latent-path class as CRTR_HOME)', () => {
60
+ process.env.CRTR_CANVAS_DB = '/mnt/shared/canvas.db';
61
+ const env = nodeEnv(node());
62
+ assert.equal(env.CRTR_CANVAS_DB, '/mnt/shared/canvas.db');
63
+ });
64
+ test('both path overrides propagate together, alongside the existing CRTR_HOME propagation', () => {
65
+ process.env.CRTR_HOME = '/mnt/canvas-home';
66
+ process.env.CRTR_SOCK_DIR = '/run/crtr/socks';
67
+ process.env.CRTR_CANVAS_DB = '/mnt/shared/canvas.db';
68
+ const env = nodeEnv(node());
69
+ assert.equal(env.CRTR_HOME, '/mnt/canvas-home');
70
+ assert.equal(env.CRTR_SOCK_DIR, '/run/crtr/socks');
71
+ assert.equal(env.CRTR_CANVAS_DB, '/mnt/shared/canvas.db');
72
+ });
73
+ test('unset ambient path overrides are omitted, never emitted as empty strings', () => {
74
+ // CRTR_SOCK_DIR/CRTR_CANVAS_DB/CRTR_HOME all deleted by beforeEach.
75
+ const env = nodeEnv(node());
76
+ assert.equal('CRTR_SOCK_DIR' in env, false);
77
+ assert.equal('CRTR_CANVAS_DB' in env, false);
78
+ assert.equal('CRTR_HOME' in env, false);
79
+ });
80
+ test('this is a narrow, named allowlist — an unrelated ambient CRTR_* var does NOT cross via nodeEnv', () => {
81
+ // Guards against "fix" drift toward spreading process.env wholesale: nodeEnv
82
+ // must keep naming each propagated key explicitly, not widen to any CRTR_*.
83
+ process.env.CRTR_SOME_UNRELATED_FLAG = 'leak-if-widened';
84
+ try {
85
+ const env = nodeEnv(node());
86
+ assert.equal('CRTR_SOME_UNRELATED_FLAG' in env, false);
87
+ }
88
+ finally {
89
+ delete process.env.CRTR_SOME_UNRELATED_FLAG;
90
+ }
91
+ });
@@ -76,6 +76,16 @@ export function nodeEnv(meta) {
76
76
  const home = process.env['CRTR_HOME'];
77
77
  if (home !== undefined && home !== '')
78
78
  env['CRTR_HOME'] = home;
79
+ // Propagate the deployment-layout path overrides (paths.ts siblings of
80
+ // CRTR_HOME) so the broker child binds its view.sock / opens canvas.db where
81
+ // every other process on this host looks. These are crtr-computed non-secret
82
+ // path config, same trust category as CRTR_HOME above — NOT a general
83
+ // CRTR_* allowlist weakening (spawn-env.ts's allowlist is untouched).
84
+ for (const key of ['CRTR_SOCK_DIR', 'CRTR_CANVAS_DB']) {
85
+ const v = process.env[key];
86
+ if (v !== undefined && v !== '')
87
+ env[key] = v;
88
+ }
79
89
  // Propagate the subtree-root node id. The broker uses it to group a subtree;
80
90
  // each launch site also emits the authoritative rootOfSpine(nodeId) over this
81
91
  // passthrough.
@@ -23,7 +23,7 @@
23
23
  //
24
24
  // reviveNode remains the ONLY sanctioned launcher of the node engine.
25
25
  import { existsSync } from 'node:fs';
26
- import { getNode, updateNode, clearPid, recordPid, fullName, cancelDeadlinesFor, subscribersOf, } from '../canvas/index.js';
26
+ import { getNode, updateNode, clearPid, recordPid, fullName, cancelDeadlinesFor, subscribersOf, openDb, } from '../canvas/index.js';
27
27
  import { transition } from './lifecycle.js';
28
28
  import { fanDoctrineWake } from './close.js';
29
29
  import { buildPiArgv } from './launch.js';
@@ -89,147 +89,184 @@ export function reviveNode(nodeId, opts) {
89
89
  // actually matters, so it's safe to call on every revive regardless of
90
90
  // entry point (daemon tick, or a direct/manual `node lifecycle revive`).
91
91
  reconcileBootLiveness();
92
- // Double-revive guard: identity-aware liveness on pi_pid, requiring
93
- // CONFIRMED alive not the fail-open-to-alive boolean adapter. A node
94
- // whose broker pid is CONFIRMED still running as that same broker (not a
95
- // reused pid) was already revived by another path — re-launching would put
96
- // a SECOND broker on the same session file. No-op. A bare isPidAlive would
97
- // misread a reused pid (heavy forking, esp. Linux) as "still alive" and
98
- // skip the relaunch forever; conversely, treating an INDETERMINATE read
99
- // (can't confirm either way, e.g. a transient `ps` probe failure) as
100
- // "alive" here would risk the opposite failure silently skipping a
101
- // needed relaunch and stranding the node with no engine. So this guard
102
- // blocks ONLY on a positively confirmed-alive read; `dead` AND
103
- // `indeterminate` both proceed to relaunch. We accept that tradeoff: an
104
- // `indeterminate` read CAN come from a genuinely live broker whose identity
105
- // `ps` probe transiently failed, so proceeding risks a rare double-launch —
106
- // but stranding a truly-dead node with no engine is the worse failure, and
107
- // recovering from it is far harder, so we bias toward relaunching.
108
- const live = getNode(nodeId) ?? meta;
109
- if (recordedPidLiveness(live.pi_pid, live.pi_pid_identity) === 'alive') {
110
- return {
111
- window: null,
112
- session: live.tmux_session ?? null,
113
- resumed: false,
114
- };
115
- }
116
- // Every (re)launch is a new cycle — bump the counter so CRTR_CYCLES and the
117
- // footer stay in sync. Mutate the in-memory meta too so buildPiArgv below
118
- // launches with the incremented count.
119
- meta.cycles = (meta.cycles ?? 0) + 1;
120
- updateNode(nodeId, { cycles: meta.cycles });
121
- // Decide whether to wake the saved pi conversation, cycle it in place (a
122
- // refresh-yield reusing the same `.jsonl`), or start fresh. resumeArgs
123
- // resumes/cycles ONLY by the absolute session-file path (cwd-immune) and ONLY
124
- // when that .jsonl actually exists; there is no bare-id fallback (the broker
125
- // preflight rejects a bare `--session`). No file on disk yet ⇒ a fresh launch.
126
- const resume = resumeArgs(meta, opts.resume);
127
- // hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
128
- // as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
129
- // the latter case). cycling and resuming are mutually exclusive; `resuming`
130
- // keeps the STRICT true-resume-only meaning `ReviveResult.resumed` and the
131
- // launch-placement hint below have always carried.
132
- const hasSessionPath = resume.resumeSessionPath !== undefined;
133
- const cycling = hasSessionPath && resume.newCycle === true;
134
- const resuming = hasSessionPath && !cycling;
135
- // Mirror THIS launch attempt's cycling-ness durably, BEFORE launching, so a
136
- // pre-session_start crash-retry (crtrd.ts) can tell "was the attempt I'm
137
- // retrying a cycle" apart from an ordinary resume/crash. Cleared by
138
- // canvas-stophook's session_start handler the instant this boot is confirmed.
139
- // Written unconditionally (true for cycling, false otherwise) so it never
140
- // goes stale across launch kinds.
141
- updateNode(nodeId, { cycle_pending: cycling });
142
- meta.cycle_pending = cycling;
143
- // A truly fresh launch — no `.jsonl` to resume OR cycle from — starts a NEW
144
- // pi session, so the old session identity is dead. Clear it from meta NOW,
145
- // before launch, so the recorded state matches what we're about to do. This
146
- // is load-bearing for boot-failure classification: if this fresh broker dies
147
- // BEFORE session_start (recording no new pid and no new session), the
148
- // daemon's pid-null branch keys on pi_session_id — a leftover stale id would
149
- // be misread as a "stranded relaunch" and retried forever instead of surfaced
150
- // via transition('crash') + surfaceBootFailure. Clearing it makes the failure
151
- // show as pid=NULL + session=NULL → boot failure, the correct terminal
152
- // outcome. A cycling refresh keeps BOTH ids — the `.jsonl` and its recorded
153
- // session id are exactly what's being reused — so a cycling broker that dies
154
- // pre-session_start reads as a "stranded relaunch" and is retried, same class
155
- // as a resume revive today.
156
- if (!hasSessionPath && (meta.pi_session_id != null || meta.pi_session_file != null)) {
157
- updateNode(nodeId, { pi_session_id: null, pi_session_file: null });
158
- meta.pi_session_id = null;
159
- meta.pi_session_file = null;
160
- }
161
- // A true resume needs no kickoff — the saved conversation just continues. A
162
- // fresh revive OR a cycling refresh-yield both get one so the (re-rooted)
163
- // window re-reads its roadmap and continues: drainBearings is the one-shot
164
- // consuming step (yield note + feed cursor + persona ack); the builder is
165
- // then pure. `resume` carries `resumeSessionPath`/`newCycle` through
166
- // unchanged (empty for true-fresh, both set for cycling) so buildPiArgv emits
167
- // the right argv either way.
168
- let inv;
169
- if (resuming) {
170
- inv = buildPiArgv(meta, resume);
171
- }
172
- else {
173
- // Fresh or cycling: drain the one-shot bearings, then build the kickoff.
174
- // When a scheduled bare self-alarm drove this revive, opts.wakeReason
175
- // carries the wake provenance so the kickoff leads with a <crtr-wake> block
176
- // ("a timer woke you"); every other reviveNode caller passes nothing → no
177
- // block.
178
- const bearings = drainBearings(meta);
179
- inv = buildPiArgv(meta, {
180
- ...resume,
181
- prompt: buildReviveKickoff(meta, bearings, opts.wakeReason),
182
- });
183
- // Fresh or cycling revive starts a NEW LLM context — reset the on-read doc
184
- // dedup so it surfaces docs from scratch (a true resume above instead KEEPS
185
- // the persisted set, continuing the same transcript).
186
- clearInjectedDocs(nodeId);
187
- }
188
- // The broker host launches the detached engine. reviveNode keeps
189
- // transition+clearPid around it (the crash-safety ordering, unchanged). It
190
- // opens NO viewer — engine-only; existing viewers reconnect, and a viewer-less
191
- // node is brought on screen by the next `focus`.
192
- transition(nodeId, 'revive');
193
- // Clear any active fault the instant this node is (re)launched — don't wait
194
- // for the fresh agent_start. A revive (auto or on-demand) is the recovery
195
- // this marker advertised.
196
- clearFault(nodeId);
197
- // Cancel-on-wake (design §6.4, AC-E1): every revive-for-any-reason (an inbox
198
- // event, a different wake, a manual focus) drops this node's pending deadline,
199
- // so the deadline always belongs to the dormancy being left. Writes only the
200
- // wakeups table, after the atomic transition above.
201
- cancelDeadlinesFor(nodeId);
202
- let launched;
92
+ // Cross-invocation revive lock (the duplicate-broker leak this fixes — live
93
+ // incident: repeated revive attempts against one node under socket thrash
94
+ // left MULTIPLE live `broker-cli` processes for the same node id, with
95
+ // canvas `pi_pid` overwritten and the older brokers untracked/leaked). The
96
+ // guard below reads `pi_pid` liveness and then, several statements later,
97
+ // launches + records a NEW pid a classic check-then-act race. Two
98
+ // `reviveNode` calls for the SAME node (typically two separate OS processes:
99
+ // a retried `crtr node lifecycle revive`/web-relay bridge call racing the
100
+ // daemon's own auto-revive tick, or a burst of reconnect retries) can both
101
+ // read the SAME stale not-alive pid before either one's `recordPid` commits,
102
+ // both launch a broker, and the second `recordPid` silently overwrites the
103
+ // first's pid leaking the first broker as an untracked live process
104
+ // forever. A `BEGIN IMMEDIATE` write transaction around the guard-check
105
+ // through `recordPid` serializes every `reviveNode` call sharing this
106
+ // canvas.db (same process or a concurrent one SQLite's write lock plus the
107
+ // WAL/`busy_timeout` already set on every connection, see db.ts): whichever
108
+ // call wins the lock finishes its guard check, launch, and `recordPid`
109
+ // BEFORE the next contender's guard check ever runs, so a second contender
110
+ // always sees the freshly-recorded ALIVE pid and no-ops instead of
111
+ // double-launching. Committed UNCONDITIONALLY in `finally` — never rolled
112
+ // back because every write inside (the cycle bump, the crash-path
113
+ // `transition('crash')`/`clearPid` on a launch failure, etc.) must persist
114
+ // exactly as it would with no transaction at all (each statement
115
+ // auto-committing today); this adds only the missing mutual exclusion, it
116
+ // changes no write's durability or the function's existing throw/return
117
+ // semantics.
118
+ const db = openDb();
119
+ db.exec('BEGIN IMMEDIATE');
203
120
  try {
204
- launched = headlessBrokerHost.launch(nodeId, inv, {
205
- cwd: meta.cwd,
206
- name: fullName(meta),
207
- resuming,
208
- });
209
- }
210
- catch (error) {
121
+ // Double-revive guard: identity-aware liveness on pi_pid, requiring
122
+ // CONFIRMED alive — not the fail-open-to-alive boolean adapter. A node
123
+ // whose broker pid is CONFIRMED still running as that same broker (not a
124
+ // reused pid) was already revived by another path — re-launching would put
125
+ // a SECOND broker on the same session file. No-op. A bare isPidAlive would
126
+ // misread a reused pid (heavy forking, esp. Linux) as "still alive" and
127
+ // skip the relaunch forever; conversely, treating an INDETERMINATE read
128
+ // (can't confirm either way, e.g. a transient `ps` probe failure) as
129
+ // "alive" here would risk the opposite failure — silently skipping a
130
+ // needed relaunch and stranding the node with no engine. So this guard
131
+ // blocks ONLY on a positively confirmed-alive read; `dead` AND
132
+ // `indeterminate` both proceed to relaunch. We accept that tradeoff: an
133
+ // `indeterminate` read CAN come from a genuinely live broker whose identity
134
+ // `ps` probe transiently failed, so proceeding risks a rare double-launch —
135
+ // but stranding a truly-dead node with no engine is the worse failure, and
136
+ // recovering from it is far harder, so we bias toward relaunching.
137
+ const live = getNode(nodeId) ?? meta;
138
+ if (recordedPidLiveness(live.pi_pid, live.pi_pid_identity) === 'alive') {
139
+ return {
140
+ window: null,
141
+ session: live.tmux_session ?? null,
142
+ resumed: false,
143
+ };
144
+ }
145
+ // Every (re)launch is a new cycle — bump the counter so CRTR_CYCLES and the
146
+ // footer stay in sync. Mutate the in-memory meta too so buildPiArgv below
147
+ // launches with the incremented count.
148
+ meta.cycles = (meta.cycles ?? 0) + 1;
149
+ updateNode(nodeId, { cycles: meta.cycles });
150
+ // Decide whether to wake the saved pi conversation, cycle it in place (a
151
+ // refresh-yield reusing the same `.jsonl`), or start fresh. resumeArgs
152
+ // resumes/cycles ONLY by the absolute session-file path (cwd-immune) and ONLY
153
+ // when that .jsonl actually exists; there is no bare-id fallback (the broker
154
+ // preflight rejects a bare `--session`). No file on disk yet ⇒ a fresh launch.
155
+ const resume = resumeArgs(meta, opts.resume);
156
+ // hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
157
+ // as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
158
+ // the latter case). cycling and resuming are mutually exclusive; `resuming`
159
+ // keeps the STRICT true-resume-only meaning `ReviveResult.resumed` and the
160
+ // launch-placement hint below have always carried.
161
+ const hasSessionPath = resume.resumeSessionPath !== undefined;
162
+ const cycling = hasSessionPath && resume.newCycle === true;
163
+ const resuming = hasSessionPath && !cycling;
164
+ // Mirror THIS launch attempt's cycling-ness durably, BEFORE launching, so a
165
+ // pre-session_start crash-retry (crtrd.ts) can tell "was the attempt I'm
166
+ // retrying a cycle" apart from an ordinary resume/crash. Cleared by
167
+ // canvas-stophook's session_start handler the instant this boot is confirmed.
168
+ // Written unconditionally (true for cycling, false otherwise) so it never
169
+ // goes stale across launch kinds.
170
+ updateNode(nodeId, { cycle_pending: cycling });
171
+ meta.cycle_pending = cycling;
172
+ // A truly fresh launch — no `.jsonl` to resume OR cycle from — starts a NEW
173
+ // pi session, so the old session identity is dead. Clear it from meta NOW,
174
+ // before launch, so the recorded state matches what we're about to do. This
175
+ // is load-bearing for boot-failure classification: if this fresh broker dies
176
+ // BEFORE session_start (recording no new pid and no new session), the
177
+ // daemon's pid-null branch keys on pi_session_id — a leftover stale id would
178
+ // be misread as a "stranded relaunch" and retried forever instead of surfaced
179
+ // via transition('crash') + surfaceBootFailure. Clearing it makes the failure
180
+ // show as pid=NULL + session=NULL → boot failure, the correct terminal
181
+ // outcome. A cycling refresh keeps BOTH ids — the `.jsonl` and its recorded
182
+ // session id are exactly what's being reused — so a cycling broker that dies
183
+ // pre-session_start reads as a "stranded relaunch" and is retried, same class
184
+ // as a resume revive today.
185
+ if (!hasSessionPath && (meta.pi_session_id != null || meta.pi_session_file != null)) {
186
+ updateNode(nodeId, { pi_session_id: null, pi_session_file: null });
187
+ meta.pi_session_id = null;
188
+ meta.pi_session_file = null;
189
+ }
190
+ // A true resume needs no kickoff — the saved conversation just continues. A
191
+ // fresh revive OR a cycling refresh-yield both get one so the (re-rooted)
192
+ // window re-reads its roadmap and continues: drainBearings is the one-shot
193
+ // consuming step (yield note + feed cursor + persona ack); the builder is
194
+ // then pure. `resume` carries `resumeSessionPath`/`newCycle` through
195
+ // unchanged (empty for true-fresh, both set for cycling) so buildPiArgv emits
196
+ // the right argv either way.
197
+ let inv;
198
+ if (resuming) {
199
+ inv = buildPiArgv(meta, resume);
200
+ }
201
+ else {
202
+ // Fresh or cycling: drain the one-shot bearings, then build the kickoff.
203
+ // When a scheduled bare self-alarm drove this revive, opts.wakeReason
204
+ // carries the wake provenance so the kickoff leads with a <crtr-wake> block
205
+ // ("a timer woke you"); every other reviveNode caller passes nothing → no
206
+ // block.
207
+ const bearings = drainBearings(meta);
208
+ inv = buildPiArgv(meta, {
209
+ ...resume,
210
+ prompt: buildReviveKickoff(meta, bearings, opts.wakeReason),
211
+ });
212
+ // Fresh or cycling revive starts a NEW LLM context — reset the on-read doc
213
+ // dedup so it surfaces docs from scratch (a true resume above instead KEEPS
214
+ // the persisted set, continuing the same transcript).
215
+ clearInjectedDocs(nodeId);
216
+ }
217
+ // The broker host launches the detached engine. reviveNode keeps
218
+ // transition+clearPid around it (the crash-safety ordering, unchanged). It
219
+ // opens NO viewer — engine-only; existing viewers reconnect, and a viewer-less
220
+ // node is brought on screen by the next `focus`.
221
+ transition(nodeId, 'revive');
222
+ // Clear any active fault the instant this node is (re)launched — don't wait
223
+ // for the fresh agent_start. A revive (auto or on-demand) is the recovery
224
+ // this marker advertised.
225
+ clearFault(nodeId);
226
+ // Cancel-on-wake (design §6.4, AC-E1): every revive-for-any-reason (an inbox
227
+ // event, a different wake, a manual focus) drops this node's pending deadline,
228
+ // so the deadline always belongs to the dormancy being left. Writes only the
229
+ // wakeups table, after the atomic transition above.
230
+ cancelDeadlinesFor(nodeId);
231
+ let launched;
211
232
  try {
212
- transition(nodeId, 'crash');
213
- // Doctrine wake: a launch-refusal here marks the node dead with NOTHING
214
- // else fanning that out — the daemon's own auto-revive is the most common
215
- // caller (superviseTick, best-effort try/catch, stderr-only on failure), so
216
- // without this a manager waiting dormant on this child hangs forever,
217
- // never told it will not come back on its own. Mirrors closeNode's step-4
218
- // fan-out (same subscribers table, same active/passive split).
219
- fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead: ${error.message}\n\n` +
220
- `It stays dead until you revive it — \`crtr node lifecycle revive ${nodeId}\`.`, { reason: 'child-crashed', child: nodeId });
233
+ launched = headlessBrokerHost.launch(nodeId, inv, {
234
+ cwd: meta.cwd,
235
+ name: fullName(meta),
236
+ resuming,
237
+ });
221
238
  }
222
- catch {
223
- /* best-effort cleanup */
239
+ catch (error) {
240
+ try {
241
+ transition(nodeId, 'crash');
242
+ // Doctrine wake: a launch-refusal here marks the node dead with NOTHING
243
+ // else fanning that out — the daemon's own auto-revive is the most common
244
+ // caller (superviseTick, best-effort try/catch, stderr-only on failure), so
245
+ // without this a manager waiting dormant on this child hangs forever,
246
+ // never told it will not come back on its own. Mirrors closeNode's step-4
247
+ // fan-out (same subscribers table, same active/passive split).
248
+ fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead: ${error.message}\n\n` +
249
+ `It stays dead until you revive it — \`crtr node lifecycle revive ${nodeId}\`.`, { reason: 'child-crashed', child: nodeId });
250
+ }
251
+ catch {
252
+ /* best-effort cleanup */
253
+ }
254
+ clearPid(nodeId);
255
+ throw new Error(`[broker] refusing to revive ${nodeId}: ${error.message}`);
224
256
  }
225
- clearPid(nodeId);
226
- throw new Error(`[broker] refusing to revive ${nodeId}: ${error.message}`);
257
+ // Record the real broker pid immediately, still inside the lock — this is
258
+ // the write that makes the guard's next reader (a contender unblocked from
259
+ // its own BEGIN IMMEDIATE the instant this transaction commits) see an
260
+ // ALIVE pid instead of racing past the same stale one this call just
261
+ // relaunched from. session_start will re-record the same pid after
262
+ // extension bind, but the daemon must supervise the process during the
263
+ // cold-boot window too; otherwise a slow-but-alive broker reads as
264
+ // pid=null+session=null and can be falsely crashed by the boot-grace path.
265
+ if (launched.pid !== null)
266
+ recordPid(nodeId, launched.pid);
267
+ return { window: null, session: meta.tmux_session ?? null, resumed: resuming, launch: launched };
268
+ }
269
+ finally {
270
+ db.exec('COMMIT');
227
271
  }
228
- // Record the real broker pid immediately. session_start will re-record the
229
- // same pid after extension bind, but the daemon must supervise the process
230
- // during the cold-boot window too; otherwise a slow-but-alive broker reads as
231
- // pid=null+session=null and can be falsely crashed by the boot-grace path.
232
- if (launched.pid !== null)
233
- recordPid(nodeId, launched.pid);
234
- return { window: null, session: meta.tmux_session ?? null, resumed: resuming, launch: launched };
235
272
  }
@@ -67,7 +67,10 @@ function ensureBracketedPaste() {
67
67
  const current = tmux(['show-options', '-s', 'terminal-overrides']).stdout;
68
68
  if (current.includes('Enbp='))
69
69
  return;
70
- tmux(['set-option', '-sga', 'terminal-overrides', '*:Enbp=\E[?2004h:Dsbp=\E[?2004l']);
70
+ // Emit a LITERAL backslash-E (`\\E`) so tmux's option parser resolves it to ESC.
71
+ // A single `\E` in this JS source collapses to the letter `E`, which stores a
72
+ // broken `Enbp=E[?2004h` (no ESC) and silently disables bracketed paste.
73
+ tmux(['set-option', '-sga', 'terminal-overrides', '*:Enbp=\\E[?2004h:Dsbp=\\E[?2004l']);
71
74
  }
72
75
  /** Create a detached session rooted at `cwd` if it doesn't exist. The session
73
76
  * name is a root's tmux home; every node under that root is a window in it. */
@@ -0,0 +1,25 @@
1
+ import type { Scope } from '../types.js';
2
+ /** The 0600 secrets store: raw relay/bearer tokens for `remoteCanvas.targets`,
3
+ * kept OUT of `config.json` (which is not permission-hardened and is treated
4
+ * as shareable/inspectable) and never logged. `crtr canvas config add
5
+ * --relay-token -` is the ONLY accepted form — reads the raw token from
6
+ * stdin; any other value is rejected with a generic error (never echoing
7
+ * what was received). Keyed by `RemoteCanvasTarget.relayTokenRef`, never by
8
+ * the target name directly, so a rename doesn't orphan the secret. */
9
+ export interface SecretsStore {
10
+ relayTokens: Record<string, string>;
11
+ }
12
+ export declare function readSecrets(scope: Scope): SecretsStore;
13
+ /** Writes secrets.json directly (never via `fs-utils.writeJson`, which has no
14
+ * mode control) so the file is created 0600 from the first byte, then
15
+ * `chmodSync`s belt-and-suspenders in case the file pre-existed with looser
16
+ * permissions (e.g. hand-copied from another machine). */
17
+ export declare function writeSecrets(scope: Scope, store: SecretsStore): void;
18
+ export declare function getRelayToken(ref: string, scope: Scope): string | undefined;
19
+ export declare function setRelayToken(ref: string, token: string, scope: Scope): void;
20
+ export declare function deleteRelayToken(ref: string, scope: Scope): void;
21
+ /** A short, non-reversible display fingerprint of a raw token — safe to print
22
+ * in `canvas config list`/`show` output. NEVER derive this from anything
23
+ * that could round-trip back to the token, and never log/return the raw
24
+ * token itself alongside it. */
25
+ export declare function fingerprint(token: string): string;
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { scopeRoot, requireScopeRoot } from './scope.js';
5
+ import { ensureDir } from './fs-utils.js';
6
+ const SECRETS_FILE = 'secrets.json';
7
+ function secretsPathFor(root) {
8
+ return join(root, SECRETS_FILE);
9
+ }
10
+ export function readSecrets(scope) {
11
+ const root = scopeRoot(scope);
12
+ if (!root)
13
+ return { relayTokens: {} };
14
+ const p = secretsPathFor(root);
15
+ if (!existsSync(p))
16
+ return { relayTokens: {} };
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
19
+ return { relayTokens: parsed.relayTokens ?? {} };
20
+ }
21
+ catch {
22
+ return { relayTokens: {} };
23
+ }
24
+ }
25
+ /** Writes secrets.json directly (never via `fs-utils.writeJson`, which has no
26
+ * mode control) so the file is created 0600 from the first byte, then
27
+ * `chmodSync`s belt-and-suspenders in case the file pre-existed with looser
28
+ * permissions (e.g. hand-copied from another machine). */
29
+ export function writeSecrets(scope, store) {
30
+ const root = requireScopeRoot(scope);
31
+ ensureDir(root);
32
+ const p = secretsPathFor(root);
33
+ writeFileSync(p, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
34
+ chmodSync(p, 0o600);
35
+ }
36
+ export function getRelayToken(ref, scope) {
37
+ return readSecrets(scope).relayTokens[ref];
38
+ }
39
+ export function setRelayToken(ref, token, scope) {
40
+ const s = readSecrets(scope);
41
+ s.relayTokens[ref] = token;
42
+ writeSecrets(scope, s);
43
+ }
44
+ export function deleteRelayToken(ref, scope) {
45
+ const s = readSecrets(scope);
46
+ delete s.relayTokens[ref];
47
+ writeSecrets(scope, s);
48
+ }
49
+ /** A short, non-reversible display fingerprint of a raw token — safe to print
50
+ * in `canvas config list`/`show` output. NEVER derive this from anything
51
+ * that could round-trip back to the token, and never log/return the raw
52
+ * token itself alongside it. */
53
+ export function fingerprint(token) {
54
+ return createHash('sha256').update(token).digest('hex').slice(0, 12);
55
+ }
@@ -0,0 +1,95 @@
1
+ // transport-remote.test.ts — `createRemoteExec` (`../transport-remote.ts`), the
2
+ // Node-side POST transport `RemoteCanvasSource` shells every remote read
3
+ // through. Covers the one thing a bare `fetch(...)` with no
4
+ // AbortController/timeout doesn't give you for free: a hung endpoint fails
5
+ // CLOSED within a bound instead of wedging the caller forever. WITHOUT the
6
+ // timeout, the "stalled endpoint" test below would hang past its own
7
+ // assertion deadline (the promise the transport returns never settles).
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { createServer } from 'node:http';
11
+ import { createRemoteExec } from '../transport-remote.js';
12
+ function startStalledServer() {
13
+ return new Promise((resolve) => {
14
+ // Accept the connection but never write a response — the request just
15
+ // hangs, exactly what a wedged in-guest `/__crtr/source` handler looks
16
+ // like from the caller's side.
17
+ const server = createServer(() => { });
18
+ server.listen(0, '127.0.0.1', () => {
19
+ const addr = server.address();
20
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
21
+ resolve({
22
+ url: `http://127.0.0.1:${port}`,
23
+ close: () => new Promise((res) => server.close(() => res())),
24
+ });
25
+ });
26
+ });
27
+ }
28
+ test('a stalled endpoint returns a timeout failure within the configured bound instead of hanging forever', async () => {
29
+ const server = await startStalledServer();
30
+ const exec = createRemoteExec(server.url, 'tok', 100); // small timeout for the test
31
+ const started = Date.now();
32
+ // Race against a generous outer deadline: if the transport doesn't
33
+ // self-bound, this whole test times out instead of asserting cleanly —
34
+ // still proves the point, but assert explicitly for a clear failure message.
35
+ const result = await Promise.race([
36
+ exec('crtr', ['--json', 'node', 'inspect', 'list']),
37
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error('transport did not resolve within the outer deadline — no timeout is wired')), 2000)),
38
+ ]);
39
+ const elapsed = Date.now() - started;
40
+ assert.equal(result.ok, false);
41
+ assert.match(result.stderr, /timeout/i);
42
+ assert.ok(elapsed < 1000, `expected the timeout to fire near the configured 100ms bound, took ${elapsed}ms`);
43
+ await server.close();
44
+ });
45
+ test('a normal fast response is unaffected by the timeout wiring', async () => {
46
+ const server = createServer((req, res) => {
47
+ const chunks = [];
48
+ req.on('data', (c) => chunks.push(c));
49
+ req.on('end', () => {
50
+ res.writeHead(200, { 'content-type': 'application/json' });
51
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
52
+ });
53
+ });
54
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
55
+ const addr = server.address();
56
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
57
+ const exec = createRemoteExec(`http://127.0.0.1:${port}`, 'tok', 5000);
58
+ const result = await exec('crtr', ['--json', 'node', 'inspect', 'list']);
59
+ assert.equal(result.ok, true);
60
+ assert.equal(result.stdout, JSON.stringify({ nodes: [] }));
61
+ await new Promise((r) => server.close(() => r()));
62
+ });
63
+ // --- Phase 3 review Minor 1: no token bytes may ever reach returned stderr ---
64
+ test('an unreachable endpoint returns a flat generic network-error message, never the raw exception text', async () => {
65
+ const exec = createRemoteExec('http://127.0.0.1:1', 'valid-token-123');
66
+ const result = await exec('crtr', ['--json', 'node', 'inspect', 'list']);
67
+ assert.equal(result.ok, false);
68
+ assert.equal(result.stderr, 'remote source: network error');
69
+ });
70
+ test('a malformed token (contains a newline) is rejected up front with a generic message, never reaching fetch/Headers', async () => {
71
+ const exec = createRemoteExec('http://127.0.0.1:1', 'bad\ntoken');
72
+ const result = await exec('crtr', ['--json', 'node', 'inspect', 'list']);
73
+ assert.equal(result.ok, false);
74
+ assert.equal(result.stderr, 'remote source: invalid stored relay token');
75
+ assert.ok(!result.stderr.includes('bad\ntoken'));
76
+ assert.ok(!result.stderr.includes('bad'));
77
+ });
78
+ test('a valid token containing typical bearer-token punctuation (-, _, .) still round-trips against a real server', async () => {
79
+ const server = createServer((req, res) => {
80
+ const chunks = [];
81
+ req.on('data', (c) => chunks.push(c));
82
+ req.on('end', () => {
83
+ res.writeHead(200, { 'content-type': 'application/json' });
84
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
85
+ });
86
+ });
87
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
88
+ const addr = server.address();
89
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
90
+ const exec = createRemoteExec(`http://127.0.0.1:${port}`, 'a1b2c3-real-token_ABC.789', 5000);
91
+ const result = await exec('crtr', ['--json', 'node', 'inspect', 'list']);
92
+ assert.equal(result.ok, true);
93
+ assert.equal(result.stdout, JSON.stringify({ nodes: [] }));
94
+ await new Promise((r) => server.close(() => r()));
95
+ });