@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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// canvas-config.test.ts — `canvas config list`/`show` (`../canvas-config.ts`)
|
|
2
|
+
// mask every target's token presence/fingerprint from a SINGLE preloaded
|
|
3
|
+
// `SecretsStore`, never re-reading/reparsing `secrets.json` once per target
|
|
4
|
+
// (Minor 1, Phase 3 review 3).
|
|
5
|
+
//
|
|
6
|
+
// Proving "reads secrets.json once, not per target" can't be done by spying —
|
|
7
|
+
// this is a real ESM module (`node:test`'s `mock.method` cannot redefine a
|
|
8
|
+
// non-configurable ESM export; confirmed empirically, not assumed). Instead
|
|
9
|
+
// `secrets.json` is a real named pipe (FIFO) with exactly ONE writer that
|
|
10
|
+
// delivers its payload once and then closes: a SECOND `readFileSync` on that
|
|
11
|
+
// path blocks forever waiting for a writer that will never come. So `list`
|
|
12
|
+
// across N>1 targets only resolves within the timeout if it reads the file
|
|
13
|
+
// exactly once — the old per-target-read code would hang on the second
|
|
14
|
+
// target's read and this test would time out.
|
|
15
|
+
import { test, before, after } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
18
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { Readable } from 'node:stream';
|
|
22
|
+
import { canvasConfigBranch } from '../canvas-config.js';
|
|
23
|
+
import { updateConfig } from '../../core/config.js';
|
|
24
|
+
import { readSecrets } from '../../core/secrets.js';
|
|
25
|
+
import { InputError } from '../../core/io.js';
|
|
26
|
+
function leaf(name) {
|
|
27
|
+
const found = (canvasConfigBranch.children ?? []).find((c) => c.name === name);
|
|
28
|
+
if (found === undefined || found.kind !== 'leaf')
|
|
29
|
+
throw new Error(`no leaf '${name}' under canvas config`);
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
let home;
|
|
33
|
+
let realHome;
|
|
34
|
+
before(() => {
|
|
35
|
+
realHome = process.env['HOME'];
|
|
36
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-canvas-config-'));
|
|
37
|
+
process.env['HOME'] = home;
|
|
38
|
+
});
|
|
39
|
+
after(() => {
|
|
40
|
+
rmSync(home, { recursive: true, force: true });
|
|
41
|
+
if (realHome !== undefined)
|
|
42
|
+
process.env['HOME'] = realHome;
|
|
43
|
+
else
|
|
44
|
+
delete process.env['HOME'];
|
|
45
|
+
});
|
|
46
|
+
test('list masks N targets from ONE secrets.json read — a per-target re-read would hang on the exhausted FIFO', async () => {
|
|
47
|
+
// Three registered targets, each with a stored token — a per-target
|
|
48
|
+
// re-read regression would need 3 successful reads of secrets.json.
|
|
49
|
+
updateConfig('user', (cfg) => {
|
|
50
|
+
cfg.remoteCanvas.targets['alpha'] = { previewEndpoint: 'https://a.example', relayTokenRef: 'alpha' };
|
|
51
|
+
cfg.remoteCanvas.targets['beta'] = { previewEndpoint: 'https://b.example', relayTokenRef: 'beta' };
|
|
52
|
+
cfg.remoteCanvas.targets['gamma'] = { previewEndpoint: 'https://c.example', relayTokenRef: 'gamma' };
|
|
53
|
+
});
|
|
54
|
+
const crouterDir = join(home, '.crouter');
|
|
55
|
+
mkdirSync(crouterDir, { recursive: true });
|
|
56
|
+
const secretsPath = join(crouterDir, 'secrets.json');
|
|
57
|
+
execFileSync('mkfifo', [secretsPath]);
|
|
58
|
+
const payload = JSON.stringify({ relayTokens: { alpha: 'tok-a', beta: 'tok-b', gamma: 'tok-c' } });
|
|
59
|
+
// A single writer: opens the FIFO, writes the payload once, and exits —
|
|
60
|
+
// there is no second writer, so a second reader blocks forever.
|
|
61
|
+
const writer = spawn('sh', ['-c', `printf '%s' '${payload}' > '${secretsPath}'`], { stdio: 'ignore' });
|
|
62
|
+
try {
|
|
63
|
+
const result = await Promise.race([
|
|
64
|
+
leaf('list').run({}),
|
|
65
|
+
new Promise((_resolve, reject) => setTimeout(() => reject(new Error('list did not resolve — a second secrets.json read is blocking on the exhausted FIFO (per-target re-read regression)')), 2000)),
|
|
66
|
+
]);
|
|
67
|
+
const targets = result['targets'];
|
|
68
|
+
assert.equal(targets.length, 3);
|
|
69
|
+
const byName = Object.fromEntries(targets.map((t) => [t['name'], t]));
|
|
70
|
+
assert.equal(byName['alpha']?.['tokenStored'], true);
|
|
71
|
+
assert.equal(byName['beta']?.['tokenStored'], true);
|
|
72
|
+
assert.equal(byName['gamma']?.['tokenStored'], true);
|
|
73
|
+
assert.notEqual(byName['alpha']?.['fingerprint'], byName['beta']?.['fingerprint']);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
writer.kill();
|
|
77
|
+
// Replace the FIFO with a plain file so later tests in this process (or a
|
|
78
|
+
// stray writer) don't wedge on it.
|
|
79
|
+
rmSync(secretsPath, { force: true });
|
|
80
|
+
writeFileSync(secretsPath, payload);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// --- Phase 3 review Major 2: `add --relay-token` must be stdin-only ---
|
|
84
|
+
test('add rejects a literal (non-"-") --relay-token value with a generic error that never echoes the received secret', async () => {
|
|
85
|
+
const secret = 'some-literal-secret-value';
|
|
86
|
+
await assert.rejects(() => leaf('add').run({ name: 'literal-reject-test', preview: 'https://example.test', relayToken: secret, cp: undefined }), (err) => {
|
|
87
|
+
assert.ok(err instanceof InputError, `expected InputError, got ${err}`);
|
|
88
|
+
const serialized = JSON.stringify({ message: err.message, payload: err.payload });
|
|
89
|
+
assert.ok(!serialized.includes(secret), 'the thrown error must never interpolate the received relay-token value');
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
test('add with --relay-token - reads the raw token from stdin and stores it', async () => {
|
|
94
|
+
const realStdin = process.stdin;
|
|
95
|
+
const fakeStdin = Readable.from([Buffer.from('piped-stdin-token\n')]);
|
|
96
|
+
fakeStdin.isTTY = false;
|
|
97
|
+
Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true });
|
|
98
|
+
try {
|
|
99
|
+
const result = await leaf('add').run({ name: 'stdin-add-test', preview: 'https://example.test', relayToken: '-', cp: undefined });
|
|
100
|
+
assert.equal(result['tokenStored'], true);
|
|
101
|
+
const secrets = readSecrets('user');
|
|
102
|
+
assert.equal(secrets.relayTokens['stdin-add-test'], 'piped-stdin-token');
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
Object.defineProperty(process, 'stdin', { value: realStdin, configurable: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// Outside a TTY it prints the static forest and exits 0 (see runBrowse).
|
|
8
8
|
import { defineLeaf } from '../core/command.js';
|
|
9
9
|
import { runBrowse } from '../core/canvas/browse/app.js';
|
|
10
|
+
import { resolveCanvasSource } from '../core/canvas/source.js';
|
|
11
|
+
import { resolveRemoteCanvasTarget } from '../core/view/remote-canvas-target.js';
|
|
12
|
+
import { RemoteEventStream } from '../core/canvas/remote-event-stream.js';
|
|
13
|
+
import { readState } from '../core/config.js';
|
|
10
14
|
export const browseLeaf = defineLeaf({
|
|
11
15
|
name: 'browse',
|
|
12
16
|
description: 'open the interactive canvas navigator (tabs/tree/search)',
|
|
@@ -29,6 +33,13 @@ export const browseLeaf = defineLeaf({
|
|
|
29
33
|
required: false,
|
|
30
34
|
constraint: 'directory to scope the navigator to by default — only nodes spawned from this dir show until you toggle to All dirs (c). The /resume-node popup passes the launching node\'s cwd. Default: the invocation cwd.',
|
|
31
35
|
},
|
|
36
|
+
{
|
|
37
|
+
kind: 'flag',
|
|
38
|
+
name: 'canvas',
|
|
39
|
+
type: 'string',
|
|
40
|
+
required: false,
|
|
41
|
+
constraint: 'Read from the named remote canvas target (see `crtr canvas config list`) instead of the local canvas. Overrides `crtr canvas use`.',
|
|
42
|
+
},
|
|
32
43
|
],
|
|
33
44
|
output: [],
|
|
34
45
|
outputKind: 'object',
|
|
@@ -40,6 +51,20 @@ export const browseLeaf = defineLeaf({
|
|
|
40
51
|
},
|
|
41
52
|
run: async (input) => {
|
|
42
53
|
const cwd = input['cwd'] ?? process.cwd();
|
|
43
|
-
|
|
54
|
+
const canvasName = input['canvas'];
|
|
55
|
+
const source = resolveCanvasSource({ canvasName });
|
|
56
|
+
// The EFFECTIVE remote target name — an explicit --canvas, or (mirroring
|
|
57
|
+
// resolveCanvasSource's own precedence) the durable `crtr canvas use`
|
|
58
|
+
// selection. Only a remote target needs an event stream; resolve its
|
|
59
|
+
// credentials ONCE here (not re-derived inside app.ts, which stays
|
|
60
|
+
// credential-agnostic).
|
|
61
|
+
const effectiveCanvasName = canvasName ?? readState('user').activeCanvas ?? undefined;
|
|
62
|
+
const eventStream = effectiveCanvasName !== undefined
|
|
63
|
+
? (() => {
|
|
64
|
+
const { previewEndpoint, relayToken } = resolveRemoteCanvasTarget(effectiveCanvasName);
|
|
65
|
+
return new RemoteEventStream(previewEndpoint, relayToken);
|
|
66
|
+
})()
|
|
67
|
+
: undefined;
|
|
68
|
+
await runBrowse({ returnPane: input['returnPane'], cwd, source, eventStream });
|
|
44
69
|
},
|
|
45
70
|
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// `crtr canvas config` — register, list, inspect, and remove named remote
|
|
2
|
+
// canvas targets (a preview endpoint + a relay token kept in the 0600
|
|
3
|
+
// secrets store, never persisted to config.json or logged). `add` requires
|
|
4
|
+
// `--relay-token -` (stdin-only) — a literal argv value is rejected with a
|
|
5
|
+
// generic error (never echoing what was received, since it may be a real
|
|
6
|
+
// secret) so the token can never land in shell history or process listings.
|
|
7
|
+
// Selecting one for read commands is `crtr canvas use`, a sibling leaf, not a
|
|
8
|
+
// child here.
|
|
9
|
+
import { defineBranch, defineLeaf } from '../core/command.js';
|
|
10
|
+
import { InputError, readStdinRaw } from '../core/io.js';
|
|
11
|
+
import { readConfig, updateConfig } from '../core/config.js';
|
|
12
|
+
import { readSecrets, setRelayToken, deleteRelayToken, fingerprint } from '../core/secrets.js';
|
|
13
|
+
const SCOPE = 'user';
|
|
14
|
+
/** Masks one target's token presence/fingerprint from an ALREADY-loaded
|
|
15
|
+
* `SecretsStore` — never re-reads/reparses `secrets.json` per target
|
|
16
|
+
* (`list`/`show` load it once and pass it in here). */
|
|
17
|
+
function maskedTarget(name, target, secrets) {
|
|
18
|
+
const token = secrets.relayTokens[target.relayTokenRef];
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
previewEndpoint: target.previewEndpoint,
|
|
22
|
+
relayTokenRef: target.relayTokenRef,
|
|
23
|
+
...(target.cpOrigin ? { cpOrigin: target.cpOrigin } : {}),
|
|
24
|
+
tokenStored: token !== undefined && token !== '',
|
|
25
|
+
...(token !== undefined && token !== '' ? { fingerprint: fingerprint(token) } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// canvas config add
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
const canvasConfigAdd = defineLeaf({
|
|
32
|
+
name: 'add',
|
|
33
|
+
description: 'register a remote canvas target (preview endpoint + relay token)',
|
|
34
|
+
whenToUse: 'wiring up a remote canvas so `node inspect`, `canvas browse`, or `surface attach --canvas` can read it \u2014 stores the preview endpoint in config.json and the raw relay token in the 0600 secrets store (never together, never persisted to config.json or logged). Re-running with the same name overwrites both.',
|
|
35
|
+
help: {
|
|
36
|
+
name: 'canvas config add',
|
|
37
|
+
summary: 'register (or overwrite) a named remote canvas target',
|
|
38
|
+
params: [
|
|
39
|
+
{ kind: 'positional', name: 'name', required: true, constraint: 'Target name \u2014 the id every other `canvas config`/`canvas use`/`--canvas` reference uses.' },
|
|
40
|
+
{ kind: 'flag', name: 'preview', type: 'string', required: true, constraint: 'The Blaxel preview endpoint base URL (e.g. https://<hash>.preview.bl.run).' },
|
|
41
|
+
{ kind: 'flag', name: 'relay-token', type: 'string', required: true, constraint: 'Must be exactly `-` — the raw token is read from stdin, never argv (keeps it out of shell history). Any other value is rejected.' },
|
|
42
|
+
{ kind: 'flag', name: 'cp', type: 'string', required: false, constraint: 'Optional control-plane origin, when this target has one distinct from the preview endpoint.' },
|
|
43
|
+
],
|
|
44
|
+
output: [
|
|
45
|
+
{ name: 'name', type: 'string', required: true, constraint: 'The target name just registered.' },
|
|
46
|
+
{ name: 'previewEndpoint', type: 'string', required: true, constraint: 'The stored preview endpoint.' },
|
|
47
|
+
{ name: 'relayTokenRef', type: 'string', required: true, constraint: 'The secrets-store key the token was written under (equals name).' },
|
|
48
|
+
{ name: 'cpOrigin', type: 'string', required: false, constraint: 'The stored control-plane origin, when --cp was given.' },
|
|
49
|
+
{ name: 'tokenStored', type: 'boolean', required: true, constraint: 'Always true on success \u2014 confirms the token was written to the 0600 store.' },
|
|
50
|
+
],
|
|
51
|
+
outputKind: 'object',
|
|
52
|
+
effects: ['Writes remoteCanvas.targets.<name> to config.json (previewEndpoint, relayTokenRef, cpOrigin).', 'Writes the raw token to the 0600 secrets store under relayTokenRef \u2014 never to config.json or stdout.'],
|
|
53
|
+
},
|
|
54
|
+
run: async (input) => {
|
|
55
|
+
const name = input['name'];
|
|
56
|
+
const previewEndpoint = input['preview'];
|
|
57
|
+
const rawFlag = input['relayToken'];
|
|
58
|
+
const cp = input['cp'];
|
|
59
|
+
if (rawFlag !== '-') {
|
|
60
|
+
throw new InputError({
|
|
61
|
+
error: 'invalid_relay_token_arg',
|
|
62
|
+
message: 'relay token must be piped on stdin — pass `--relay-token -`',
|
|
63
|
+
field: 'relay-token',
|
|
64
|
+
next: 'Re-run with `--relay-token -` and pipe the raw token on stdin (e.g. `echo "$TOKEN" | crtr canvas config add <name> --preview <url> --relay-token -`).',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const token = (await readStdinRaw()).trim();
|
|
68
|
+
if (token === '') {
|
|
69
|
+
throw new InputError({
|
|
70
|
+
error: 'empty_token',
|
|
71
|
+
message: 'relay token is empty',
|
|
72
|
+
field: 'relay-token',
|
|
73
|
+
next: 'Pass a non-empty token, or `--relay-token -` and pipe it on stdin.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const target = { previewEndpoint, relayTokenRef: name, ...(cp ? { cpOrigin: cp } : {}) };
|
|
77
|
+
updateConfig(SCOPE, (cfg) => {
|
|
78
|
+
cfg.remoteCanvas.targets[name] = target;
|
|
79
|
+
});
|
|
80
|
+
setRelayToken(name, token, SCOPE);
|
|
81
|
+
return { name, previewEndpoint, relayTokenRef: name, ...(cp ? { cpOrigin: cp } : {}), tokenStored: true };
|
|
82
|
+
},
|
|
83
|
+
render: (r) => `Registered remote canvas target '${r['name']}' → ${r['previewEndpoint']} (token stored).`,
|
|
84
|
+
});
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// canvas config list
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
const canvasConfigList = defineLeaf({
|
|
89
|
+
name: 'list',
|
|
90
|
+
description: 'list registered remote canvas targets',
|
|
91
|
+
whenToUse: 'checking which remote canvas targets exist before `canvas use`/`--canvas <name>` \u2014 never prints a raw token, only a non-reversible fingerprint.',
|
|
92
|
+
help: {
|
|
93
|
+
name: 'canvas config list',
|
|
94
|
+
summary: 'list every registered remote canvas target (masked \u2014 no raw tokens)',
|
|
95
|
+
output: [
|
|
96
|
+
{
|
|
97
|
+
name: 'targets',
|
|
98
|
+
type: 'object[]',
|
|
99
|
+
required: true,
|
|
100
|
+
constraint: 'Each: {name, previewEndpoint, relayTokenRef, cpOrigin?, tokenStored, fingerprint?}. fingerprint is a non-reversible sha256 prefix, present only when a token is stored.',
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
outputKind: 'object',
|
|
104
|
+
effects: ['Read-only: reads config.json and the secrets-store token presence/fingerprint (never the raw token).'],
|
|
105
|
+
},
|
|
106
|
+
run: async () => {
|
|
107
|
+
const cfg = readConfig(SCOPE);
|
|
108
|
+
const secrets = readSecrets(SCOPE);
|
|
109
|
+
const targets = Object.entries(cfg.remoteCanvas.targets).map(([name, t]) => maskedTarget(name, t, secrets));
|
|
110
|
+
return { targets };
|
|
111
|
+
},
|
|
112
|
+
render: (r) => {
|
|
113
|
+
const targets = r['targets'];
|
|
114
|
+
if (targets.length === 0)
|
|
115
|
+
return 'No remote canvas targets configured.';
|
|
116
|
+
return targets
|
|
117
|
+
.map((t) => `${t['name']} — ${t['previewEndpoint']} (${t['tokenStored'] ? `token ${t['fingerprint']}` : 'no token stored'})`)
|
|
118
|
+
.join('\n');
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// canvas config show
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
const canvasConfigShow = defineLeaf({
|
|
125
|
+
name: 'show',
|
|
126
|
+
description: 'show one registered remote canvas target',
|
|
127
|
+
whenToUse: 'inspecting a single remote canvas target\u2019s config \u2014 masked the same way as `list`.',
|
|
128
|
+
help: {
|
|
129
|
+
name: 'canvas config show',
|
|
130
|
+
summary: 'show one remote canvas target (masked \u2014 no raw token)',
|
|
131
|
+
params: [
|
|
132
|
+
{ kind: 'positional', name: 'name', required: true, constraint: 'Target name.' },
|
|
133
|
+
],
|
|
134
|
+
output: [
|
|
135
|
+
{ name: 'name', type: 'string', required: true, constraint: 'The target name.' },
|
|
136
|
+
{ name: 'previewEndpoint', type: 'string', required: true, constraint: 'The stored preview endpoint.' },
|
|
137
|
+
{ name: 'relayTokenRef', type: 'string', required: true, constraint: 'The secrets-store key holding the token.' },
|
|
138
|
+
{ name: 'cpOrigin', type: 'string', required: false, constraint: 'The stored control-plane origin, when this target has one distinct from the preview endpoint.' },
|
|
139
|
+
{ name: 'tokenStored', type: 'boolean', required: true, constraint: 'Whether a token is currently stored under relayTokenRef.' },
|
|
140
|
+
],
|
|
141
|
+
outputKind: 'object',
|
|
142
|
+
effects: ['Read-only.'],
|
|
143
|
+
},
|
|
144
|
+
run: async (input) => {
|
|
145
|
+
const name = input['name'];
|
|
146
|
+
const cfg = readConfig(SCOPE);
|
|
147
|
+
const target = cfg.remoteCanvas.targets[name];
|
|
148
|
+
if (target === undefined) {
|
|
149
|
+
throw new InputError({
|
|
150
|
+
error: 'not_found',
|
|
151
|
+
message: `no remote canvas target named '${name}'`,
|
|
152
|
+
next: 'List configured targets with `crtr canvas config list`.',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return maskedTarget(name, target, readSecrets(SCOPE));
|
|
156
|
+
},
|
|
157
|
+
render: (r) => `${r['name']} — ${r['previewEndpoint']} (${r['tokenStored'] ? `token ${r['fingerprint']}` : 'no token stored'})`,
|
|
158
|
+
});
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// canvas config remove
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
const canvasConfigRemove = defineLeaf({
|
|
163
|
+
name: 'remove',
|
|
164
|
+
description: 'remove a registered remote canvas target and its stored token',
|
|
165
|
+
whenToUse: 'decommissioning a remote canvas target \u2014 drops it from config.json and deletes its raw token from the secrets store.',
|
|
166
|
+
help: {
|
|
167
|
+
name: 'canvas config remove',
|
|
168
|
+
summary: 'remove a remote canvas target and its stored token',
|
|
169
|
+
params: [
|
|
170
|
+
{ kind: 'positional', name: 'name', required: true, constraint: 'Target name.' },
|
|
171
|
+
],
|
|
172
|
+
output: [
|
|
173
|
+
{ name: 'removed', type: 'string', required: true, constraint: 'The target name removed.' },
|
|
174
|
+
],
|
|
175
|
+
outputKind: 'object',
|
|
176
|
+
effects: ['Deletes remoteCanvas.targets.<name> from config.json.', 'Deletes the stored token under relayTokenRef from the secrets store.'],
|
|
177
|
+
},
|
|
178
|
+
run: async (input) => {
|
|
179
|
+
const name = input['name'];
|
|
180
|
+
const cfg = readConfig(SCOPE);
|
|
181
|
+
const target = cfg.remoteCanvas.targets[name];
|
|
182
|
+
if (target === undefined) {
|
|
183
|
+
throw new InputError({
|
|
184
|
+
error: 'not_found',
|
|
185
|
+
message: `no remote canvas target named '${name}'`,
|
|
186
|
+
next: 'List configured targets with `crtr canvas config list`.',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
updateConfig(SCOPE, (c) => {
|
|
190
|
+
delete c.remoteCanvas.targets[name];
|
|
191
|
+
});
|
|
192
|
+
deleteRelayToken(target.relayTokenRef, SCOPE);
|
|
193
|
+
return { removed: name };
|
|
194
|
+
},
|
|
195
|
+
render: (r) => `Removed remote canvas target '${r['removed']}'.`,
|
|
196
|
+
});
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Export — mounted under `crtr canvas`
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
export const canvasConfigBranch = defineBranch({
|
|
201
|
+
name: 'config',
|
|
202
|
+
description: 'register/list/inspect/remove remote canvas targets',
|
|
203
|
+
whenToUse: 'setting up or auditing a remote canvas target (preview endpoint + relay token) before selecting one with `crtr canvas use`.',
|
|
204
|
+
help: {
|
|
205
|
+
name: 'canvas config',
|
|
206
|
+
summary: 'manage named remote canvas targets',
|
|
207
|
+
model: 'A remote canvas target is a preview endpoint + a relay token registered under a name. `add` registers one (token goes to the 0600 secrets store, never config.json); `list`/`show` display masked entries (fingerprint only, never the raw token); `remove` deletes both the config entry and the stored token. Select a target for read commands with `crtr canvas use <name>`, or back to local with `crtr canvas use --local`.',
|
|
208
|
+
},
|
|
209
|
+
children: [canvasConfigAdd, canvasConfigList, canvasConfigShow, canvasConfigRemove],
|
|
210
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const canvasUseLeaf: import("../core/command.js").LeafDef;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `crtr canvas use` — durably select which canvas read commands
|
|
2
|
+
// (`node inspect list/show`, `canvas browse`) default onto: a named remote
|
|
3
|
+
// target, or back to local. A single leaf, not a branch — there is exactly
|
|
4
|
+
// one decision here (pick a target, or go local), not a family of verbs.
|
|
5
|
+
import { defineLeaf } from '../core/command.js';
|
|
6
|
+
import { InputError } from '../core/io.js';
|
|
7
|
+
import { updateState } from '../core/config.js';
|
|
8
|
+
import { getRemoteCanvasTarget } from '../core/view/remote-canvas-target.js';
|
|
9
|
+
const SCOPE = 'user';
|
|
10
|
+
export const canvasUseLeaf = defineLeaf({
|
|
11
|
+
name: 'use',
|
|
12
|
+
description: 'select the durable default canvas for read commands (a remote target, or local)',
|
|
13
|
+
whenToUse: 'switching what `node inspect list/show` and `canvas browse` read from by default, without passing `--canvas <name>` on every invocation \u2014 select a remote target registered via `crtr canvas config add`, or switch back to local. A command\u2019s own `--canvas <name>` flag always overrides this for that one call.',
|
|
14
|
+
help: {
|
|
15
|
+
name: 'canvas use',
|
|
16
|
+
summary: 'select the durable default canvas (a registered remote target, or local) for read commands',
|
|
17
|
+
params: [
|
|
18
|
+
{ kind: 'positional', name: 'name', required: false, constraint: 'A remote canvas target registered via `crtr canvas config add`. Mutually exclusive with --local; exactly one of the two is required.' },
|
|
19
|
+
{ kind: 'flag', name: 'local', type: 'bool', required: false, constraint: 'Switch back to the local canvas. Mutually exclusive with <name>.' },
|
|
20
|
+
],
|
|
21
|
+
output: [
|
|
22
|
+
{ name: 'active', type: 'string', required: false, constraint: 'The now-active target name, or omitted/null for local.' },
|
|
23
|
+
],
|
|
24
|
+
outputKind: 'object',
|
|
25
|
+
effects: ['Writes state.json\u2019s activeCanvas (user scope).'],
|
|
26
|
+
},
|
|
27
|
+
run: async (input) => {
|
|
28
|
+
const name = input['name'];
|
|
29
|
+
const local = input['local'] === true;
|
|
30
|
+
if (name !== undefined && local) {
|
|
31
|
+
throw new InputError({
|
|
32
|
+
error: 'ambiguous_scope',
|
|
33
|
+
message: '<name> and --local are mutually exclusive',
|
|
34
|
+
next: 'Pass a target name to go remote, or --local to go local \u2014 not both.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (name === undefined && !local) {
|
|
38
|
+
throw new InputError({
|
|
39
|
+
error: 'missing_target',
|
|
40
|
+
message: 'nothing to select \u2014 pass a target name or --local',
|
|
41
|
+
next: 'Pass a target name (see `crtr canvas config list`) to go remote, or --local to go local.',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (local) {
|
|
45
|
+
updateState(SCOPE, (s) => {
|
|
46
|
+
s.activeCanvas = null;
|
|
47
|
+
});
|
|
48
|
+
return { active: null };
|
|
49
|
+
}
|
|
50
|
+
if (getRemoteCanvasTarget(name, SCOPE) === undefined) {
|
|
51
|
+
throw new InputError({
|
|
52
|
+
error: 'not_found',
|
|
53
|
+
message: `no remote canvas target named '${name}'`,
|
|
54
|
+
next: 'List configured targets with `crtr canvas config list`, or register one with `crtr canvas config add`.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
updateState(SCOPE, (s) => {
|
|
58
|
+
s.activeCanvas = name;
|
|
59
|
+
});
|
|
60
|
+
return { active: name };
|
|
61
|
+
},
|
|
62
|
+
render: (r) => (r['active'] ? `Active canvas: ${r['active']}.` : 'Active canvas: local.'),
|
|
63
|
+
});
|
package/dist/commands/canvas.js
CHANGED
|
@@ -17,6 +17,8 @@ import { canvasPruneLeaf } from './canvas-prune.js';
|
|
|
17
17
|
import { canvasRebuildIndexLeaf } from './canvas-rebuild-index.js';
|
|
18
18
|
import { historyBranch } from './canvas-history.js';
|
|
19
19
|
import { issueNewLeaf } from './canvas-issue.js';
|
|
20
|
+
import { canvasConfigBranch } from './canvas-config.js';
|
|
21
|
+
import { canvasUseLeaf } from './canvas-use.js';
|
|
20
22
|
export const issueBranch = defineBranch({
|
|
21
23
|
name: 'issue',
|
|
22
24
|
description: 'capture a quick todo/idea/bug into a GitHub issue (or a .todos/ note)',
|
|
@@ -40,8 +42,8 @@ export function registerCanvas() {
|
|
|
40
42
|
help: {
|
|
41
43
|
name: 'canvas',
|
|
42
44
|
summary: 'observe and supervise the whole agent graph',
|
|
43
|
-
model: 'Canvas-wide operations, distinct from per-node work (`node`) and a node\'s own spine write verb (`push`). `dashboard` renders the subscription forest as a tree; `browse` opens an interactive full-screen navigator (tabs/tree/search) over the whole canvas and resumes the chosen node; `attention` aggregates pending human asks across the graph; `revive --all` mass-reconnects every disconnected node (bringing back ONE named node is `node lifecycle revive <id>` instead); `history` searches and recalls the content record (reports + context docs) of past work in a cwd; `prune` bounds growth by deleting terminal nodes past a TTL.',
|
|
45
|
+
model: 'Canvas-wide operations, distinct from per-node work (`node`) and a node\'s own spine write verb (`push`). `dashboard` renders the subscription forest as a tree; `browse` opens an interactive full-screen navigator (tabs/tree/search) over the whole canvas and resumes the chosen node; `attention` aggregates pending human asks across the graph; `revive --all` mass-reconnects every disconnected node (bringing back ONE named node is `node lifecycle revive <id>` instead); `history` searches and recalls the content record (reports + context docs) of past work in a cwd; `prune` bounds growth by deleting terminal nodes past a TTL. `config` registers/lists remote canvas targets (preview endpoint + relay token); `use` switches read commands (`node inspect`, `browse`) onto a configured remote target or back to local.',
|
|
44
46
|
},
|
|
45
|
-
children: [dashboardLeaf, canvasSnapshotLeaf, browseLeaf, attentionBranch, reviveLeaf, historyBranch, chordLeaf, issueBranch, canvasPruneLeaf, canvasRebuildIndexLeaf],
|
|
47
|
+
children: [dashboardLeaf, canvasSnapshotLeaf, browseLeaf, attentionBranch, reviveLeaf, historyBranch, chordLeaf, issueBranch, canvasPruneLeaf, canvasRebuildIndexLeaf, canvasConfigBranch, canvasUseLeaf],
|
|
46
48
|
});
|
|
47
49
|
}
|
package/dist/commands/node.js
CHANGED
|
@@ -37,7 +37,8 @@ import { readMergedLaunchConfig } from '../core/config.js';
|
|
|
37
37
|
import { listProfiles } from '../core/profiles/manifest.js';
|
|
38
38
|
import { stateBlock } from '../core/help.js';
|
|
39
39
|
import { getNode, updateNode, listNodes, subscribe, unsubscribe, contextDir, reportsDir, nodeArtifacts, readContextTokens, armTrigger, listTriggers, cancelTrigger, TriggerArmError, fullName, } from '../core/canvas/index.js';
|
|
40
|
-
import {
|
|
40
|
+
import { resolveCanvasSource } from '../core/canvas/source.js';
|
|
41
|
+
import { RemoteCanvasSource } from '../core/canvas/remote-canvas-source.js';
|
|
41
42
|
// Past this much context, an ORCHESTRATOR that spawns a managed child is better
|
|
42
43
|
// off yielding than holding its fat window open for the child's result: the
|
|
43
44
|
// child's finish revives it fresh against its roadmap, so a clean window absorbs
|
|
@@ -387,14 +388,15 @@ const nodeList = defineLeaf({
|
|
|
387
388
|
{ kind: 'flag', name: 'lifecycle', type: 'string', required: false, constraint: 'Filter by lifecycle (comma-separated for several; accepted values: terminal, resident).' },
|
|
388
389
|
{ kind: 'flag', name: 'cwd', type: 'string', required: false, constraint: 'Filter to nodes pinned to this exact cwd (absolute path). Roster is canvas-wide (all cwds) by default.' },
|
|
389
390
|
{ kind: 'flag', name: 'under', type: 'string', required: false, constraint: 'Graph scope: restrict to this node and its subscription descendants (the sub-DAG reachable downward — its reports, theirs, …). This is "one initiative / my subtree". ANDs with every attribute filter above, narrowing any of them to this subtree.' },
|
|
390
|
-
{ kind: 'flag', name: 'hanging', type: 'bool', required: false, constraint: 'Only nodes PARKED on an active fault (live broker + marker when provider-owned). Use to find stuck/rate-limited nodes awaiting auto-revive.' },
|
|
391
|
+
{ kind: 'flag', name: 'hanging', type: 'bool', required: false, constraint: 'Only nodes PARKED on an active fault (live broker + marker when provider-owned). Use to find stuck/rate-limited nodes awaiting auto-revive. Not supported against a --canvas remote target in this phase (returns no rows).' },
|
|
392
|
+
{ kind: 'flag', name: 'canvas', type: 'string', required: false, constraint: 'Read from the named remote canvas target (see `crtr canvas config list`) instead of the local canvas. Overrides `crtr canvas use`.' },
|
|
391
393
|
],
|
|
392
394
|
output: [
|
|
393
395
|
{ name: 'nodes', type: 'object[]', required: true, constraint: 'Rows: {node_id, name, kind, mode, lifecycle, status, cwd, parent, created, hanging}. `hanging` is null normally, or {kind, message, since} when the node is PARKED on an active fault (rate-limit/overloaded/connection/other); the canvas-graph views render it with ⚠ + a countdown or state cue.' },
|
|
394
396
|
],
|
|
395
397
|
outputKind: 'object',
|
|
396
398
|
dynamicState: () => kindsStateBlock(),
|
|
397
|
-
effects: ['Read-only: queries canvas.db
|
|
399
|
+
effects: ['Read-only: queries canvas.db; locally also reads the active-fault marker (suppressed against a --canvas remote target).'],
|
|
398
400
|
},
|
|
399
401
|
run: async (input) => {
|
|
400
402
|
const csv = (k) => {
|
|
@@ -410,7 +412,7 @@ const nodeList = defineLeaf({
|
|
|
410
412
|
const hangingOnly = input['hanging'] === true;
|
|
411
413
|
// Graph scope (--under): self + subscription descendants. Validate the anchor
|
|
412
414
|
// so a typo'd id is a structured error, not a silently-empty roster.
|
|
413
|
-
const canvasSource =
|
|
415
|
+
const canvasSource = resolveCanvasSource({ canvasName: input['canvas'] });
|
|
414
416
|
let scope;
|
|
415
417
|
if (under !== undefined && under !== '') {
|
|
416
418
|
if (await canvasSource.getNode(under) === null) {
|
|
@@ -433,11 +435,16 @@ const nodeList = defineLeaf({
|
|
|
433
435
|
});
|
|
434
436
|
// Enrich each row with its active-fault state. Daemon-owned provider faults
|
|
435
437
|
// are suppressed when the broker pid is gone; manual/client faults stay
|
|
436
|
-
// visible until cleared.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
438
|
+
// visible until cleared. Fault state is a LOCAL read, so a remote canvas's
|
|
439
|
+
// rows are never enriched with it — `hanging` is suppressed to null and
|
|
440
|
+
// --hanging returns no rows against a remote target.
|
|
441
|
+
const remote = canvasSource instanceof RemoteCanvasSource;
|
|
442
|
+
const nodes = remote
|
|
443
|
+
? rows.map((row) => ({ ...row, hanging: null }))
|
|
444
|
+
: await Promise.all(rows.map(async (row) => {
|
|
445
|
+
const hanging = activeFaultForDisplay(row, await readFaultAsync(row.node_id));
|
|
446
|
+
return { ...row, hanging };
|
|
447
|
+
}));
|
|
441
448
|
return { nodes: nodes.filter((n) => !hangingOnly || n.hanging !== null) };
|
|
442
449
|
},
|
|
443
450
|
});
|
|
@@ -456,6 +463,11 @@ async function enrichNeighborFromSource(source, ref) {
|
|
|
456
463
|
active: ref.active,
|
|
457
464
|
};
|
|
458
465
|
}
|
|
466
|
+
/** Symbol-keyed marker set on a remote-passthrough `node inspect show` result so
|
|
467
|
+
* `render()` below can tell it came from a remote canvas without adding an
|
|
468
|
+
* enumerable field — `JSON.stringify` (the `--json` path) skips symbol keys,
|
|
469
|
+
* so this is invisible to machine output. */
|
|
470
|
+
const REMOTE_SHOW_MARKER = Symbol('remote-show');
|
|
459
471
|
const nodeShow = defineLeaf({
|
|
460
472
|
name: 'show',
|
|
461
473
|
description: 'show a node + its spine neighbors, artifacts, and drill-in pointers',
|
|
@@ -465,6 +477,7 @@ const nodeShow = defineLeaf({
|
|
|
465
477
|
summary: 'show a node in depth — meta slice, named neighbors (reports/managers), artifact/trigger counts, and drill-in pointers (context dir, session file, children). The debugging entrypoint for one node.',
|
|
466
478
|
params: [
|
|
467
479
|
{ kind: 'positional', name: 'node', required: true, constraint: 'Node id.' },
|
|
480
|
+
{ kind: 'flag', name: 'canvas', type: 'string', required: false, constraint: 'Read from the named remote canvas target (see `crtr canvas config list`) instead of the local canvas. Overrides `crtr canvas use`.' },
|
|
468
481
|
],
|
|
469
482
|
output: [
|
|
470
483
|
{ name: 'node', type: 'object', required: true, constraint: 'The full node meta (system prompt + every telemetry field). The render surfaces a glanceable slice; `--json` carries the complete dump.' },
|
|
@@ -485,7 +498,23 @@ const nodeShow = defineLeaf({
|
|
|
485
498
|
},
|
|
486
499
|
run: async (input) => {
|
|
487
500
|
const id = input['node'];
|
|
488
|
-
const
|
|
501
|
+
const canvasName = input['canvas'];
|
|
502
|
+
const canvasSource = resolveCanvasSource({ canvasName });
|
|
503
|
+
// Remote branch: `node inspect show` already computes this exact
|
|
504
|
+
// {node, reports, managers, artifacts, pending_triggers, paths, follow_up}
|
|
505
|
+
// shape in-guest — passthrough it verbatim rather than re-deriving
|
|
506
|
+
// artifacts/triggers/paths from LOCAL disk (which would be meaningless for
|
|
507
|
+
// a remote node id). The `render` below works unchanged on either branch
|
|
508
|
+
// since it only reads generic fields off the returned object.
|
|
509
|
+
if (canvasSource instanceof RemoteCanvasSource) {
|
|
510
|
+
const passthrough = await canvasSource.rawShow(id);
|
|
511
|
+
if (passthrough === null) {
|
|
512
|
+
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list --canvas <name>`.' });
|
|
513
|
+
}
|
|
514
|
+
const result = passthrough;
|
|
515
|
+
result[REMOTE_SHOW_MARKER] = true;
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
489
518
|
const node = await canvasSource.getNode(id);
|
|
490
519
|
if (node === null) {
|
|
491
520
|
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
@@ -525,7 +554,8 @@ const nodeShow = defineLeaf({
|
|
|
525
554
|
const node = r['node'];
|
|
526
555
|
const id = String(node['node_id']);
|
|
527
556
|
const pid = node['pi_pid'];
|
|
528
|
-
const
|
|
557
|
+
const isRemoteShow = Boolean(r[REMOTE_SHOW_MARKER]);
|
|
558
|
+
const alive = isRemoteShow ? 'remote (liveness not checked locally)' : (isPidAlive(pid) ? 'alive' : 'dead');
|
|
529
559
|
const intent = node['intent'] ? `/${String(node['intent'])}` : '';
|
|
530
560
|
const parts = [];
|
|
531
561
|
// 1) Glanceable meta slice — the debug-critical fields, not the full dump.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|