@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.
- package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
- package/dist/clients/attach/attach-cmd.d.ts +30 -2
- package/dist/clients/attach/attach-cmd.js +622 -622
- package/dist/clients/attach/canvas-panels.js +9 -3
- package/dist/clients/attach/graph-overlay.d.ts +5 -0
- package/dist/clients/attach/graph-overlay.js +40 -7
- package/dist/clients/attach/input-controller.d.ts +9 -0
- package/dist/clients/attach/input-controller.js +47 -8
- package/dist/clients/attach/slash-commands.d.ts +11 -4
- package/dist/clients/attach/slash-commands.js +44 -9
- package/dist/clients/attach/transport-relay.d.ts +5 -5
- package/dist/clients/attach/transport-relay.js +3 -30
- package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
- package/dist/commands/__tests__/canvas-config.test.js +107 -0
- package/dist/commands/canvas-browse.js +26 -1
- package/dist/commands/canvas-config.d.ts +2 -0
- package/dist/commands/canvas-config.js +210 -0
- package/dist/commands/canvas-use.d.ts +1 -0
- package/dist/commands/canvas-use.js +63 -0
- package/dist/commands/canvas.js +4 -2
- package/dist/commands/node.js +41 -11
- package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
- package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
- package/dist/core/__tests__/secrets.test.d.ts +1 -0
- package/dist/core/__tests__/secrets.test.js +55 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
- package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
- package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
- package/dist/core/canvas/browse/app.d.ts +14 -0
- package/dist/core/canvas/browse/app.js +97 -15
- package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
- package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +3 -1
- package/dist/core/canvas/nav-model.d.ts +19 -10
- package/dist/core/canvas/nav-model.js +30 -12
- package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
- package/dist/core/canvas/remote-canvas-source.js +222 -0
- package/dist/core/canvas/remote-event-stream.d.ts +24 -0
- package/dist/core/canvas/remote-event-stream.js +94 -0
- package/dist/core/canvas/render.d.ts +13 -1
- package/dist/core/canvas/render.js +56 -37
- package/dist/core/canvas/source.d.ts +9 -0
- package/dist/core/canvas/source.js +15 -0
- package/dist/core/config.js +4 -3
- package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/node-env.test.js +91 -0
- package/dist/core/runtime/nodes.js +10 -0
- package/dist/core/secrets.d.ts +25 -0
- package/dist/core/secrets.js +55 -0
- package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
- package/dist/core/view/__tests__/transport-remote.test.js +95 -0
- package/dist/core/view/remote-canvas-target.d.ts +2 -1
- package/dist/core/view/remote-canvas-target.js +12 -7
- package/dist/core/view/transport-remote.d.ts +2 -0
- package/dist/core/view/transport-remote.js +53 -0
- package/dist/types.d.ts +19 -11
- package/dist/types.js +1 -1
- 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
|
|
184
|
-
const
|
|
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
|
|
214
|
-
const
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
527
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -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 `
|
|
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.
|
|
257
|
+
if (typeof r.relayTokenRef !== 'string' || r.relayTokenRef.trim() === '')
|
|
257
258
|
return null;
|
|
258
|
-
const out = { previewEndpoint: r.previewEndpoint,
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
23
|
+
`. Add one with \`crtr canvas config add ${name} --preview <url> --relay-token -\`.`);
|
|
19
24
|
}
|
|
20
|
-
const relayToken =
|
|
25
|
+
const relayToken = getRelayToken(target.relayTokenRef, scope);
|
|
21
26
|
if (relayToken === undefined || relayToken === '') {
|
|
22
|
-
throw usage(`
|
|
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
|
}
|