@crouton-kit/crouter 0.3.65 → 0.3.67

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 (83) hide show
  1. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  2. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +24 -12
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +41 -16
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  8. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  9. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  10. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  11. package/dist/clients/attach/attach-cmd.js +632 -632
  12. package/dist/clients/attach/canvas-panels.js +9 -3
  13. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  14. package/dist/clients/attach/graph-overlay.js +65 -11
  15. package/dist/clients/attach/input-controller.d.ts +9 -0
  16. package/dist/clients/attach/input-controller.js +47 -8
  17. package/dist/clients/attach/slash-commands.d.ts +11 -4
  18. package/dist/clients/attach/slash-commands.js +44 -9
  19. package/dist/clients/attach/transport-relay.d.ts +5 -5
  20. package/dist/clients/attach/transport-relay.js +3 -30
  21. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  22. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  23. package/dist/commands/canvas-browse.js +26 -1
  24. package/dist/commands/canvas-config.d.ts +2 -0
  25. package/dist/commands/canvas-config.js +210 -0
  26. package/dist/commands/canvas-use.d.ts +1 -0
  27. package/dist/commands/canvas-use.js +63 -0
  28. package/dist/commands/canvas.js +4 -2
  29. package/dist/commands/node-lifecycle-revive.js +9 -2
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/on-read-crouter-home-fence.test.d.ts +1 -0
  32. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +67 -0
  33. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  34. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  35. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  36. package/dist/core/__tests__/secrets.test.js +55 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  38. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  40. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  42. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  44. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  46. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  47. package/dist/core/canvas/browse/app.d.ts +14 -0
  48. package/dist/core/canvas/browse/app.js +97 -15
  49. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  50. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  51. package/dist/core/canvas/browse/render.d.ts +4 -0
  52. package/dist/core/canvas/browse/render.js +3 -1
  53. package/dist/core/canvas/nav-model.d.ts +19 -10
  54. package/dist/core/canvas/nav-model.js +30 -12
  55. package/dist/core/canvas/pid.d.ts +71 -13
  56. package/dist/core/canvas/pid.js +73 -29
  57. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  58. package/dist/core/canvas/remote-canvas-source.js +222 -0
  59. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  60. package/dist/core/canvas/remote-event-stream.js +94 -0
  61. package/dist/core/canvas/render.d.ts +13 -1
  62. package/dist/core/canvas/render.js +56 -37
  63. package/dist/core/canvas/source.d.ts +9 -0
  64. package/dist/core/canvas/source.js +15 -0
  65. package/dist/core/config.js +4 -3
  66. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  67. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  68. package/dist/core/runtime/nodes.js +10 -0
  69. package/dist/core/runtime/revive-all.js +8 -2
  70. package/dist/core/runtime/revive.js +18 -8
  71. package/dist/core/secrets.d.ts +25 -0
  72. package/dist/core/secrets.js +55 -0
  73. package/dist/core/substrate/on-read.js +13 -2
  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-remote.d.ts +2 -0
  79. package/dist/core/view/transport-remote.js +53 -0
  80. package/dist/daemon/crtrd.js +18 -8
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/package.json +1 -1
@@ -15,6 +15,7 @@
15
15
  import { existsSync, readFileSync, statSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { getNode, listNodes, subscriptionsOf, view } from './canvas.js';
18
+ import { RemoteCanvasSource } from './remote-canvas-source.js';
18
19
  import { fullName } from './labels.js';
19
20
  import { jobDir, contextDir } from './paths.js';
20
21
  import { countAsks, asksForNodes } from './attention.js';
@@ -174,25 +175,24 @@ export function renderForest() {
174
175
  }
175
176
  return parts.join('\n\n');
176
177
  }
177
- async function nodeLineFromSource(source, nodeId, indent, connector) {
178
+ async function nodeLineFromSource(source, nodeId, indent, connector, remote) {
178
179
  const node = await source.getNode(nodeId);
179
180
  if (node === null)
180
181
  return `${indent}${connector}? <missing meta: ${nodeId}>`;
181
- const hanging = hangingFor(node);
182
+ const hanging = remote ? null : hangingFor(node);
182
183
  const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
183
- const tel = readNodeTelemetry(nodeId);
184
- const ctx = fmtCtx(tel.tokens_in);
185
- const asks = countAsks(nodeId);
184
+ const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(nodeId).tokens_in);
185
+ const asks = remote ? 0 : countAsks(nodeId);
186
186
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
187
187
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
188
188
  return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
189
189
  }
190
- async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out) {
190
+ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out, remote) {
191
191
  if (visited.has(nodeId))
192
192
  return;
193
193
  visited.add(nodeId);
194
194
  const connector = isLast ? '└─ ' : '├─ ';
195
- out.push(await nodeLineFromSource(source, nodeId, indent, connector));
195
+ out.push(await nodeLineFromSource(source, nodeId, indent, connector, remote));
196
196
  const children = await source.subscriptionsOf(nodeId).catch(() => []);
197
197
  const childIndent = indent + (isLast ? ' ' : '│ ');
198
198
  for (let i = 0; i < children.length; i++) {
@@ -203,18 +203,18 @@ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out)
203
203
  out.push(`${childIndent}${cycleConnector}↺ <cycle: ${child.node_id}>`);
204
204
  continue;
205
205
  }
206
- await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out);
206
+ await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out, remote);
207
207
  }
208
208
  }
209
209
  export async function renderTreeFromSource(source, rootId) {
210
+ const remote = source instanceof RemoteCanvasSource;
210
211
  const node = await source.getNode(rootId);
211
212
  if (node === null)
212
213
  return `? <missing node: ${rootId}>`;
213
- const tel = readNodeTelemetry(rootId);
214
- const ctx = fmtCtx(tel.tokens_in);
215
- const asks = countAsks(rootId);
214
+ const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(rootId).tokens_in);
215
+ const asks = remote ? 0 : countAsks(rootId);
216
216
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
217
- const hanging = hangingFor(node);
217
+ const hanging = remote ? null : hangingFor(node);
218
218
  const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
219
219
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
220
220
  const out = [];
@@ -224,7 +224,7 @@ export async function renderTreeFromSource(source, rootId) {
224
224
  for (let i = 0; i < children.length; i++) {
225
225
  const child = children[i];
226
226
  const isLast = i === children.length - 1;
227
- await walkTreeFromSource(source, child.node_id, '', isLast, visited, out);
227
+ await walkTreeFromSource(source, child.node_id, '', isLast, visited, out, remote);
228
228
  }
229
229
  return out.join('\n');
230
230
  }
@@ -468,14 +468,16 @@ export function dashboardRows(rootId) {
468
468
  * a deterministic canvas-home path, so it tracks last-message recency without a meta
469
469
  * read. Falls back to `created`'s epoch when a node never ran a turn (no telemetry).
470
470
  * Never throws. */
471
- function sessionMtime(nodeId, created) {
472
- try {
473
- const st = statSync(join(jobDir(nodeId), 'telemetry.json'), { throwIfNoEntry: false });
474
- if (st !== undefined)
475
- return st.mtimeMs;
476
- }
477
- catch {
478
- /* fall through to created */
471
+ function sessionMtime(nodeId, created, remote = false) {
472
+ if (!remote) {
473
+ try {
474
+ const st = statSync(join(jobDir(nodeId), 'telemetry.json'), { throwIfNoEntry: false });
475
+ if (st !== undefined)
476
+ return st.mtimeMs;
477
+ }
478
+ catch {
479
+ /* fall through to created */
480
+ }
479
481
  }
480
482
  const t = Date.parse(created);
481
483
  return Number.isNaN(t) ? 0 : t;
@@ -500,11 +502,11 @@ function sessionMtime(nodeId, created) {
500
502
  */
501
503
  export function dashboardRowsAll() {
502
504
  const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
503
- return listNodes().map((row) => dashboardRowFromNodeRow(row, focusedNodeIds));
505
+ return listNodes().map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, false));
504
506
  }
505
- function dashboardRowFromNodeRow(row, focusedNodeIds) {
507
+ function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
506
508
  const live = row.status === 'active' || row.status === 'idle';
507
- const hanging = activeFaultForDisplay(row, readFault(row.node_id));
509
+ const hanging = remote ? null : activeFaultForDisplay(row, readFault(row.node_id));
508
510
  return {
509
511
  node_id: row.node_id,
510
512
  name: row.name, // handle only; enrichRow upgrades to fullName (meta.description)
@@ -516,15 +518,20 @@ function dashboardRowFromNodeRow(row, focusedNodeIds) {
516
518
  cwd: row.cwd,
517
519
  created: row.created,
518
520
  lifecycle: row.lifecycle,
519
- mtimeMs: sessionMtime(row.node_id, row.created),
520
- streaming: hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
521
+ mtimeMs: sessionMtime(row.node_id, row.created, remote),
522
+ streaming: !remote && hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
521
523
  hanging,
522
- viewed: focusedNodeIds.has(row.node_id),
524
+ viewed: remote ? false : focusedNodeIds.has(row.node_id),
523
525
  };
524
526
  }
527
+ /** A remote source provides only graph structure (the 8 CanvasSource methods);
528
+ * every other per-node field below is a local disk/SQLite read and is suppressed
529
+ * to a safe default for a remote source — `listFocuses()` (SQLite) is skipped
530
+ * entirely rather than queried and discarded. */
525
531
  export async function dashboardRowsAllFromSource(source) {
526
- const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
527
- return (await source.listNodes()).map((row) => dashboardRowFromNodeRow(row, focusedNodeIds));
532
+ const remote = source instanceof RemoteCanvasSource;
533
+ const focusedNodeIds = remote ? new Set() : new Set(listFocuses().map((f) => f.node_id));
534
+ return (await source.listNodes()).map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, remote));
528
535
  }
529
536
  // ---------------------------------------------------------------------------
530
537
  // Lazy enrichment API — fold the expensive per-node reads into rows on demand,
@@ -565,16 +572,23 @@ export function enrichRows(rows) {
565
572
  row.enriched = true;
566
573
  }
567
574
  }
568
- export async function enrichRowsFromSource(source, rows) {
575
+ /** Telemetry (ctx tokens) is a local disk read, so it is suppressed to 0 for a
576
+ * remote source. Ask counts are suppressed to 0 UNLESS the caller supplies
577
+ * `remoteAsks` — a pre-fetched `askCountsForView` map (one call per forest
578
+ * root; the caller's job, since the right root set is a browse/snapshot
579
+ * concern, not this per-row enrichment loop's). See `runBrowse`'s
580
+ * `remoteAskCounts`, fetched once per snapshot rebuild, not per flush. */
581
+ export async function enrichRowsFromSource(source, rows, remoteAsks) {
569
582
  const todo = rows.filter((r) => r.enriched !== true);
570
583
  if (todo.length === 0)
571
584
  return;
572
- const asks = asksForNodes(todo.map((r) => r.node_id));
585
+ const remote = source instanceof RemoteCanvasSource;
586
+ const asks = remote ? (remoteAsks ?? {}) : asksForNodes(todo.map((r) => r.node_id));
573
587
  await Promise.all(todo.map(async (row) => {
574
588
  const meta = await source.getNode(row.node_id);
575
589
  if (meta !== null)
576
590
  row.name = fullName(meta);
577
- row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
591
+ row.ctx_tokens = remote ? 0 : (readNodeTelemetry(row.node_id).tokens_in ?? 0);
578
592
  row.asks = asks[row.node_id] ?? 0;
579
593
  row.enriched = true;
580
594
  }));
@@ -595,14 +609,19 @@ export function loadPreview(row) {
595
609
  row.previewLoaded = true;
596
610
  return row;
597
611
  }
612
+ /** goal (initial-prompt.md) and session parts are local disk reads — suppressed
613
+ * to undefined for a remote source. */
598
614
  export async function loadPreviewFromSource(source, row) {
599
615
  if (row.previewLoaded === true)
600
616
  return row;
601
- row.goal = readGoalText(row.node_id);
602
- const meta = await source.getNode(row.node_id);
603
- const { prompts, lastAssistant } = readSessionParts(meta?.pi_session_file);
604
- row.prompts = prompts;
605
- row.lastAssistant = lastAssistant;
617
+ const remote = source instanceof RemoteCanvasSource;
618
+ row.goal = remote ? undefined : readGoalText(row.node_id);
619
+ if (!remote) {
620
+ const meta = await source.getNode(row.node_id);
621
+ const { prompts, lastAssistant } = readSessionParts(meta?.pi_session_file);
622
+ row.prompts = prompts;
623
+ row.lastAssistant = lastAssistant;
624
+ }
606
625
  row.previewLoaded = true;
607
626
  return row;
608
627
  }
@@ -1,4 +1,5 @@
1
1
  import type { NodeMeta, NodeRow, NodeStatus, SubscriptionRef } from './types.js';
2
+ import type { Scope } from '../../types.js';
2
3
  export interface CanvasSource {
3
4
  getNode(nodeId: string): Promise<NodeMeta | null>;
4
5
  getRow(nodeId: string): Promise<NodeRow | null>;
@@ -24,3 +25,11 @@ export declare class LocalCanvasSource implements CanvasSource {
24
25
  hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
25
26
  }
26
27
  export declare const localCanvasSource: CanvasSource;
28
+ /** The active read source for a command: an explicit `--canvas <name>` wins,
29
+ * else the durable `crtr canvas use` selection (`ScopeState.activeCanvas`),
30
+ * else local — byte-for-byte the same `localCanvasSource` singleton so a
31
+ * caller with neither set sees zero behavior change. */
32
+ export declare function resolveCanvasSource(opts?: {
33
+ canvasName?: string;
34
+ scope?: Scope;
35
+ }): CanvasSource;
@@ -1,5 +1,8 @@
1
1
  import { getNode, getRow, listNodes, subscriptionsOf, subscribersOf, view, hasActiveLiveSubscription, } from './canvas.js';
2
2
  import { asksForNodes } from './attention.js';
3
+ import { readState } from '../config.js';
4
+ import { resolveRemoteCanvasTarget } from '../view/remote-canvas-target.js';
5
+ import { RemoteCanvasSource } from './remote-canvas-source.js';
3
6
  export class LocalCanvasSource {
4
7
  async getNode(nodeId) {
5
8
  return getNode(nodeId);
@@ -27,3 +30,15 @@ export class LocalCanvasSource {
27
30
  }
28
31
  }
29
32
  export const localCanvasSource = new LocalCanvasSource();
33
+ /** The active read source for a command: an explicit `--canvas <name>` wins,
34
+ * else the durable `crtr canvas use` selection (`ScopeState.activeCanvas`),
35
+ * else local — byte-for-byte the same `localCanvasSource` singleton so a
36
+ * caller with neither set sees zero behavior change. */
37
+ export function resolveCanvasSource(opts) {
38
+ const scope = opts?.scope ?? 'user';
39
+ const name = opts?.canvasName ?? readState(scope).activeCanvas ?? null;
40
+ if (!name)
41
+ return localCanvasSource;
42
+ const { previewEndpoint, relayToken } = resolveRemoteCanvasTarget(name, scope);
43
+ return new RemoteCanvasSource(previewEndpoint, relayToken);
44
+ }
@@ -38,6 +38,7 @@ export function readState(scope) {
38
38
  plugins: existing.plugins ?? {},
39
39
  last_self_check: existing.last_self_check,
40
40
  bootstrap_done: existing.bootstrap_done,
41
+ activeCanvas: existing.activeCanvas ?? null,
41
42
  };
42
43
  }
43
44
  export function writeConfig(scope, config) {
@@ -245,7 +246,7 @@ function mergeKinds(raw, base = defaultKindsConfig()) {
245
246
  }
246
247
  /** Validate one raw `remoteCanvas.targets.<name>` entry, or drop it (return
247
248
  * null) rather than throwing — same rule `normalizeKindEntry` follows. A
248
- * valid entry needs a non-empty `previewEndpoint` and `relayTokenEnv`; the
249
+ * valid entry needs a non-empty `previewEndpoint` and `relayTokenRef`; the
249
250
  * token itself is never stored here (see `RemoteCanvasTarget`). */
250
251
  function normalizeRemoteCanvasTarget(raw) {
251
252
  if (raw === null || typeof raw !== 'object')
@@ -253,9 +254,9 @@ function normalizeRemoteCanvasTarget(raw) {
253
254
  const r = raw;
254
255
  if (typeof r.previewEndpoint !== 'string' || r.previewEndpoint.trim() === '')
255
256
  return null;
256
- if (typeof r.relayTokenEnv !== 'string' || r.relayTokenEnv.trim() === '')
257
+ if (typeof r.relayTokenRef !== 'string' || r.relayTokenRef.trim() === '')
257
258
  return null;
258
- const out = { previewEndpoint: r.previewEndpoint, relayTokenEnv: r.relayTokenEnv };
259
+ const out = { previewEndpoint: r.previewEndpoint, relayTokenRef: r.relayTokenRef };
259
260
  if (typeof r.cpOrigin === 'string' && r.cpOrigin.trim() !== '')
260
261
  out.cpOrigin = r.cpOrigin;
261
262
  return out;
@@ -0,0 +1 @@
1
+ export {};
@@ -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.
@@ -13,7 +13,7 @@
13
13
  // self-guards the double-spawn. The selection predicate (`isDisconnected`) is
14
14
  // pure so the scope rules are unit-testable without booting a single process.
15
15
  import { listNodes, getNode } from '../canvas/index.js';
16
- import { isPidAlive } from '../canvas/pid.js';
16
+ import { recordedPidLiveness } from '../canvas/pid.js';
17
17
  import { reviveNode } from './revive.js';
18
18
  /** Statuses a node lands in DELIBERATELY: a worker that finished its own work
19
19
  * (`done`) or a node a human closed/reaped (`canceled`). The daemon never
@@ -56,7 +56,13 @@ export function listDisconnected() {
56
56
  const out = [];
57
57
  for (const row of listNodes()) {
58
58
  const meta = getNode(row.node_id);
59
- if (meta !== null && isDisconnected(meta, isPidAlive))
59
+ // IDENTITY-aware AND requires CONFIRMED alive: a bare isPidAlive would
60
+ // misread a recycled pi_pid as "engine running" and SKIP the node, so it
61
+ // never reaches reviveNode's own identity-aware double-launch guard
62
+ // (Round 2 of the pid-reuse-liveness fix, fold-in Major). `dead` OR
63
+ // `indeterminate` both count as disconnected/eligible here — only a
64
+ // positively confirmed-alive read is treated as "still connected".
65
+ if (meta !== null && isDisconnected(meta, (pid) => recordedPidLiveness(pid, meta.pi_pid_identity) === 'alive'))
60
66
  out.push(meta);
61
67
  }
62
68
  return out;
@@ -29,7 +29,7 @@ import { fanDoctrineWake } from './close.js';
29
29
  import { buildPiArgv } from './launch.js';
30
30
  import { buildReviveKickoff, drainBearings } from './kickoff.js';
31
31
  import { headlessBrokerHost } from './host.js';
32
- import { isRecordedPidAlive } from '../canvas/pid.js';
32
+ import { recordedPidLiveness } from '../canvas/pid.js';
33
33
  import { reconcileBootLiveness } from '../canvas/boot.js';
34
34
  import { clearFault } from './fault.js';
35
35
  import { clearInjectedDocs } from '../substrate/injected-store.js';
@@ -89,14 +89,24 @@ 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: the broker's isAlive is identity-aware liveness on
93
- // pi_pid, so a node whose broker pid is still running (and CONFIRMED to
94
- // still be that same broker, not a reused pid) was already revived by
95
- // another path — re-launching would put a SECOND broker on the same
96
- // session file. No-op. A bare isPidAlive would misread a reused pid (heavy
97
- // forking, esp. Linux) as "still alive" and skip the relaunch forever.
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.
98
108
  const live = getNode(nodeId) ?? meta;
99
- if (isRecordedPidAlive(live.pi_pid, live.pi_pid_identity)) {
109
+ if (recordedPidLiveness(live.pi_pid, live.pi_pid_identity) === 'alive') {
100
110
  return {
101
111
  window: null,
102
112
  session: live.tmux_session ?? null,
@@ -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
+ }
@@ -58,6 +58,7 @@ import { pathExists, readText, walkFiles } from '../fs-utils.js';
58
58
  import { parseFrontmatterGeneric } from '../frontmatter.js';
59
59
  import { evalCondition } from '../predicate.js';
60
60
  import { listAllMemoryDocs } from '../memory-resolver.js';
61
+ import { userScopeRoot } from '../scope.js';
61
62
  import { assembleNodeSubject, buildCeilingIndex, displayName, effectiveRung, gatePasses, normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName, } from './index.js';
62
63
  import { cachedSubstrateDocs } from './session-cache.js';
63
64
  // Ancestor dirs we never look inside for a `.crouter/memory/` store (the read
@@ -156,16 +157,26 @@ function hasExplicitTrigger(doc) {
156
157
  * `.crouter/memory/`, walking from the read file up to $HOME (or the
157
158
  * filesystem root for a read outside $HOME), skipping junk ancestors. The
158
159
  * user-global `~/.crouter/memory/` store is deliberately skipped: otherwise
159
- * every file read under $HOME would surface unrelated user-wide preferences. */
160
+ * every file read under $HOME would surface unrelated user-wide preferences.
161
+ *
162
+ * The crouter home (`~/.crouter`, the user scope root) is skipped for the same
163
+ * reason it is never a project scope root (scope.ts's `isProjectScopeDir`): it
164
+ * is the user scope, not a project ancestor. Without this fence, reading any
165
+ * canvas runtime artifact (`~/.crouter/canvas/nodes/.../reports|context/...`)
166
+ * walks up THROUGH `~/.crouter` and would inject whatever lives at
167
+ * `~/.crouter/.crouter/memory/` — e.g. a differently-scoped node whose cwd
168
+ * resolved its project store there — leaking one node's private memory into
169
+ * every unrelated node that reads a canvas file. */
160
170
  function positionalCandidates(absReadFile) {
161
171
  const out = [];
162
172
  const seenDocPaths = new Set();
163
173
  const home = realpathOrSelf(homedir());
174
+ const userRoot = realpathOrSelf(userScopeRoot());
164
175
  const fsRoot = parse(absReadFile).root;
165
176
  let dir = dirname(absReadFile);
166
177
  let depth = 0;
167
178
  while (true) {
168
- if (dir !== home && !isJunkAncestor(dir)) {
179
+ if (dir !== home && dir !== userRoot && !isJunkAncestor(dir)) {
169
180
  const memDir = join(dir, CRTR_DIR_NAME, 'memory');
170
181
  if (pathExists(memDir)) {
171
182
  for (const file of safeWalkMd(memDir)) {