@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.
- 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/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +24 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +41 -16
- 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 +632 -632
- 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 +65 -11
- 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-lifecycle-revive.js +9 -2
- package/dist/commands/node.js +41 -11
- package/dist/core/__tests__/on-read-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +67 -0
- 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/pid.d.ts +71 -13
- package/dist/core/canvas/pid.js +73 -29
- 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/runtime/revive-all.js +8 -2
- package/dist/core/runtime/revive.js +18 -8
- package/dist/core/secrets.d.ts +25 -0
- package/dist/core/secrets.js +55 -0
- package/dist/core/substrate/on-read.js +13 -2
- 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/daemon/crtrd.js +18 -8
- 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.
|
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
93
|
-
//
|
|
94
|
-
// still
|
|
95
|
-
// another path — re-launching would put
|
|
96
|
-
// session file. No-op. A bare isPidAlive would
|
|
97
|
-
// forking, esp. Linux) as "still alive" and
|
|
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 (
|
|
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)) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|