@crouton-kit/crouter 0.3.66 → 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 (72) 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/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  4. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  5. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  7. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  8. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  9. package/dist/clients/attach/attach-cmd.js +622 -622
  10. package/dist/clients/attach/canvas-panels.js +9 -3
  11. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  12. package/dist/clients/attach/graph-overlay.js +40 -7
  13. package/dist/clients/attach/input-controller.d.ts +9 -0
  14. package/dist/clients/attach/input-controller.js +47 -8
  15. package/dist/clients/attach/slash-commands.d.ts +11 -4
  16. package/dist/clients/attach/slash-commands.js +44 -9
  17. package/dist/clients/attach/transport-relay.d.ts +5 -5
  18. package/dist/clients/attach/transport-relay.js +3 -30
  19. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  20. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  21. package/dist/commands/canvas-browse.js +26 -1
  22. package/dist/commands/canvas-config.d.ts +2 -0
  23. package/dist/commands/canvas-config.js +210 -0
  24. package/dist/commands/canvas-use.d.ts +1 -0
  25. package/dist/commands/canvas-use.js +63 -0
  26. package/dist/commands/canvas.js +4 -2
  27. package/dist/commands/node.js +41 -11
  28. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  29. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  30. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  31. package/dist/core/__tests__/secrets.test.js +55 -0
  32. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  33. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  34. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  35. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  36. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  37. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  38. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  39. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  40. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  41. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  42. package/dist/core/canvas/browse/app.d.ts +14 -0
  43. package/dist/core/canvas/browse/app.js +97 -15
  44. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  45. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  46. package/dist/core/canvas/browse/render.d.ts +4 -0
  47. package/dist/core/canvas/browse/render.js +3 -1
  48. package/dist/core/canvas/nav-model.d.ts +19 -10
  49. package/dist/core/canvas/nav-model.js +30 -12
  50. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  51. package/dist/core/canvas/remote-canvas-source.js +222 -0
  52. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  53. package/dist/core/canvas/remote-event-stream.js +94 -0
  54. package/dist/core/canvas/render.d.ts +13 -1
  55. package/dist/core/canvas/render.js +56 -37
  56. package/dist/core/canvas/source.d.ts +9 -0
  57. package/dist/core/canvas/source.js +15 -0
  58. package/dist/core/config.js +4 -3
  59. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  60. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  61. package/dist/core/runtime/nodes.js +10 -0
  62. package/dist/core/secrets.d.ts +25 -0
  63. package/dist/core/secrets.js +55 -0
  64. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  65. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  66. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  67. package/dist/core/view/remote-canvas-target.js +12 -7
  68. package/dist/core/view/transport-remote.d.ts +2 -0
  69. package/dist/core/view/transport-remote.js +53 -0
  70. package/dist/types.d.ts +19 -11
  71. package/dist/types.js +1 -1
  72. 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.
@@ -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
+ });
@@ -1,3 +1,4 @@
1
- import type { Scope } from '../../types.js';
1
+ import type { Scope, RemoteCanvasTarget } from '../../types.js';
2
2
  import type { RelayTarget } from '../../clients/attach/transport-relay.js';
3
+ export declare function getRemoteCanvasTarget(name: string, scope?: Scope): RemoteCanvasTarget | undefined;
3
4
  export declare function resolveRemoteCanvasTarget(name: string, scope?: Scope): RelayTarget;
@@ -2,12 +2,17 @@
2
2
  // config entry (see `RemoteCanvasTarget`, `src/types.ts`) into a `RelayTarget`
3
3
  // ready for `RelayTransport` (`src/clients/attach/transport-relay.ts`).
4
4
  //
5
- // The raw bearer token NEVER lives in config or argv — only the name of an
6
- // environment variable holding it (`relayTokenEnv`), read here at connect
7
- // time. This is the Phase 1 interim contract; Phase 3 owns the real
8
- // secret-store-backed `crtr canvas config`/`crtr canvas use` CLI.
5
+ // The raw bearer token NEVER lives in config.json — only a ref
6
+ // (`relayTokenRef`) into the 0600 secrets store (`src/core/secrets.ts`),
7
+ // resolved here at connect time via `getRelayToken`. Targets are registered
8
+ // and the token stored via `crtr canvas config add` (`--relay-token -` reads
9
+ // it from stdin, the preferred form to keep it out of shell history).
9
10
  import { usage } from '../errors.js';
10
11
  import { readConfig } from '../config.js';
12
+ import { getRelayToken } from '../secrets.js';
13
+ export function getRemoteCanvasTarget(name, scope = 'user') {
14
+ return readConfig(scope).remoteCanvas.targets[name];
15
+ }
11
16
  export function resolveRemoteCanvasTarget(name, scope = 'user') {
12
17
  const cfg = readConfig(scope);
13
18
  const target = cfg.remoteCanvas.targets[name];
@@ -15,11 +20,11 @@ export function resolveRemoteCanvasTarget(name, scope = 'user') {
15
20
  const known = Object.keys(cfg.remoteCanvas.targets);
16
21
  throw usage(`no remote canvas target named '${name}'` +
17
22
  (known.length > 0 ? ` — configured: ${known.join(', ')}` : ' — none configured yet') +
18
- `. Add remoteCanvas.targets.${name} to config.json (run \`crtr sys config path\`): {"previewEndpoint": "https://<hash>.preview.bl.run", "relayTokenEnv": "<ENV_VAR_NAME>"}.`);
23
+ `. Add one with \`crtr canvas config add ${name} --preview <url> --relay-token -\`.`);
19
24
  }
20
- const relayToken = process.env[target.relayTokenEnv];
25
+ const relayToken = getRelayToken(target.relayTokenRef, scope);
21
26
  if (relayToken === undefined || relayToken === '') {
22
- throw usage(`remote canvas target '${name}' needs its relay token in $${target.relayTokenEnv} that environment variable is unset. Export it before running this command; the token is never read from config or argv.`);
27
+ throw usage(`target '${name}' has no stored relay token — add it with \`crtr canvas config add ${name} --relay-token -\`.`);
23
28
  }
24
29
  return { previewEndpoint: target.previewEndpoint, relayToken };
25
30
  }
@@ -0,0 +1,2 @@
1
+ import type { RawResponse } from './contract.js';
2
+ export declare function createRemoteExec(previewEndpoint: string, relayToken: string, timeoutMs?: number): (bin: string, args: string[]) => Promise<RawResponse>;