agent-canvas 0.2.1 → 0.7.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 (99) 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 -106
  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 +5 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/files-api.test.ts +0 -78
  96. package/daemon/src/remote-feedback.test.ts +0 -165
  97. package/daemon/src/secrets-api.test.ts +0 -123
  98. package/daemon/src/share-e2e.test.ts +0 -277
  99. 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.1",
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.7.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.7.0",
34
+ "@fabrika/canvas-kernel": "0.7.0",
35
+ "@fabrika/daemon-kit": "0.7.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
@@ -41,3 +40,8 @@ bunx agent-canvas instructions --list
41
40
  ```
42
41
 
43
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,78 +0,0 @@
1
- import { afterEach, expect, test } from "bun:test";
2
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
3
- import { tmpdir } from "os";
4
- import { join } from "path";
5
- import { SessionManager } from "@fabrika/canvas-kernel/server";
6
- import { dispatch } from "@fabrika/daemon-kit";
7
- import { createFileHandlers } from "./handlers/files";
8
-
9
- const directories: string[] = [];
10
-
11
- afterEach(() => {
12
- for (const directory of directories.splice(0)) {
13
- rmSync(directory, { recursive: true, force: true });
14
- }
15
- });
16
-
17
- function setup() {
18
- const directory = mkdtempSync(join(tmpdir(), "agent-canvas-files-"));
19
- directories.push(directory);
20
- const projectRoot = join(directory, "project");
21
- const outsideRoot = join(directory, "project-other");
22
- mkdirSync(projectRoot);
23
- mkdirSync(outsideRoot);
24
- writeFileSync(join(projectRoot, "local.ts"), "export const local = true;");
25
- writeFileSync(join(outsideRoot, "external.ts"), "export const external = true;");
26
- writeFileSync(join(outsideRoot, "image.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\" />");
27
- const manager = new SessionManager(join(directory, "sessions"));
28
- manager.upsert("files", new Map([["plan.jsx", "<Section />"]]), projectRoot);
29
- const routes = createFileHandlers(manager);
30
-
31
- async function get(endpoint: string, path: string) {
32
- const url = new URL(`http://localhost:19400/api/${endpoint}`);
33
- url.searchParams.set("session", "files");
34
- url.searchParams.set("path", path);
35
- const response = dispatch(routes, new Request(url), url);
36
- if (!response) throw new Error(`No route for ${endpoint}`);
37
- return response;
38
- }
39
-
40
- return { get, projectRoot, outsideRoot };
41
- }
42
-
43
- test("serves relative paths and absolute paths inside and outside the project root", async () => {
44
- const { get, projectRoot, outsideRoot } = setup();
45
- for (const path of ["local.ts", join(projectRoot, "local.ts"), join(outsideRoot, "external.ts")]) {
46
- const response = await get("file", path);
47
- expect(response.status).toBe(200);
48
- expect(await response.json()).toEqual({
49
- content: path.endsWith("external.ts") ? "export const external = true;" : "export const local = true;",
50
- language: "typescript",
51
- });
52
- }
53
- });
54
-
55
- test("serves absolute image and directory paths outside the project root", async () => {
56
- const { get, outsideRoot } = setup();
57
- const image = await get("image", join(outsideRoot, "image.svg"));
58
- expect(image.status).toBe(200);
59
- expect(image.headers.get("Content-Type")).toBe("image/svg+xml");
60
- const tree = await get("tree", outsideRoot);
61
- expect(tree.status).toBe(200);
62
- expect(await tree.json()).toEqual({ entries: [
63
- { name: "external.ts", type: "file" },
64
- { name: "image.svg", type: "file" },
65
- ] });
66
- });
67
-
68
- test("rejects relative traversal into a sibling with the same root prefix", async () => {
69
- const { get } = setup();
70
- for (const endpoint of ["file", "image", "tree"]) {
71
- expect((await get(endpoint, "../project-other/external.ts")).status).toBe(403);
72
- }
73
- });
74
-
75
- test("returns 404 for a missing absolute file", async () => {
76
- const { get, outsideRoot } = setup();
77
- expect((await get("file", join(outsideRoot, "missing.ts"))).status).toBe(404);
78
- });
@@ -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
- });