@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
|
@@ -17,4 +17,4 @@ You are an implementation agent. Your job is to **implement this feature or chan
|
|
|
17
17
|
|
|
18
18
|
Work directly. Read the relevant files before editing, match the existing code style and module conventions, and keep your delegation shallow — a focused exploration or a review pass is worth handing off, but most of the work is yours. Throw errors early; no silent fallbacks. Break things correctly rather than patching them badly; prefer clean, breaking changes over backwards-compat hacks in pre-production code.
|
|
19
19
|
|
|
20
|
-
Done means **provably correct against the spec's acceptance criteria** — not "it builds," not "the tests pass." Green output proves the code ran, not that it does what was asked; check the result against each acceptance criterion yourself. On a load-bearing change, get it critiqued by something other than you before calling it done — spawn a reviewer on the diff and fold in what it finds. And if the change outgrows what one window can finish well — many files, several phases, a design that keeps moving — promote yourself into a developer orchestrator and decompose it rather than grinding past the edge of your context.
|
|
20
|
+
Done means **provably correct against the spec's acceptance criteria** — not "it builds," not "the tests pass." Green output proves the code ran, not that it does what was asked; check the result against each acceptance criterion yourself. On a load-bearing change, get it critiqued by something other than you before calling it done — spawn a reviewer on the diff and fold in what it finds. But validate judiciously: a delegate's green report is settled evidence — don't re-run a suite or re-read a diff that already cleared its gate; check only what changed since. And if the change outgrows what one window can finish well — many files, several phases, a design that keeps moving — promote yourself into a developer orchestrator and decompose it rather than grinding past the edge of your context.
|
|
@@ -12,4 +12,6 @@ Before you shape the roadmap, read `crtr memory read development` for the roadma
|
|
|
12
12
|
|
|
13
13
|
Stay flexible, not waterfall. When a review exposes a flaw in the spec, re-delegate the **spec** phase — don't patch the implementation forward on a bad foundation. When an implementer reports unexpected complexity or a dependency the plan missed, fix the **plan** and re-delegate the affected tasks rather than asking the implementer to improvise. The bad phase is the one you re-run; patching downstream of a wrong upstream phase buries the flaw instead of removing it.
|
|
14
14
|
|
|
15
|
+
Validate judiciously — trust the agent. A gate that already ran green is settled evidence: when a child reports its build clean, its suite passing, or a reviewer reports the diff read, take the report and move on — do not re-run the same suite, rebuild the same tree, or re-read the same code to reassure yourself. Attach each new check to what *changed* since the last green gate (the fix diff, the new tests), never the whole feature from scratch; re-verify a report only when it is internally inconsistent or contradicted by evidence, not on general suspicion. Redundant re-validation burns whole windows and adds no information — the second identical green proves nothing the first didn't.
|
|
16
|
+
|
|
15
17
|
Post-implementation review is not one generic "review this" pass — it is several distinct perspectives, each its own assessment: does this **reuse** what the codebase already provides rather than reinventing it, is the **quality** sound, is it **efficient**, and are the **tests** real rather than green theatre. Hand each lens to its own reviewer so it assesses independently. Size the reviewer to the surface: a small focused diff goes to a single `review` worker; a whole feature's worth of files goes to a `review` orchestrator that fans the lenses across it and returns one verdict.
|
package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts
CHANGED
|
@@ -1074,10 +1074,10 @@ test("treats a request timeout as transient: retries the SAME provider in place,
|
|
|
1074
1074
|
}
|
|
1075
1075
|
});
|
|
1076
1076
|
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
test("
|
|
1077
|
+
// Once the in-place transient retry budget is exhausted, the failure is surfaced to the
|
|
1078
|
+
// user AND the credential is rotated away: the struggling credential cools down briefly and
|
|
1079
|
+
// the turn continues on the fallback provider, rather than hard-failing.
|
|
1080
|
+
test("surfaces the transient error and rotates to the fallback once the in-place retry budget is exhausted", async () => {
|
|
1081
1081
|
resetRotationState();
|
|
1082
1082
|
writeLadderConfig();
|
|
1083
1083
|
helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
|
|
@@ -1085,6 +1085,10 @@ test("ends the turn without rotating once the transient retry budget is exhauste
|
|
|
1085
1085
|
|
|
1086
1086
|
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
1087
1087
|
ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
|
|
1088
|
+
const notifications: Array<{ message: string; level: string }> = [];
|
|
1089
|
+
ctx.ui.notify = (message: string, level: string) => {
|
|
1090
|
+
notifications.push({ message, level });
|
|
1091
|
+
};
|
|
1088
1092
|
const streamedModels: any[] = [];
|
|
1089
1093
|
const sleepCalls: number[] = [];
|
|
1090
1094
|
rotationModule.__setSleepForTest(async (ms) => {
|
|
@@ -1093,8 +1097,12 @@ test("ends the turn without rotating once the transient retry budget is exhauste
|
|
|
1093
1097
|
rotationModule.__setStreamForProviderForTest((model) => {
|
|
1094
1098
|
streamedModels.push(model);
|
|
1095
1099
|
return (async function* () {
|
|
1096
|
-
|
|
1097
|
-
|
|
1100
|
+
if (model.provider === "anthropic") {
|
|
1101
|
+
// 503 with no retry-after header is transient by STATUS, not just by message.
|
|
1102
|
+
yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
yield { type: "text_delta", text: "hello from codex fallback" };
|
|
1098
1106
|
})() as any;
|
|
1099
1107
|
});
|
|
1100
1108
|
|
|
@@ -1108,13 +1116,17 @@ test("ends the turn without rotating once the transient retry budget is exhauste
|
|
|
1108
1116
|
const emitted = [] as any[];
|
|
1109
1117
|
for await (const event of stream) emitted.push(event);
|
|
1110
1118
|
|
|
1111
|
-
|
|
1112
|
-
assert.
|
|
1119
|
+
// Retried the same anthropic credential in place (3 attempts) then rotated to codex.
|
|
1120
|
+
assert.equal(streamedModels.filter((m) => m.provider === "anthropic").length, 3);
|
|
1113
1121
|
assert.deepEqual(sleepCalls, [250, 750]);
|
|
1114
|
-
assert.
|
|
1115
|
-
|
|
1116
|
-
assert.
|
|
1117
|
-
assert.
|
|
1122
|
+
assert.deepEqual(setModelCalls.map((m) => m.provider), ["openai-codex"]);
|
|
1123
|
+
// The turn was served on the fallback, never failed with a terminal error.
|
|
1124
|
+
assert.ok(emitted.some((e) => e.type === "text_delta"));
|
|
1125
|
+
assert.ok(!emitted.some((e) => e.type === "error"));
|
|
1126
|
+
// The user was told about the transient failure AND that it rotated.
|
|
1127
|
+
assert.ok(notifications.some((n) => /transient error after 3 attempts/.test(n.message) && /rotating/i.test(n.message)));
|
|
1128
|
+
// The struggling anthropic credential was cooled down so rotation moved on.
|
|
1129
|
+
assert.ok(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil > Date.now());
|
|
1118
1130
|
} finally {
|
|
1119
1131
|
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1120
1132
|
rotationModule.__setSleepForTest(undefined);
|
|
@@ -47,17 +47,23 @@ const INVALID_REFRESH_TOKEN_BACKOFF_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
47
47
|
// not a wait. Cap at the same window as our own default rate-limit backoff.
|
|
48
48
|
const SOLE_PROVIDER_MAX_WAIT_MS = DEFAULT_RATE_LIMIT_BACKOFF_MS;
|
|
49
49
|
// Short in-place retry budget for a transient network/5xx blip on the SAME credential
|
|
50
|
-
// (#122): a couple of quick retries, no cooldown
|
|
51
|
-
//
|
|
50
|
+
// (#122): a couple of quick retries, no cooldown. Once this budget is exhausted we no
|
|
51
|
+
// longer hard-fail the turn -- we surface the error and rotate (see TRANSIENT_ROTATE below).
|
|
52
52
|
const TRANSIENT_RETRY_BACKOFFS_MS = [250, 750];
|
|
53
|
+
// After the in-place transient retry budget is exhausted, rotate instead of failing: the
|
|
54
|
+
// credential itself is probably healthy (this is a provider-side blip like "overloaded"),
|
|
55
|
+
// so cool it down only briefly -- long enough to rotate to another credential / fallback
|
|
56
|
+
// provider, short enough that it comes back quickly if it was the only option.
|
|
57
|
+
const TRANSIENT_ROTATE_COOLDOWN_MS = 60 * 1000;
|
|
53
58
|
|
|
54
59
|
let runtimeContext: ExtensionContext | undefined;
|
|
55
60
|
let extensionAPI: ExtensionAPI | undefined;
|
|
56
61
|
|
|
57
62
|
type AttemptResult = {
|
|
58
|
-
result: "success" | "rate_limited" | "fatal";
|
|
63
|
+
result: "success" | "rate_limited" | "transient_exhausted" | "fatal";
|
|
59
64
|
retryAfterMs?: number;
|
|
60
65
|
attemptAt: number;
|
|
66
|
+
attempts?: number;
|
|
61
67
|
classification?: ProviderErrorClassification;
|
|
62
68
|
};
|
|
63
69
|
|
|
@@ -466,21 +472,16 @@ async function streamProviderAttempt(
|
|
|
466
472
|
return { result: "rate_limited", retryAfterMs: requestedRetryAfterMs, attemptAt, classification };
|
|
467
473
|
}
|
|
468
474
|
|
|
469
|
-
// Transient: retry the SAME credential/provider in place -- no cooldown
|
|
475
|
+
// Transient: retry the SAME credential/provider in place first -- no cooldown for the
|
|
476
|
+
// quick in-place budget.
|
|
470
477
|
const backoffMs = TRANSIENT_RETRY_BACKOFFS_MS[attempt - 1];
|
|
471
478
|
if (backoffMs === undefined) {
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
model,
|
|
479
|
-
`${getProviderLabel(providerId)} "${credential.label}" hit a transient error after ${attempt} attempts (${classification.reason})`,
|
|
480
|
-
),
|
|
481
|
-
});
|
|
482
|
-
flush();
|
|
483
|
-
return { result: "fatal", attemptAt, classification };
|
|
479
|
+
// In-place retry budget exhausted. Rather than hard-failing the turn, hand back a
|
|
480
|
+
// transient-exhausted result so the caller surfaces the error to the user AND rotates
|
|
481
|
+
// to another credential / fallback provider. Nothing is emitted into the stream here:
|
|
482
|
+
// the buffered (pre-content) events are discarded exactly like the rate-limit path, so
|
|
483
|
+
// the rotation target streams a clean turn.
|
|
484
|
+
return { result: "transient_exhausted", attemptAt, classification, attempts: attempt };
|
|
484
485
|
}
|
|
485
486
|
logRotation(
|
|
486
487
|
`${getProviderLabel(providerId)} subscription "${credential.label}" hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same credential in ${backoffMs}ms (attempt ${attempt + 1})`,
|
|
@@ -599,6 +600,30 @@ async function runManagedProvider(
|
|
|
599
600
|
logRotation(
|
|
600
601
|
`${getProviderLabel(providerId)} subscription "${freshCredential.label}" rate-limited (status=${result.classification?.status ?? "n/a"}, reason=${result.classification?.reason ?? "unclassified"}, ${retryAfterPart}); cooling down for ${Math.ceil(cooldownMs / 1000)}s, trying next credential`,
|
|
601
602
|
);
|
|
603
|
+
// Surface the rotation to the user too (parity with the transient-exhausted path), so a
|
|
604
|
+
// rate-limit-driven credential/provider switch is visible rather than silent.
|
|
605
|
+
runtimeContext?.ui.notify?.(
|
|
606
|
+
`${getProviderLabel(providerId)} "${freshCredential.label}" rate-limited (cooling down ${Math.ceil(cooldownMs / 1000)}s); rotating to another credential/provider.`,
|
|
607
|
+
"warn",
|
|
608
|
+
);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (result.result === "transient_exhausted") {
|
|
613
|
+
// The credential is likely healthy -- this was a provider-side transient blip (e.g.
|
|
614
|
+
// "overloaded") that survived the in-place retries. Surface it to the user and rotate:
|
|
615
|
+
// cool the credential down briefly so this loop (and the pool-exhaustion logic below)
|
|
616
|
+
// moves on to the next credential / fallback provider instead of failing the turn.
|
|
617
|
+
const reason = result.classification?.reason ?? "transient error";
|
|
618
|
+
markSubscriptionRateLimited(providerId, freshCredential.label, TRANSIENT_ROTATE_COOLDOWN_MS, result.attemptAt);
|
|
619
|
+
runtimeContext?.ui.notify?.(
|
|
620
|
+
`${getProviderLabel(providerId)} "${freshCredential.label}" hit a transient error after ${result.attempts ?? 1} attempts (${reason}); rotating to another credential/provider.`,
|
|
621
|
+
"warn",
|
|
622
|
+
);
|
|
623
|
+
logRotation(
|
|
624
|
+
`${getProviderLabel(providerId)} subscription "${freshCredential.label}" transient error exhausted (reason=${reason}, status=${result.classification?.status ?? "n/a"}); cooling down ${Math.ceil(TRANSIENT_ROTATE_COOLDOWN_MS / 1000)}s and rotating`,
|
|
625
|
+
);
|
|
626
|
+
setStatus(formatStatusLine(providerId));
|
|
602
627
|
continue;
|
|
603
628
|
}
|
|
604
629
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// attach-chrome-remote.test.ts — the safe-defaults contract for the attach
|
|
2
|
+
// chrome (buildCanvasPanelLines + GraphOverlay) when the `canvasSource` is a
|
|
3
|
+
// RemoteCanvasSource, i.e. `crtr surface attach --canvas <name>`.
|
|
4
|
+
//
|
|
5
|
+
// Adversarial, not merely a clean-machine assertion (mirrors
|
|
6
|
+
// core/canvas/__tests__/render-remote.test.ts): BEFORE rendering, this test
|
|
7
|
+
// seeds real, CONFLICTING local state for node id `a` — a poisoned telemetry
|
|
8
|
+
// reading (context tokens + a live-activity cue), an active fault, a local
|
|
9
|
+
// tmux focus, and a broker attach.json marking it human-watched — every
|
|
10
|
+
// historically-broken local read the remote suppression in nav-model.ts is
|
|
11
|
+
// supposed to bypass. If the production suppression ever regressed back to
|
|
12
|
+
// consulting local disk/SQLite for a remote id, the rendered lines below would
|
|
13
|
+
// carry the poisoned tokens/glyph/background instead of the safe defaults, so
|
|
14
|
+
// a clean-machine run could never mask the regression.
|
|
15
|
+
//
|
|
16
|
+
// Also proves the read-only contract: GraphOverlay's Enter/m/x key handlers
|
|
17
|
+
// must no-op against a remote graph row instead of shelling local
|
|
18
|
+
// `crtr node focus` / `crtr node lifecycle close`.
|
|
19
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { createServer } from 'node:http';
|
|
22
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { RemoteCanvasSource } from '../../../core/canvas/remote-canvas-source.js';
|
|
26
|
+
import { createNode } from '../../../core/canvas/canvas.js';
|
|
27
|
+
import { closeDb } from '../../../core/canvas/db.js';
|
|
28
|
+
import { openFocusRow } from '../../../core/canvas/focuses.js';
|
|
29
|
+
import { jobDir } from '../../../core/canvas/paths.js';
|
|
30
|
+
import { recordFault } from '../../../core/runtime/fault.js';
|
|
31
|
+
import { BG_ATTACHED } from '../../../core/canvas/nav-model.js';
|
|
32
|
+
import { dispatchSlashCommand, slashCommandList } from '../slash-commands.js';
|
|
33
|
+
import { resolveInitialMode, requestModeSwitch, tagAttachPane, untagAttachPane, reapOnDetach, } from '../attach-cmd.js';
|
|
34
|
+
import { buildCanvasPanelLines } from '../canvas-panels.js';
|
|
35
|
+
import { GraphOverlay } from '../graph-overlay.js';
|
|
36
|
+
function startMockCrtrServer(route) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const server = createServer((req, res) => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
req.on('data', (c) => chunks.push(c));
|
|
41
|
+
req.on('end', () => {
|
|
42
|
+
const bodyText = Buffer.concat(chunks).toString('utf8');
|
|
43
|
+
const parsed = JSON.parse(bodyText);
|
|
44
|
+
const result = route(parsed.args);
|
|
45
|
+
res.writeHead(result.status ?? 200, { 'content-type': 'application/json' });
|
|
46
|
+
res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify(result.body), stderr: '' }));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
server.listen(0, '127.0.0.1', () => {
|
|
50
|
+
const addr = server.address();
|
|
51
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
52
|
+
resolve({
|
|
53
|
+
url: `http://127.0.0.1:${port}`,
|
|
54
|
+
close: () => new Promise((res) => server.close(() => res())),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function meta(id, extra) {
|
|
60
|
+
return {
|
|
61
|
+
node_id: id,
|
|
62
|
+
name: id,
|
|
63
|
+
created: new Date().toISOString(),
|
|
64
|
+
cwd: '/tmp/work',
|
|
65
|
+
kind: 'general',
|
|
66
|
+
mode: 'base',
|
|
67
|
+
lifecycle: 'terminal',
|
|
68
|
+
status: 'active',
|
|
69
|
+
...extra,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function show(node, reports, managers) {
|
|
73
|
+
return {
|
|
74
|
+
node,
|
|
75
|
+
reports: reports.map((id) => ({ node_id: id, name: id, kind: 'general', status: 'active', active: true })),
|
|
76
|
+
managers: managers.map((id) => ({ node_id: id, name: id, kind: 'general', status: 'active', active: true })),
|
|
77
|
+
artifacts: { report: 0, doc: 0, roadmap: 0 },
|
|
78
|
+
pending_triggers: 0,
|
|
79
|
+
paths: { context_dir: '/tmp/context', reports_dir: '/tmp/reports', session_file: null },
|
|
80
|
+
follow_up: '',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const IDENTITY_PALETTE = {
|
|
84
|
+
accent: (s) => s,
|
|
85
|
+
active: (s) => s,
|
|
86
|
+
info: (s) => s,
|
|
87
|
+
muted: (s) => s,
|
|
88
|
+
faint: (s) => s,
|
|
89
|
+
border: (s) => s,
|
|
90
|
+
bold: (s) => s,
|
|
91
|
+
error: (s) => s,
|
|
92
|
+
warning: (s) => s,
|
|
93
|
+
bashMode: (s) => s,
|
|
94
|
+
bashModeAlt: (s) => s,
|
|
95
|
+
surface: (s) => s,
|
|
96
|
+
};
|
|
97
|
+
/** A fake `TUI` carrying just what `GraphOverlay` touches: `showOverlay` (a
|
|
98
|
+
* no-op handle) and `requestRender`. GraphOverlay's key handlers only shell
|
|
99
|
+
* `crtr` (via execFile) and toggle local overlay/handle state — they never
|
|
100
|
+
* reach into the real TUI beyond these two calls. */
|
|
101
|
+
function fakeTui() {
|
|
102
|
+
let renders = 0;
|
|
103
|
+
const tui = {
|
|
104
|
+
showOverlay: () => ({
|
|
105
|
+
hide: () => { },
|
|
106
|
+
setHidden: () => { },
|
|
107
|
+
isHidden: () => false,
|
|
108
|
+
focus: () => { },
|
|
109
|
+
unfocus: () => { },
|
|
110
|
+
isFocused: () => true,
|
|
111
|
+
}),
|
|
112
|
+
requestRender: () => { renders++; },
|
|
113
|
+
};
|
|
114
|
+
return { tui, renderCount: () => renders };
|
|
115
|
+
}
|
|
116
|
+
let home;
|
|
117
|
+
let localCwd;
|
|
118
|
+
before(() => {
|
|
119
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-attach-chrome-remote-'));
|
|
120
|
+
localCwd = mkdtempSync(join(tmpdir(), 'crtr-attach-chrome-remote-cwd-'));
|
|
121
|
+
process.env['CRTR_HOME'] = home;
|
|
122
|
+
});
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
closeDb();
|
|
125
|
+
rmSync(home, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
after(() => {
|
|
128
|
+
closeDb();
|
|
129
|
+
rmSync(home, { recursive: true, force: true });
|
|
130
|
+
rmSync(localCwd, { recursive: true, force: true });
|
|
131
|
+
delete process.env['CRTR_HOME'];
|
|
132
|
+
});
|
|
133
|
+
/** Seed conflicting local state for node id `a` — every local read the
|
|
134
|
+
* attach-chrome remote suppression is supposed to bypass. */
|
|
135
|
+
function seedPoisonedLocalState() {
|
|
136
|
+
createNode(meta('a', { cwd: localCwd }));
|
|
137
|
+
// A local focus — poisons focusedNodeIds()/isAttached's fast path.
|
|
138
|
+
openFocusRow('poison-focus', null, null, 'a');
|
|
139
|
+
// A broker attach.json — poisons isAttached's slow path.
|
|
140
|
+
writeFileSync(join(jobDir('a'), 'attach.json'), JSON.stringify({ viewers: 1 }));
|
|
141
|
+
// An active, manually-retried fault — poisons nodeGlyph's hanging overlay.
|
|
142
|
+
recordFault('a', {
|
|
143
|
+
link: 'viewer↔broker',
|
|
144
|
+
op: 'poison',
|
|
145
|
+
kind: 'other',
|
|
146
|
+
retry: { disposition: 'manual' },
|
|
147
|
+
message: 'POISON FAULT — local read leaked into remote attach chrome',
|
|
148
|
+
});
|
|
149
|
+
// Local telemetry — poisons tokensCell + activityCell.
|
|
150
|
+
writeFileSync(join(jobDir('a'), 'telemetry.json'), JSON.stringify({
|
|
151
|
+
context_tokens: 999_999,
|
|
152
|
+
last_activity: 'POISON ACTIVITY — local read leaked into remote attach chrome',
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
test('buildCanvasPanelLines suppresses every local-read adornment for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
|
|
156
|
+
seedPoisonedLocalState();
|
|
157
|
+
const rootMeta = meta('root');
|
|
158
|
+
const aMeta = meta('a'); // the REMOTE node's meta — unrelated to the poisoned local NodeMeta above
|
|
159
|
+
const server = await startMockCrtrServer((args) => {
|
|
160
|
+
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
|
|
161
|
+
const id = args[4];
|
|
162
|
+
if (id === 'root')
|
|
163
|
+
return { body: show(rootMeta, ['a'], []) };
|
|
164
|
+
if (id === 'a')
|
|
165
|
+
return { body: show(aMeta, [], ['root']) };
|
|
166
|
+
}
|
|
167
|
+
return { status: 500, body: {} };
|
|
168
|
+
});
|
|
169
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
170
|
+
// 'root' has 'a' as a live report → row('a') renders into `reports`.
|
|
171
|
+
const { reports } = await buildCanvasPanelLines('root', {}, IDENTITY_PALETTE, source);
|
|
172
|
+
assert.equal(reports.length, 1);
|
|
173
|
+
const line = reports[0];
|
|
174
|
+
assert.ok(!line.includes('1000k'), `local telemetry (999999 tokens) must not leak into a remote row: ${line}`);
|
|
175
|
+
assert.ok(!line.includes('POISON'), `poisoned local text must never appear in a remote row: ${line}`);
|
|
176
|
+
assert.ok(!line.includes('⚠'), `a local active fault must not surface as the hanging glyph on a remote row: ${line}`);
|
|
177
|
+
assert.ok(!line.includes(BG_ATTACHED), `a local focus/attach must not paint a remote row as attached: ${line}`);
|
|
178
|
+
await server.close();
|
|
179
|
+
});
|
|
180
|
+
test('GraphOverlay suppresses every local-read adornment for a RemoteCanvasSource AND makes Enter/m/x no-ops, even with conflicting local state for the same node id', async () => {
|
|
181
|
+
seedPoisonedLocalState();
|
|
182
|
+
const rootMeta = meta('root');
|
|
183
|
+
const aMeta = meta('a');
|
|
184
|
+
const server = await startMockCrtrServer((args) => {
|
|
185
|
+
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
|
|
186
|
+
const id = args[4];
|
|
187
|
+
if (id === 'root')
|
|
188
|
+
return { body: show(rootMeta, ['a'], []) };
|
|
189
|
+
if (id === 'a')
|
|
190
|
+
return { body: show(aMeta, [], ['root']) };
|
|
191
|
+
}
|
|
192
|
+
return { status: 500, body: {} };
|
|
193
|
+
});
|
|
194
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
195
|
+
const { tui } = fakeTui();
|
|
196
|
+
const overlay = new GraphOverlay(tui, 'root', () => ({}), IDENTITY_PALETTE, source);
|
|
197
|
+
overlay.open();
|
|
198
|
+
// buildSnapshot() is async (chased through the mock server); poll for it to land.
|
|
199
|
+
for (let i = 0; i < 100 && overlay.snapshot === undefined; i++) {
|
|
200
|
+
await new Promise((r) => setImmediate(r));
|
|
201
|
+
}
|
|
202
|
+
assert.notEqual(overlay.snapshot, undefined, 'snapshot never built');
|
|
203
|
+
const lines = overlay.render(100).join('\n');
|
|
204
|
+
assert.ok(!lines.includes('1000k'), `local telemetry must not leak into the remote graph overlay: ${lines}`);
|
|
205
|
+
assert.ok(!lines.includes('POISON'), `poisoned local text must never appear in the remote graph overlay: ${lines}`);
|
|
206
|
+
assert.ok(!lines.includes('⚠'), `a local active fault must not surface as the hanging glyph in the remote graph overlay: ${lines}`);
|
|
207
|
+
assert.ok(!lines.includes(BG_ATTACHED), `a local focus/attach must not paint a remote row as attached in the graph overlay: ${lines}`);
|
|
208
|
+
// Footer hint must drop the focus/kill/mgr hints in remote mode.
|
|
209
|
+
assert.ok(!lines.includes('kill'), `remote overlay footer must not advertise kill: ${lines}`);
|
|
210
|
+
assert.ok(!lines.includes('focus') && !lines.includes('mgr'), `remote overlay footer must not advertise focus/mgr: ${lines}`);
|
|
211
|
+
// Enter must NOT close the overlay (a local `close()` is the observable
|
|
212
|
+
// effect of a real `focusTarget` dispatch) — it must no-op instead.
|
|
213
|
+
overlay.handleInput('\r');
|
|
214
|
+
assert.equal(overlay.isOpen(), true, 'Enter must not act (close) on a remote graph row');
|
|
215
|
+
// x must NOT arm the y/n kill confirm — render() would show it in the hint.
|
|
216
|
+
overlay.handleInput('x');
|
|
217
|
+
const afterX = overlay.render(100).join('\n');
|
|
218
|
+
assert.ok(!afterX.includes('y/n'), `x must not arm a kill confirm on a remote graph row: ${afterX}`);
|
|
219
|
+
// m must NOT close the overlay either (it would focusTarget the manager).
|
|
220
|
+
overlay.handleInput('m');
|
|
221
|
+
assert.equal(overlay.isOpen(), true, 'm must not act (close) on a remote graph row');
|
|
222
|
+
await server.close();
|
|
223
|
+
});
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Phase 3 review 2 — the ENCLOSING attach viewer (slash commands, pane tag,
|
|
226
|
+
// detach-reap, Alt+M mode-switch), not just the GraphOverlay/panel rendering
|
|
227
|
+
// the fix above already covers. Same adversarial shape: seed colliding LOCAL
|
|
228
|
+
// state for the remote node id `a`, then assert the remote path never touches
|
|
229
|
+
// it — and, symmetrically, that the LOCAL path is byte-for-byte unchanged
|
|
230
|
+
// (every guard below defaults to acting exactly as before when `remote` is
|
|
231
|
+
// `false`).
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
function fakeSlashContext(remote, nodeId) {
|
|
234
|
+
const notices = [];
|
|
235
|
+
const sent = [];
|
|
236
|
+
const ctx = {
|
|
237
|
+
send: (frame) => sent.push(frame),
|
|
238
|
+
notify: (m) => notices.push(m),
|
|
239
|
+
cwd: '/tmp/does-not-matter',
|
|
240
|
+
// Explicit empty string (not `undefined`) so promoteNode/openContext's
|
|
241
|
+
// `ctx.nodeId ?? process.env['CRTR_NODE_ID']` fallback can NEVER pick up
|
|
242
|
+
// this test process's own real CRTR_NODE_ID (this test runs INSIDE a live
|
|
243
|
+
// crtr node) — the local-mode assertions below must reach the real
|
|
244
|
+
// no-node-id early return, never an accidental real `crtr node promote`
|
|
245
|
+
// against ourselves.
|
|
246
|
+
nodeId: '',
|
|
247
|
+
remote,
|
|
248
|
+
};
|
|
249
|
+
return { ctx, notices, sent };
|
|
250
|
+
}
|
|
251
|
+
test('slash autocomplete structurally omits the local-only native canvas commands (/promote, /resume-node, /context) in remote mode, and keeps them in local mode', () => {
|
|
252
|
+
const remoteNames = slashCommandList(undefined, true).map((c) => c.name);
|
|
253
|
+
assert.ok(!remoteNames.includes('promote'), `remote autocomplete must not offer /promote: ${remoteNames.join(',')}`);
|
|
254
|
+
assert.ok(!remoteNames.includes('resume-node'), `remote autocomplete must not offer /resume-node: ${remoteNames.join(',')}`);
|
|
255
|
+
assert.ok(!remoteNames.includes('context'), `remote autocomplete must not offer /context: ${remoteNames.join(',')}`);
|
|
256
|
+
// Remote-safe canvas commands stay offered.
|
|
257
|
+
assert.ok(remoteNames.includes('graph'), 'remote autocomplete must still offer /graph (already remote-safe)');
|
|
258
|
+
assert.ok(remoteNames.includes('view'), 'remote autocomplete must still offer /view (self-contained popup)');
|
|
259
|
+
// Local (remote=false, the default) is unchanged — all five canvas commands.
|
|
260
|
+
const localNames = slashCommandList().map((c) => c.name);
|
|
261
|
+
for (const name of ['graph', 'promote', 'resume-node', 'view', 'context']) {
|
|
262
|
+
assert.ok(localNames.includes(name), `local autocomplete must still offer /${name}: ${localNames.join(',')}`);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
test('dispatchSlashCommand no-ops /promote, /resume-node, /context in remote mode instead of shelling a local crtr command', () => {
|
|
266
|
+
const { ctx, notices, sent } = fakeSlashContext(true, 'a');
|
|
267
|
+
for (const cmd of ['/promote', '/resume-node', '/context', '/promote orchestrator']) {
|
|
268
|
+
notices.length = 0;
|
|
269
|
+
const handled = dispatchSlashCommand(cmd, ctx);
|
|
270
|
+
assert.equal(handled, true, `${cmd} must be handled (native, never forwarded to the engine as a prompt)`);
|
|
271
|
+
assert.equal(notices.length, 1, `${cmd} must notify exactly once (the remote-unsafe notice), got: ${JSON.stringify(notices)}`);
|
|
272
|
+
assert.match(notices[0], /not available for a remote attach/, `${cmd} must notify the remote-unsafe message, not proceed into its local handler: ${notices[0]}`);
|
|
273
|
+
}
|
|
274
|
+
assert.equal(sent.length, 0, 'no command frame must ever be sent for a gated remote canvas command');
|
|
275
|
+
// /graph and /view are UNaffected by the remote gate (graph is already
|
|
276
|
+
// remote-safe via GraphOverlay's own `remote` handling; view is self-
|
|
277
|
+
// contained). Neither reaches the remote-unsafe notice.
|
|
278
|
+
notices.length = 0;
|
|
279
|
+
ctx.onGraph = () => notices.push('graph-toggled');
|
|
280
|
+
dispatchSlashCommand('/graph', ctx);
|
|
281
|
+
assert.deepEqual(notices, ['graph-toggled'], '/graph must still work normally in remote mode');
|
|
282
|
+
});
|
|
283
|
+
test('dispatchSlashCommand leaves the LOCAL (non-remote) path for /promote, /resume-node, /context byte-for-byte unchanged', () => {
|
|
284
|
+
const { ctx, notices } = fakeSlashContext(false, '');
|
|
285
|
+
// No TMUX in this test process (a headless `node --test` run), so
|
|
286
|
+
// /resume-node and /context hit their OWN "needs tmux" notices — proving
|
|
287
|
+
// dispatch reached their real local handlers, not our new remote gate.
|
|
288
|
+
const savedTmux = process.env['TMUX'];
|
|
289
|
+
const savedTmuxPane = process.env['TMUX_PANE'];
|
|
290
|
+
delete process.env['TMUX'];
|
|
291
|
+
delete process.env['TMUX_PANE'];
|
|
292
|
+
try {
|
|
293
|
+
notices.length = 0;
|
|
294
|
+
dispatchSlashCommand('/promote', ctx);
|
|
295
|
+
assert.deepEqual(notices, ['/promote: no node to promote (viewer has no node id)']);
|
|
296
|
+
notices.length = 0;
|
|
297
|
+
dispatchSlashCommand('/resume-node', ctx);
|
|
298
|
+
assert.deepEqual(notices, ['/resume-node needs tmux']);
|
|
299
|
+
notices.length = 0;
|
|
300
|
+
dispatchSlashCommand('/context', ctx);
|
|
301
|
+
assert.deepEqual(notices, ['/context needs tmux']);
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
if (savedTmux !== undefined)
|
|
305
|
+
process.env['TMUX'] = savedTmux;
|
|
306
|
+
else
|
|
307
|
+
delete process.env['TMUX'];
|
|
308
|
+
if (savedTmuxPane !== undefined)
|
|
309
|
+
process.env['TMUX_PANE'] = savedTmuxPane;
|
|
310
|
+
else
|
|
311
|
+
delete process.env['TMUX_PANE'];
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
test('resolveInitialMode/requestModeSwitch never touch the local mode-switch read/write for a remote attach, and are unchanged for local', () => {
|
|
315
|
+
let reads = 0;
|
|
316
|
+
const readMode = (_cwd, _nodeId) => {
|
|
317
|
+
reads++;
|
|
318
|
+
return 'plan'; // poisoned value — must never surface for remote
|
|
319
|
+
};
|
|
320
|
+
assert.equal(resolveInitialMode('/tmp/cwd', 'a', true, readMode), 'normal', 'remote must always start normal');
|
|
321
|
+
assert.equal(reads, 0, 'remote must never read the local mode-switch current file');
|
|
322
|
+
assert.equal(resolveInitialMode('/tmp/cwd', 'a', false, readMode), 'plan', 'local must read through unchanged');
|
|
323
|
+
assert.equal(reads, 1, 'local must call the injected local reader exactly once');
|
|
324
|
+
let writes = 0;
|
|
325
|
+
let lastWrite;
|
|
326
|
+
const writeRequest = (cwd, nodeId, mode) => {
|
|
327
|
+
writes++;
|
|
328
|
+
lastWrite = { cwd, nodeId, mode };
|
|
329
|
+
};
|
|
330
|
+
const remoteOk = requestModeSwitch('/tmp/cwd', 'a', true, 'spec', writeRequest);
|
|
331
|
+
assert.equal(remoteOk, false, 'remote Alt+M must no-op (return false)');
|
|
332
|
+
assert.equal(writes, 0, 'remote must never write the local mode-switch request file');
|
|
333
|
+
const localOk = requestModeSwitch('/tmp/cwd', 'a', false, 'spec', writeRequest);
|
|
334
|
+
assert.equal(localOk, true, 'local Alt+M must still write through');
|
|
335
|
+
assert.equal(writes, 1, 'local must call the injected local writer exactly once');
|
|
336
|
+
assert.deepEqual(lastWrite, { cwd: '/tmp/cwd', nodeId: 'a', mode: 'spec' });
|
|
337
|
+
});
|
|
338
|
+
test('tagAttachPane/untagAttachPane never tag/untag the pane for a remote attach, and are unchanged for local', () => {
|
|
339
|
+
let calls = [];
|
|
340
|
+
const setOpt = (pane, name, value) => {
|
|
341
|
+
calls.push([pane, name, value]);
|
|
342
|
+
};
|
|
343
|
+
calls = [];
|
|
344
|
+
tagAttachPane(true, '%3', 'a', setOpt);
|
|
345
|
+
assert.deepEqual(calls, [], 'remote must never tag the pane with @crtr_node');
|
|
346
|
+
calls = [];
|
|
347
|
+
tagAttachPane(false, '%3', 'a', setOpt);
|
|
348
|
+
assert.deepEqual(calls, [['%3', '@crtr_node', 'a']], 'local tag must still fire unchanged');
|
|
349
|
+
calls = [];
|
|
350
|
+
untagAttachPane(true, '%3', setOpt);
|
|
351
|
+
assert.deepEqual(calls, [], 'remote must never untag the pane');
|
|
352
|
+
calls = [];
|
|
353
|
+
untagAttachPane(false, '%3', setOpt);
|
|
354
|
+
assert.deepEqual(calls, [['%3', '@crtr_node', '']], 'local untag must still fire unchanged');
|
|
355
|
+
// No pane resolved (outside tmux) — unaffected either way.
|
|
356
|
+
calls = [];
|
|
357
|
+
tagAttachPane(false, undefined, 'a', setOpt);
|
|
358
|
+
assert.deepEqual(calls, [], 'no pane to tag when outside tmux');
|
|
359
|
+
});
|
|
360
|
+
test('reapOnDetach never reaps a local node for a remote attach even on an id collision, and is unchanged for local', () => {
|
|
361
|
+
let reaped = [];
|
|
362
|
+
const reap = (nodeId) => {
|
|
363
|
+
reaped.push(nodeId);
|
|
364
|
+
};
|
|
365
|
+
reaped = [];
|
|
366
|
+
reapOnDetach(true, 'a', reap);
|
|
367
|
+
assert.deepEqual(reaped, [], 'remote detach must never reap a local node, even one colliding with the remote id');
|
|
368
|
+
reaped = [];
|
|
369
|
+
reapOnDetach(false, 'a', reap);
|
|
370
|
+
assert.deepEqual(reaped, ['a'], 'local detach-reap must still fire unchanged');
|
|
371
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|