agent-canvas 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -105
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +9 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/remote-feedback.test.ts +0 -165
  96. package/daemon/src/secrets-api.test.ts +0 -123
  97. package/daemon/src/share-e2e.test.ts +0 -277
  98. package/daemon/src/share.test.ts +0 -289
@@ -1,21 +1,21 @@
1
1
  {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "jsx": "react-jsx",
7
- "strict": true,
8
- "esModuleInterop": true,
9
- "skipLibCheck": true,
10
- "noEmit": true,
11
- "types": ["@types/bun"],
12
- "paths": {
13
- "#canvas/runtime": ["./client/canvas-runtime.ts"],
14
- "#canvas/components": ["./client/components/index.ts"]
15
- }
16
- },
17
- "include": ["src/**/*", "client/**/*"],
18
- "exclude": [
19
- "src/share-e2e.test.ts"
20
- ]
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "types": ["@types/bun"],
12
+ "paths": {
13
+ "#canvas/runtime": ["./client/canvas-runtime.ts"],
14
+ "#canvas/components": ["./client/components/index.ts"]
15
+ }
16
+ },
17
+ "include": ["src/**/*", "client/**/*"],
18
+ "exclude": [
19
+ "src/share-e2e.test.ts"
20
+ ]
21
21
  }
package/package.json CHANGED
@@ -1,49 +1,49 @@
1
1
  {
2
- "name": "agent-canvas",
3
- "version": "0.2.0",
4
- "description": "Interactive visual canvas for Claude Code — rich annotatable documents in the browser",
5
- "bin": {
6
- "agent-canvas": "bin/agent-canvas.js"
7
- },
8
- "files": [
9
- "bin",
10
- "daemon",
11
- "skills"
12
- ],
13
- "scripts": {
14
- "build": "bun daemon/build.ts",
15
- "check:versions": "bun scripts/check-release-versions.ts",
16
- "typecheck": "tsc --noEmit && tsc --noEmit -p daemon/tsconfig.json",
17
- "test": "bun test",
18
- "prepublishOnly": "bun daemon/build.ts"
19
- },
20
- "dependencies": {
21
- "@fabrika/annotations": "0.2.0",
22
- "@fabrika/canvas-kernel": "0.2.0",
23
- "@fabrika/daemon-kit": "0.2.0",
24
- "marked": "^17.0.4",
25
- "mermaid-parser-bundle": "^0.1.2",
26
- "preact": "^10.25.0",
27
- "preact-render-to-string": "^6.6.6"
28
- },
29
- "devDependencies": {
30
- "@tailwindcss/cli": "4",
31
- "@types/bun": "^1.3.10",
32
- "@types/react": "^18.3.12",
33
- "@types/react-dom": "^18.3.1",
34
- "tailwindcss": "4",
35
- "typescript": "^5.9.3"
36
- },
37
- "repository": {
38
- "type": "git",
39
- "url": "https://github.com/contember/agent-canvas"
40
- },
41
- "workspaces": ["workers/*"],
42
- "license": "MIT",
43
- "keywords": [
44
- "claude-code",
45
- "canvas",
46
- "interactive",
47
- "annotations"
48
- ]
2
+ "name": "agent-canvas",
3
+ "version": "0.6.0",
4
+ "description": "Interactive visual canvas for Claude Code — rich annotatable documents in the browser",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/contember/fabrika-platform.git",
9
+ "directory": "packages/agent-canvas"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "bin": {
15
+ "agent-canvas": "./bin/agent-canvas.js"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "daemon",
20
+ "daemon/dist",
21
+ "skills"
22
+ ],
23
+ "exports": {
24
+ "./package.json": "./package.json"
25
+ },
26
+ "scripts": {
27
+ "build": "bun daemon/build.ts",
28
+ "release:prepare": "bun run build",
29
+ "typecheck": "tsc --noEmit && tsc --noEmit -p daemon/tsconfig.json && tsc --noEmit -p daemon/review-widget/tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@fabrika/annotations": "0.6.0",
34
+ "@fabrika/canvas-kernel": "0.6.0",
35
+ "@fabrika/daemon-kit": "0.6.0",
36
+ "@jridgewell/trace-mapping": "^0.3.31",
37
+ "marked": "^17.0.4",
38
+ "mermaid-parser-bundle": "^0.1.2",
39
+ "preact": "^10.25.0",
40
+ "preact-render-to-string": "^6.6.6",
41
+ "zod": "^3.25.76"
42
+ },
43
+ "keywords": [
44
+ "claude-code",
45
+ "canvas",
46
+ "interactive",
47
+ "annotations"
48
+ ]
49
49
  }
@@ -9,7 +9,6 @@ description: >
9
9
  implementation summaries, proposals, decision documents, explanations, and
10
10
  runbooks that hand local secrets to commands without putting values in context.
11
11
  disable-model-invocation: true
12
-
13
12
  ---
14
13
 
15
14
  ## Session
@@ -29,6 +28,10 @@ fresh, so the variable will not survive between commands.
29
28
 
30
29
  ## Instructions
31
30
 
31
+ Always use absolute filesystem paths for local file references in canvas components
32
+ (such as `FilePreview`), and verify that the referenced files exist. Resolve them
33
+ against the user's project directory, not the temporary directory containing the JSX.
34
+
32
35
  Run these commands from the user's project root before using Canvas:
33
36
 
34
37
  ```bash
@@ -37,3 +40,8 @@ bunx agent-canvas instructions --list
37
40
  ```
38
41
 
39
42
  **Getting detailed docs**: Run `bunx agent-canvas instructions <topic>` for detailed documentation on any component or flow. For example: `bunx agent-canvas instructions component-mermaid` or `bunx agent-canvas instructions flow-feature`.
43
+
44
+ ## Reviewing a running application
45
+
46
+ When the user wants to comment on a running application instead of a canvas, follow
47
+ `bunx agent-canvas instructions app-review`. Review mode needs no session ID; it uses a project name.
@@ -1,27 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { parseSecretAssignments, spawnWithSecretEnvironment } from "./exec";
3
-
4
- describe("secret-aware exec", () => {
5
- test("parses explicit environment mappings", () => {
6
- expect(parseSecretAssignments(["SERVICE_TOKEN=service-token", "API_KEY=api.key"])).toEqual([
7
- { envName: "SERVICE_TOKEN", fieldId: "service-token" },
8
- { envName: "API_KEY", fieldId: "api.key" },
9
- ]);
10
- });
11
-
12
- test("rejects invalid and duplicate environment names", () => {
13
- expect(() => parseSecretAssignments(["bad-name=token"])).toThrow("Invalid environment variable name");
14
- expect(() => parseSecretAssignments(["TOKEN=first", "TOKEN=second"])).toThrow("Duplicate environment variable mapping");
15
- });
16
-
17
- test("injects the secret into the child environment", async () => {
18
- const assignments = parseSecretAssignments(["RUNBOOK_TEST_TOKEN=service-token"]);
19
- const exitCode = await spawnWithSecretEnvironment(
20
- [process.execPath, "-e", "process.exit(process.env.RUNBOOK_TEST_TOKEN === 'test-value' ? 0 : 1)"],
21
- assignments,
22
- new Map([["service-token", "test-value"]]),
23
- );
24
-
25
- expect(exitCode).toBe(0);
26
- });
27
- });
@@ -1,79 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { waitForFeedback } from "./helpers";
3
-
4
- class FakeSocket {
5
- onopen: ((event: Event) => void) | null = null;
6
- onmessage: ((event: MessageEvent) => void) | null = null;
7
- onerror: ((event: Event) => void) | null = null;
8
- onclose: ((event: CloseEvent) => void) | null = null;
9
-
10
- close(): void {}
11
- }
12
-
13
- describe("waitForFeedback", () => {
14
- test("reconnects after an unexpected close", async () => {
15
- const sockets: FakeSocket[] = [];
16
- const result = waitForFeedback("session", {
17
- createSocket: () => {
18
- const socket = new FakeSocket();
19
- sockets.push(socket);
20
- queueMicrotask(() => {
21
- if (sockets.length === 1) socket.onclose?.(new CloseEvent("close"));
22
- else socket.onmessage?.(new MessageEvent("message", { data: JSON.stringify({ type: "submit", feedback: "Done" }) }));
23
- });
24
- return socket;
25
- },
26
- timeoutMs: 1_000,
27
- reconnectAttempts: 1,
28
- reconnectDelayMs: 0,
29
- });
30
-
31
- await expect(result).resolves.toBe("Done");
32
- expect(sockets).toHaveLength(2);
33
- });
34
-
35
- test("rejects when the connection keeps closing", async () => {
36
- const result = waitForFeedback("session", {
37
- createSocket: () => {
38
- const socket = new FakeSocket();
39
- queueMicrotask(() => socket.onclose?.(new CloseEvent("close")));
40
- return socket;
41
- },
42
- timeoutMs: 1_000,
43
- reconnectAttempts: 2,
44
- reconnectDelayMs: 0,
45
- });
46
-
47
- await expect(result).rejects.toThrow("WebSocket closed before feedback was submitted.");
48
- });
49
-
50
- test("reports a browser render error without reconnecting", async () => {
51
- const sockets: FakeSocket[] = [];
52
- const result = waitForFeedback("session", {
53
- createSocket: () => {
54
- const socket = new FakeSocket();
55
- sockets.push(socket);
56
- queueMicrotask(() => socket.onmessage?.(new MessageEvent("message", {
57
- data: JSON.stringify({
58
- type: "render-error",
59
- error: {
60
- revision: 3,
61
- filename: "architecture.jsx",
62
- message: "Table: required prop `headers` must be an array",
63
- componentStack: "\n at Table",
64
- },
65
- }),
66
- })));
67
- return socket;
68
- },
69
- timeoutMs: 1_000,
70
- reconnectAttempts: 2,
71
- reconnectDelayMs: 0,
72
- });
73
-
74
- await expect(result).rejects.toThrow(
75
- "Canvas render failed in architecture.jsx (revision 3):\nTable: required prop `headers` must be an array",
76
- );
77
- expect(sockets).toHaveLength(1);
78
- });
79
- });
@@ -1,165 +0,0 @@
1
- /**
2
- * Tests for the remote feedback poller. Uses a real SessionManager with
3
- * a real share recorded on a session, and mocks `fetch` to simulate
4
- * worker responses. Verifies persistence, broadcast, dedup, and backoff.
5
- */
6
-
7
- import { describe, expect, test, afterEach, beforeEach } from "bun:test";
8
- import { mkdtempSync, rmSync } from "fs";
9
- import { join } from "path";
10
- import { tmpdir } from "os";
11
- import { SessionManager } from "@fabrika/canvas-kernel/server";
12
- import { startRemoteFeedbackPoller } from "./remote-feedback";
13
-
14
- let testDir: string;
15
- beforeEach(() => {
16
- testDir = mkdtempSync(join(tmpdir(), "canvas-rf-test-"));
17
- });
18
- afterEach(() => {
19
- try { rmSync(testDir, { recursive: true, force: true }); } catch {}
20
- });
21
-
22
- function newSessionManager(): SessionManager {
23
- return new SessionManager(testDir);
24
- }
25
-
26
- const originalFetch = globalThis.fetch;
27
- const originalEndpoint = process.env.CANVAS_SHARE_ENDPOINT;
28
-
29
- beforeEach(() => {
30
- process.env.CANVAS_SHARE_ENDPOINT = "https://mock.example.com";
31
- });
32
-
33
- afterEach(() => {
34
- globalThis.fetch = originalFetch;
35
- if (originalEndpoint !== undefined) process.env.CANVAS_SHARE_ENDPOINT = originalEndpoint;
36
- else delete process.env.CANVAS_SHARE_ENDPOINT;
37
- });
38
-
39
- function setupSession(sm: SessionManager): { sessionId: string; shareId: string } {
40
- const sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
41
- const shareId = "abc123def456";
42
- sm.upsert(sessionId, new Map([["plan.jsx", "x"]]), "/tmp");
43
- sm.saveCompiled(sessionId, "plan.jsx", "x");
44
- sm.addShare(sessionId, {
45
- shareId,
46
- url: "https://mock.example.com/s/" + shareId,
47
- revision: 1,
48
- createdAt: new Date().toISOString(),
49
- });
50
- return { sessionId, shareId };
51
- }
52
-
53
- describe("startRemoteFeedbackPoller", () => {
54
- test("persists fetched entries and updates lastFeedbackAt", async () => {
55
- const sm = newSessionManager();
56
- const { sessionId, shareId } = setupSession(sm);
57
-
58
- let calls = 0;
59
- (globalThis as any).fetch = async (url: any) => {
60
- calls++;
61
- expect(String(url)).toContain(`/shares/${shareId}/feedback`);
62
- return new Response(JSON.stringify({
63
- entries: [{
64
- id: "fb1",
65
- shareId,
66
- revision: 1,
67
- submittedAt: "2026-04-09T10:00:00.000Z",
68
- author: { id: "u1", name: "Alice" },
69
- annotations: [{
70
- id: "a1",
71
- snippet: "x",
72
- note: "good",
73
- createdAt: "2026-04-09T10:00:00.000Z",
74
- }],
75
- }],
76
- latestAt: "2026-04-09T10:00:00.000Z",
77
- }), { status: 200 });
78
- };
79
-
80
- let broadcasted: { sessionId: string; revision: number; count: number }[] = [];
81
- const broadcast = (sid: string, rev: number, entries: any[]) => {
82
- broadcasted.push({ sessionId: sid, revision: rev, count: entries.length });
83
- };
84
-
85
- const poller = startRemoteFeedbackPoller(sm, broadcast, "0.0.0");
86
- // Wait for at least one poll cycle
87
- await new Promise((r) => setTimeout(r, 100));
88
- poller.stop();
89
-
90
- expect(calls).toBeGreaterThanOrEqual(1);
91
- expect(broadcasted.length).toBeGreaterThanOrEqual(1);
92
- expect(broadcasted[0].sessionId).toBe(sessionId);
93
- expect(broadcasted[0].revision).toBe(1);
94
- expect(broadcasted[0].count).toBe(1);
95
-
96
- const persisted = sm.getRemoteFeedback(sessionId, 1);
97
- expect(persisted).toHaveLength(1);
98
- expect(persisted[0].id).toBe("fb1");
99
- expect(persisted[0].author.name).toBe("Alice");
100
-
101
- const updatedShare = sm.getShares(sessionId).find((s) => s.shareId === shareId);
102
- expect(updatedShare?.lastFeedbackAt).toBe("2026-04-09T10:00:00.000Z");
103
-
104
- sm.remove(sessionId);
105
- });
106
-
107
- test("dedupes entries by id across polls", async () => {
108
- const sm = newSessionManager();
109
- const { sessionId } = setupSession(sm);
110
-
111
- const sameEntry = {
112
- id: "fb-same",
113
- shareId: "abc123def456",
114
- revision: 1,
115
- submittedAt: "2026-04-09T10:00:00.000Z",
116
- author: { id: "u1", name: "Bob" },
117
- annotations: [],
118
- };
119
-
120
- (globalThis as any).fetch = async () => new Response(JSON.stringify({ entries: [sameEntry] }), { status: 200 });
121
-
122
- const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
123
- await new Promise((r) => setTimeout(r, 200));
124
- poller.stop();
125
-
126
- // Even after multiple polls returning the same entry, only one should be persisted.
127
- const persisted = sm.getRemoteFeedback(sessionId, 1);
128
- expect(persisted).toHaveLength(1);
129
-
130
- sm.remove(sessionId);
131
- });
132
-
133
- test("404 stops further polls (revoked share)", async () => {
134
- const sm = newSessionManager();
135
- const { sessionId, shareId } = setupSession(sm);
136
-
137
- let calls = 0;
138
- (globalThis as any).fetch = async () => {
139
- calls++;
140
- return new Response("not found", { status: 404 });
141
- };
142
-
143
- const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
144
- await new Promise((r) => setTimeout(r, 200));
145
- poller.stop();
146
-
147
- // After the first 404 the share is marked dead — calls should stay at 1.
148
- expect(calls).toBe(1);
149
- void shareId;
150
- sm.remove(sessionId);
151
- });
152
-
153
- test("does nothing when no shares exist", async () => {
154
- const sm = newSessionManager();
155
- let calls = 0;
156
- (globalThis as any).fetch = async () => { calls++; return new Response("", { status: 200 }); };
157
-
158
- const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
159
- await new Promise((r) => setTimeout(r, 100));
160
- poller.stop();
161
-
162
- expect(calls).toBe(0);
163
- });
164
-
165
- });
@@ -1,123 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "fs";
3
- import { tmpdir } from "os";
4
- import { join } from "path";
5
- import { createApiHandlers } from "./handlers/api";
6
- import { SessionManager } from "@fabrika/canvas-kernel/server";
7
- import { dispatch, type Route } from "@fabrika/daemon-kit";
8
-
9
- const testDirectories: string[] = [];
10
-
11
- function setup(): { routes: Route[]; sessionId: string } {
12
- const directory = mkdtempSync(join(tmpdir(), "agent-canvas-secret-api-"));
13
- testDirectories.push(directory);
14
- const sessionManager = new SessionManager(directory);
15
- const sessionId = "runbook";
16
- sessionManager.upsert(sessionId, new Map([["runbook.jsx", "<Section title=\"Runbook\" />"]]), process.cwd());
17
- const routes = createApiHandlers({
18
- sessionManager,
19
- broadcastPlanUpdate: () => {},
20
- broadcastRevisionUpdate: () => {},
21
- port: 19400,
22
- version: "test",
23
- cliAuthToken: "test-cli-capability",
24
- });
25
- return { routes, sessionId };
26
- }
27
-
28
- async function call(routes: Route[], method: string, path: string, body?: unknown): Promise<Response> {
29
- const url = new URL(`http://localhost:19400${path}`);
30
- const request = new Request(url, {
31
- method,
32
- ...(body === undefined
33
- ? {}
34
- : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
35
- });
36
- const response = dispatch(routes, request, url);
37
- if (!response) throw new Error(`No route for ${method} ${path}`);
38
- return response;
39
- }
40
-
41
- afterEach(() => {
42
- for (const directory of testDirectories.splice(0)) {
43
- rmSync(directory, { recursive: true, force: true });
44
- }
45
- });
46
-
47
- describe("local secret API", () => {
48
- test("stores readiness metadata separately from the resolved value", async () => {
49
- const { routes, sessionId } = setup();
50
- const base = `/api/session/${sessionId}/secrets/service-token`;
51
-
52
- const stored = await call(routes, "POST", `${base}/value`, { value: "test-secret-value" });
53
- expect(stored.status).toBe(200);
54
- expect(stored.headers.get("Cache-Control")).toBe("no-store");
55
-
56
- const status = await call(routes, "GET", `${base}/status`);
57
- const statusText = await status.text();
58
- expect(statusText).toContain('"ready":true');
59
- expect(statusText).not.toContain("test-secret-value");
60
-
61
- const resolveUrl = new URL(`http://localhost:19400/api/session/${sessionId}/secrets/resolve`);
62
- const resolveRequest = new Request(resolveUrl, {
63
- method: "POST",
64
- headers: {
65
- "Content-Type": "application/json",
66
- "X-Agent-Canvas-CLI-Token": "test-cli-capability",
67
- },
68
- body: JSON.stringify({ fields: ["service-token"] }),
69
- });
70
- const resolveResponse = dispatch(routes, resolveRequest, resolveUrl);
71
- if (!resolveResponse) throw new Error("No secret resolve route");
72
- const resolved = await resolveResponse;
73
- expect(await resolved.json()).toEqual({
74
- values: [{ id: "service-token", value: "test-secret-value" }],
75
- });
76
- });
77
-
78
- test("returns missing field IDs without partial secret values", async () => {
79
- const { routes, sessionId } = setup();
80
- await call(routes, "POST", `/api/session/${sessionId}/secrets/ready-token/value`, { value: "available" });
81
-
82
- const url = new URL(`http://localhost:19400/api/session/${sessionId}/secrets/resolve`);
83
- const request = new Request(url, {
84
- method: "POST",
85
- headers: {
86
- "Content-Type": "application/json",
87
- "X-Agent-Canvas-CLI-Token": "test-cli-capability",
88
- },
89
- body: JSON.stringify({ fields: ["ready-token", "missing-token"] }),
90
- });
91
- const routed = dispatch(routes, request, url);
92
- if (!routed) throw new Error("No secret resolve route");
93
- const response = await routed;
94
-
95
- expect(response.status).toBe(409);
96
- expect(await response.json()).toEqual({
97
- error: "Required secrets are not ready",
98
- missing: ["missing-token"],
99
- });
100
- });
101
-
102
- test("clears a stored value without returning it", async () => {
103
- const { routes, sessionId } = setup();
104
- const base = `/api/session/${sessionId}/secrets/service-token`;
105
- await call(routes, "POST", `${base}/value`, { value: "test-secret-value" });
106
-
107
- const cleared = await call(routes, "DELETE", `${base}/value`);
108
- expect(await cleared.json()).toEqual({ ok: true, ready: false });
109
- expect(await (await call(routes, "GET", `${base}/status`)).json()).toEqual({ ready: false });
110
- });
111
-
112
- test("does not expose resolved values to same-origin browser code", async () => {
113
- const { routes, sessionId } = setup();
114
- await call(routes, "POST", `/api/session/${sessionId}/secrets/service-token/value`, { value: "test-secret-value" });
115
-
116
- const response = await call(routes, "POST", `/api/session/${sessionId}/secrets/resolve`, {
117
- fields: ["service-token"],
118
- });
119
-
120
- expect(response.status).toBe(403);
121
- expect(await response.text()).not.toContain("test-secret-value");
122
- });
123
- });