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.
- package/README.md +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -106
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +5 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/files-api.test.ts +0 -78
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
package/daemon/tsconfig.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
}
|
package/skills/canvas/SKILL.md
CHANGED
|
@@ -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
|
-
});
|
package/bin/lib/helpers.test.ts
DELETED
|
@@ -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
|
-
});
|