agent-canvas 0.1.26 → 0.1.28
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/.github/workflows/ci.yml +25 -0
- package/.github/workflows/deploy-worker-prod.yml +20 -0
- package/.github/workflows/deploy-worker-stage.yml +22 -0
- package/.github/workflows/deploy-worker.yml +73 -0
- package/bin/lib/instructions/overview.md +1 -0
- package/bun.lock +193 -0
- package/daemon/build.ts +22 -0
- package/daemon/client/AnnotationEditor.tsx +12 -2
- package/daemon/client/AnnotationProvider.tsx +20 -6
- package/daemon/client/AnnotationSidebar.tsx +72 -35
- package/daemon/client/App.tsx +219 -10
- package/daemon/client/FileBrowser.tsx +16 -0
- package/daemon/client/FileViewer.tsx +18 -0
- package/daemon/client/PlanRenderer.tsx +3 -2
- package/daemon/client/ReviewerIdentityDialog.tsx +95 -0
- package/daemon/client/SessionSwitcher.tsx +5 -0
- package/daemon/client/ShareDialog.tsx +230 -0
- package/daemon/client/clientApi.ts +142 -0
- package/daemon/client/runtime.ts +22 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +74 -74
- package/daemon/dist/components.js +50 -50
- package/daemon/src/handlers/api.ts +63 -0
- package/daemon/src/remote-feedback.test.ts +179 -0
- package/daemon/src/remote-feedback.ts +128 -0
- package/daemon/src/server.ts +5 -0
- package/daemon/src/session.ts +139 -6
- package/daemon/src/share.test.ts +221 -0
- package/daemon/src/share.ts +187 -0
- package/daemon/src/websocket.ts +15 -2
- package/package.json +5 -1
- package/workers/canvas-share/README.md +159 -0
- package/workers/canvas-share/oblaka.ts +56 -0
- package/workers/canvas-share/package.json +25 -0
- package/workers/canvas-share/public/assets/.gitkeep +0 -0
- package/workers/canvas-share/public/assets/client.css +2 -0
- package/workers/canvas-share/public/assets/client.js +91 -0
- package/workers/canvas-share/public/assets/components.js +63 -0
- package/workers/canvas-share/public/assets/jsx-runtime.js +3 -0
- package/workers/canvas-share/public/assets/preact-compat.js +1 -0
- package/workers/canvas-share/public/assets/runtime.js +1 -0
- package/workers/canvas-share/public/index.html +385 -0
- package/workers/canvas-share/public/screenshot-dark.png +0 -0
- package/workers/canvas-share/public/screenshot-light.png +0 -0
- package/workers/canvas-share/src/index.test.ts +165 -0
- package/workers/canvas-share/src/index.ts +510 -0
- package/workers/canvas-share/src/types.ts +97 -0
- package/workers/canvas-share/src/util.ts +156 -0
- package/workers/canvas-share/src/validation.ts +117 -0
- package/workers/canvas-share/tsconfig.json +16 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the share packager. Uses a temp directory to back a real
|
|
3
|
+
* SessionManager so we exercise the actual disk persistence path. The
|
|
4
|
+
* worker push itself is mocked via fetch override.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { SessionManager } from "./session";
|
|
12
|
+
import { buildSharePayload, pushShareToWorker, shareRevision, loadShareConfig } from "./share";
|
|
13
|
+
|
|
14
|
+
let testDir: string;
|
|
15
|
+
let originalSessionsDir: string | undefined;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
testDir = mkdtempSync(join(tmpdir(), "canvas-share-test-"));
|
|
19
|
+
// Override SESSIONS_DIR via env (paths.ts reads at module load — for
|
|
20
|
+
// these tests we instead seed a SessionManager and inject revisions
|
|
21
|
+
// through its public API rather than touching its private dirs).
|
|
22
|
+
originalSessionsDir = process.env.CANVAS_TEST_SESSIONS_DIR;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
|
|
27
|
+
if (originalSessionsDir !== undefined) {
|
|
28
|
+
process.env.CANVAS_TEST_SESSIONS_DIR = originalSessionsDir;
|
|
29
|
+
} else {
|
|
30
|
+
delete process.env.CANVAS_TEST_SESSIONS_DIR;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("loadShareConfig", () => {
|
|
35
|
+
test("returns null without endpoint", () => {
|
|
36
|
+
const before = process.env.CANVAS_SHARE_ENDPOINT;
|
|
37
|
+
delete process.env.CANVAS_SHARE_ENDPOINT;
|
|
38
|
+
expect(loadShareConfig("0.0.0")).toBeNull();
|
|
39
|
+
if (before) process.env.CANVAS_SHARE_ENDPOINT = before;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("strips trailing slashes", () => {
|
|
43
|
+
process.env.CANVAS_SHARE_ENDPOINT = "https://example.com/";
|
|
44
|
+
const cfg = loadShareConfig("0.0.0");
|
|
45
|
+
expect(cfg?.endpoint).toBe("https://example.com");
|
|
46
|
+
delete process.env.CANVAS_SHARE_ENDPOINT;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("includes auth token when set", () => {
|
|
50
|
+
process.env.CANVAS_SHARE_ENDPOINT = "https://example.com";
|
|
51
|
+
process.env.CANVAS_SHARE_AUTH_TOKEN = "secret";
|
|
52
|
+
const cfg = loadShareConfig("0.0.0");
|
|
53
|
+
expect(cfg?.authToken).toBe("secret");
|
|
54
|
+
delete process.env.CANVAS_SHARE_ENDPOINT;
|
|
55
|
+
delete process.env.CANVAS_SHARE_AUTH_TOKEN;
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("buildSharePayload", () => {
|
|
60
|
+
test("packages compiled JS + JSX source", () => {
|
|
61
|
+
const sm = new SessionManager(testDir);
|
|
62
|
+
const sid = `test-${Date.now()}`;
|
|
63
|
+
const files = new Map([["plan.jsx", "<Section>hi</Section>"]]);
|
|
64
|
+
sm.upsert(sid, files, "/tmp", "Test label");
|
|
65
|
+
sm.saveCompiled(sid, "plan.jsx", "/* compiled */ export default ()=>null");
|
|
66
|
+
|
|
67
|
+
const payload = buildSharePayload(sm, sid, 1, "0.1.27");
|
|
68
|
+
expect(payload.version).toBe(1);
|
|
69
|
+
expect(payload.origin.sessionId).toBe(sid);
|
|
70
|
+
expect(payload.origin.revision).toBe(1);
|
|
71
|
+
expect(payload.origin.label).toBe("Test label");
|
|
72
|
+
expect(payload.canvasFiles).toHaveLength(1);
|
|
73
|
+
expect(payload.canvasFiles[0].filename).toBe("plan.jsx");
|
|
74
|
+
expect(payload.canvasFiles[0].compiledJs).toContain("/* compiled */");
|
|
75
|
+
expect(payload.canvasFiles[0].sourceJsx).toContain("<Section>hi</Section>");
|
|
76
|
+
expect(payload.runtime.componentsVersion).toBe("0.1.27");
|
|
77
|
+
|
|
78
|
+
sm.remove(sid);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("throws when revision missing", () => {
|
|
82
|
+
const sm = new SessionManager(testDir);
|
|
83
|
+
const sid = `test-${Date.now()}`;
|
|
84
|
+
sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
|
|
85
|
+
sm.saveCompiled(sid, "plan.jsx", "x");
|
|
86
|
+
expect(() => buildSharePayload(sm, sid, 99, "0.0.0")).toThrow();
|
|
87
|
+
sm.remove(sid);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("throws when no compiled JS", () => {
|
|
91
|
+
const sm = new SessionManager(testDir);
|
|
92
|
+
const sid = `test-${Date.now()}`;
|
|
93
|
+
sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
|
|
94
|
+
// Intentionally skip saveCompiled
|
|
95
|
+
expect(() => buildSharePayload(sm, sid, 1, "0.0.0")).toThrow();
|
|
96
|
+
sm.remove(sid);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("pushShareToWorker", () => {
|
|
101
|
+
const originalFetch = globalThis.fetch;
|
|
102
|
+
afterEach(() => { globalThis.fetch = originalFetch; });
|
|
103
|
+
|
|
104
|
+
test("posts JSON and unwraps the response", async () => {
|
|
105
|
+
let captured: { url: string; init: RequestInit } | null = null;
|
|
106
|
+
(globalThis as any).fetch = async (url: any, init: any) => {
|
|
107
|
+
captured = { url: String(url), init };
|
|
108
|
+
return new Response(JSON.stringify({
|
|
109
|
+
shareId: "deadbeef",
|
|
110
|
+
url: "https://example.com/s/deadbeef",
|
|
111
|
+
ownerToken: "ot",
|
|
112
|
+
expiresAt: "2026-12-31T00:00:00Z",
|
|
113
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const result = await pushShareToWorker(
|
|
117
|
+
{
|
|
118
|
+
version: 1,
|
|
119
|
+
origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
|
|
120
|
+
canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
|
|
121
|
+
runtime: { componentsVersion: "1.0.0" },
|
|
122
|
+
},
|
|
123
|
+
{ endpoint: "https://example.com", componentsVersion: "1.0.0" },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(result.shareId).toBe("deadbeef");
|
|
127
|
+
expect(result.url).toBe("https://example.com/s/deadbeef");
|
|
128
|
+
expect(result.ownerToken).toBe("ot");
|
|
129
|
+
expect(captured!.url).toBe("https://example.com/shares");
|
|
130
|
+
expect(captured!.init.method).toBe("POST");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("includes Authorization header when authToken set", async () => {
|
|
134
|
+
let captured: any = null;
|
|
135
|
+
(globalThis as any).fetch = async (_url: any, init: any) => {
|
|
136
|
+
captured = init;
|
|
137
|
+
return new Response(JSON.stringify({ shareId: "x", url: "x" }), { status: 200 });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
await pushShareToWorker(
|
|
141
|
+
{
|
|
142
|
+
version: 1,
|
|
143
|
+
origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
|
|
144
|
+
canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
|
|
145
|
+
runtime: { componentsVersion: "1.0.0" },
|
|
146
|
+
},
|
|
147
|
+
{ endpoint: "https://example.com", componentsVersion: "1.0.0", authToken: "topsecret" },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(captured.headers["Authorization"]).toBe("Bearer topsecret");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("throws on non-2xx", async () => {
|
|
154
|
+
(globalThis as any).fetch = async () => new Response("nope", { status: 500 });
|
|
155
|
+
await expect(
|
|
156
|
+
pushShareToWorker(
|
|
157
|
+
{
|
|
158
|
+
version: 1,
|
|
159
|
+
origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
|
|
160
|
+
canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
|
|
161
|
+
runtime: { componentsVersion: "1.0.0" },
|
|
162
|
+
},
|
|
163
|
+
{ endpoint: "https://example.com", componentsVersion: "1.0.0" },
|
|
164
|
+
),
|
|
165
|
+
).rejects.toThrow(/500/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("throws on malformed response", async () => {
|
|
169
|
+
(globalThis as any).fetch = async () => new Response(JSON.stringify({ shareId: "x" }), { status: 200 });
|
|
170
|
+
await expect(
|
|
171
|
+
pushShareToWorker(
|
|
172
|
+
{
|
|
173
|
+
version: 1,
|
|
174
|
+
origin: { sessionId: "x", revision: 1, createdAt: "2026-01-01T00:00:00Z" },
|
|
175
|
+
canvasFiles: [{ filename: "p.jsx", compiledJs: "x" }],
|
|
176
|
+
runtime: { componentsVersion: "1.0.0" },
|
|
177
|
+
},
|
|
178
|
+
{ endpoint: "https://example.com", componentsVersion: "1.0.0" },
|
|
179
|
+
),
|
|
180
|
+
).rejects.toThrow(/invalid response/);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("shareRevision", () => {
|
|
185
|
+
const originalFetch = globalThis.fetch;
|
|
186
|
+
afterEach(() => { globalThis.fetch = originalFetch; });
|
|
187
|
+
|
|
188
|
+
test("records share entry on the session", async () => {
|
|
189
|
+
(globalThis as any).fetch = async () =>
|
|
190
|
+
new Response(JSON.stringify({
|
|
191
|
+
shareId: "abc123def456",
|
|
192
|
+
url: "https://example.com/s/abc123def456",
|
|
193
|
+
ownerToken: "owner",
|
|
194
|
+
expiresAt: "2026-12-31T00:00:00Z",
|
|
195
|
+
}), { status: 200 });
|
|
196
|
+
|
|
197
|
+
const sm = new SessionManager(testDir);
|
|
198
|
+
const sid = `test-${Date.now()}`;
|
|
199
|
+
sm.upsert(sid, new Map([["plan.jsx", "x"]]), "/tmp");
|
|
200
|
+
sm.saveCompiled(sid, "plan.jsx", "x");
|
|
201
|
+
|
|
202
|
+
const entry = await shareRevision(sm, sid, 1, {
|
|
203
|
+
endpoint: "https://example.com",
|
|
204
|
+
componentsVersion: "1.0.0",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(entry.shareId).toBe("abc123def456");
|
|
208
|
+
expect(entry.revision).toBe(1);
|
|
209
|
+
expect(entry.ownerToken).toBe("owner");
|
|
210
|
+
expect(entry.expiresAt).toBe("2026-12-31T00:00:00Z");
|
|
211
|
+
|
|
212
|
+
const shares = sm.getShares(sid);
|
|
213
|
+
expect(shares).toHaveLength(1);
|
|
214
|
+
expect(shares[0].shareId).toBe("abc123def456");
|
|
215
|
+
|
|
216
|
+
sm.remove(sid);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
void mkdirSync;
|
|
221
|
+
void writeFileSync;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { SessionManager, ShareEntry } from "./session";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canvas sharing: packages a specific revision into a self-contained
|
|
5
|
+
* snapshot and POSTs it to the configured cloud endpoint (CF Worker).
|
|
6
|
+
*
|
|
7
|
+
* The cloud endpoint stores the snapshot and returns a public capability
|
|
8
|
+
* URL that the author can send to reviewers. Runtime bundles
|
|
9
|
+
* (react/preact-compat, runtime.js, components.js) are NOT bundled into the
|
|
10
|
+
* payload — the worker serves matching runtime from its own deploy, keyed
|
|
11
|
+
* off `runtime.componentsVersion` in the payload. This keeps shares small
|
|
12
|
+
* while still guaranteeing version compatibility.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface SharePayload {
|
|
16
|
+
version: 1;
|
|
17
|
+
origin: {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
revision: number;
|
|
20
|
+
label?: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
};
|
|
23
|
+
canvasFiles: Array<{
|
|
24
|
+
filename: string;
|
|
25
|
+
compiledJs: string;
|
|
26
|
+
sourceJsx?: string;
|
|
27
|
+
}>;
|
|
28
|
+
runtime: {
|
|
29
|
+
componentsVersion: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ShareResponse {
|
|
34
|
+
shareId: string;
|
|
35
|
+
url: string;
|
|
36
|
+
ownerToken?: string;
|
|
37
|
+
expiresAt?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ShareConfig {
|
|
41
|
+
/** Base URL of the share worker, e.g. `https://canvas-share.example.workers.dev` */
|
|
42
|
+
endpoint: string;
|
|
43
|
+
/** Version of the @agent-canvas runtime bundles — worker uses this to pick matching runtime */
|
|
44
|
+
componentsVersion: string;
|
|
45
|
+
/** Optional bearer token. If the worker has SHARE_AUTH_TOKEN set, this
|
|
46
|
+
* must match. Loaded from CANVAS_SHARE_AUTH_TOKEN env var on the daemon. */
|
|
47
|
+
authToken?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadShareConfig(version: string): ShareConfig | null {
|
|
51
|
+
const endpoint = process.env.CANVAS_SHARE_ENDPOINT ?? "https://canvas.contember.com";
|
|
52
|
+
if (!endpoint) return null;
|
|
53
|
+
return {
|
|
54
|
+
endpoint: endpoint.replace(/\/+$/, ""),
|
|
55
|
+
componentsVersion: version,
|
|
56
|
+
...(process.env.CANVAS_SHARE_AUTH_TOKEN ? { authToken: process.env.CANVAS_SHARE_AUTH_TOKEN } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a SharePayload for the given session/revision. Reads the already
|
|
62
|
+
* compiled JS from the session's revision directory. Also includes the
|
|
63
|
+
* original JSX source for "view source" functionality on the shared canvas.
|
|
64
|
+
*/
|
|
65
|
+
export function buildSharePayload(
|
|
66
|
+
sessionManager: SessionManager,
|
|
67
|
+
sessionId: string,
|
|
68
|
+
revision: number,
|
|
69
|
+
componentsVersion: string,
|
|
70
|
+
): SharePayload {
|
|
71
|
+
const session = sessionManager.get(sessionId);
|
|
72
|
+
if (!session) throw new Error(`Session ${sessionId} not found`);
|
|
73
|
+
|
|
74
|
+
const revInfo = session.revisions.find((r) => r.revision === revision);
|
|
75
|
+
if (!revInfo) throw new Error(`Revision ${revision} not found in session ${sessionId}`);
|
|
76
|
+
|
|
77
|
+
const canvasFiles: SharePayload["canvasFiles"] = [];
|
|
78
|
+
for (const cf of revInfo.canvasFiles) {
|
|
79
|
+
const compiledJs = sessionManager.getCompiled(sessionId, cf.filename, revision);
|
|
80
|
+
if (!compiledJs) {
|
|
81
|
+
throw new Error(`No compiled JS for ${cf.filename} at revision ${revision}`);
|
|
82
|
+
}
|
|
83
|
+
const sourceJsx = sessionManager.readRevisionJsx(sessionId, revision, cf.filename) ?? undefined;
|
|
84
|
+
canvasFiles.push({
|
|
85
|
+
filename: cf.filename,
|
|
86
|
+
compiledJs,
|
|
87
|
+
...(sourceJsx ? { sourceJsx } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
version: 1,
|
|
93
|
+
origin: {
|
|
94
|
+
sessionId,
|
|
95
|
+
revision,
|
|
96
|
+
...(revInfo.label ? { label: revInfo.label } : {}),
|
|
97
|
+
createdAt: revInfo.createdAt,
|
|
98
|
+
},
|
|
99
|
+
canvasFiles,
|
|
100
|
+
runtime: { componentsVersion },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* POST the payload to the CF Worker. Returns the `shareId` + public URL
|
|
106
|
+
* assigned by the worker. Throws on any HTTP error — the caller is expected
|
|
107
|
+
* to surface the failure to the client (dialog) so the user can retry or
|
|
108
|
+
* fix their config.
|
|
109
|
+
*/
|
|
110
|
+
export async function pushShareToWorker(
|
|
111
|
+
payload: SharePayload,
|
|
112
|
+
config: ShareConfig,
|
|
113
|
+
): Promise<ShareResponse> {
|
|
114
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
115
|
+
if (config.authToken) headers["Authorization"] = `Bearer ${config.authToken}`;
|
|
116
|
+
|
|
117
|
+
const res = await fetch(`${config.endpoint}/shares`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers,
|
|
120
|
+
body: JSON.stringify(payload),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
const text = await res.text().catch(() => "");
|
|
124
|
+
throw new Error(`Share endpoint returned ${res.status}: ${text || res.statusText}`);
|
|
125
|
+
}
|
|
126
|
+
const data = (await res.json()) as { shareId?: string; url?: string; ownerToken?: string; expiresAt?: string };
|
|
127
|
+
if (!data.shareId || !data.url) {
|
|
128
|
+
throw new Error(`Share endpoint returned invalid response: ${JSON.stringify(data)}`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
shareId: data.shareId,
|
|
132
|
+
url: data.url,
|
|
133
|
+
...(data.ownerToken ? { ownerToken: data.ownerToken } : {}),
|
|
134
|
+
...(data.expiresAt ? { expiresAt: data.expiresAt } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* High-level: build payload, push to worker, record the share on the
|
|
140
|
+
* session. Returns the ShareEntry that was recorded.
|
|
141
|
+
*/
|
|
142
|
+
export async function shareRevision(
|
|
143
|
+
sessionManager: SessionManager,
|
|
144
|
+
sessionId: string,
|
|
145
|
+
revision: number,
|
|
146
|
+
config: ShareConfig,
|
|
147
|
+
): Promise<ShareEntry> {
|
|
148
|
+
const payload = buildSharePayload(sessionManager, sessionId, revision, config.componentsVersion);
|
|
149
|
+
const response = await pushShareToWorker(payload, config);
|
|
150
|
+
const entry: ShareEntry = {
|
|
151
|
+
shareId: response.shareId,
|
|
152
|
+
url: response.url,
|
|
153
|
+
revision,
|
|
154
|
+
createdAt: new Date().toISOString(),
|
|
155
|
+
...(response.ownerToken ? { ownerToken: response.ownerToken } : {}),
|
|
156
|
+
...(response.expiresAt ? { expiresAt: response.expiresAt } : {}),
|
|
157
|
+
};
|
|
158
|
+
sessionManager.addShare(sessionId, entry);
|
|
159
|
+
return entry;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Revoke a share by calling the worker's owner-only delete endpoint. Removes
|
|
164
|
+
* it from the local session metadata on success.
|
|
165
|
+
*/
|
|
166
|
+
export async function revokeShare(
|
|
167
|
+
sessionManager: SessionManager,
|
|
168
|
+
sessionId: string,
|
|
169
|
+
shareId: string,
|
|
170
|
+
config: ShareConfig,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
const session = sessionManager.get(sessionId);
|
|
173
|
+
const entry = session?.shares?.find((s) => s.shareId === shareId);
|
|
174
|
+
if (!entry) throw new Error(`Share ${shareId} not found`);
|
|
175
|
+
if (!entry.ownerToken) throw new Error("No owner token recorded for this share");
|
|
176
|
+
|
|
177
|
+
const res = await fetch(`${config.endpoint}/shares/${shareId}/revoke`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Authorization": `Bearer ${entry.ownerToken}` },
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
const text = await res.text().catch(() => "");
|
|
183
|
+
throw new Error(`Revoke failed: ${res.status} ${text}`);
|
|
184
|
+
}
|
|
185
|
+
sessionManager.removeShare(sessionId, shareId);
|
|
186
|
+
}
|
|
187
|
+
|
package/daemon/src/websocket.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ServerWebSocket } from "bun";
|
|
2
|
-
import type { SessionManager } from "./session";
|
|
2
|
+
import type { SessionManager, RemoteFeedbackEntry } from "./session";
|
|
3
3
|
|
|
4
4
|
export type WSData = { type: "browser" | "wait"; sessionId: string };
|
|
5
5
|
|
|
@@ -36,6 +36,19 @@ export function createWebSocketManager(sessionManager: SessionManager) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function broadcastRemoteFeedback(sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) {
|
|
40
|
+
const sockets = browserSockets.get(sessionId);
|
|
41
|
+
if (!sockets || sockets.size === 0) return;
|
|
42
|
+
const payload = JSON.stringify({
|
|
43
|
+
type: "remote-feedback",
|
|
44
|
+
revision,
|
|
45
|
+
entries,
|
|
46
|
+
});
|
|
47
|
+
for (const ws of sockets) {
|
|
48
|
+
ws.send(payload);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
function broadcastWatcherStatus(sessionId: string) {
|
|
40
53
|
const sockets = browserSockets.get(sessionId);
|
|
41
54
|
if (!sockets) return;
|
|
@@ -140,5 +153,5 @@ export function createWebSocketManager(sessionManager: SessionManager) {
|
|
|
140
153
|
},
|
|
141
154
|
};
|
|
142
155
|
|
|
143
|
-
return { handlers, broadcastPlanUpdate, broadcastRevisionUpdate, broadcastWatcherStatus };
|
|
156
|
+
return { handlers, broadcastPlanUpdate, broadcastRevisionUpdate, broadcastWatcherStatus, broadcastRemoteFeedback };
|
|
144
157
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "Interactive visual canvas for Claude Code — rich annotatable documents in the browser",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-canvas": "bin/agent-canvas.js"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "bun daemon/build.ts",
|
|
10
10
|
"typecheck": "tsc --noEmit && tsc --noEmit -p daemon/tsconfig.json",
|
|
11
|
+
"test": "bun test",
|
|
11
12
|
"prepublishOnly": "bun daemon/build.ts"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
@@ -28,6 +29,9 @@
|
|
|
28
29
|
"type": "git",
|
|
29
30
|
"url": "https://github.com/contember/agent-canvas"
|
|
30
31
|
},
|
|
32
|
+
"workspaces": [
|
|
33
|
+
"workers/*"
|
|
34
|
+
],
|
|
31
35
|
"license": "MIT",
|
|
32
36
|
"keywords": [
|
|
33
37
|
"claude-code",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# canvas-share
|
|
2
|
+
|
|
3
|
+
Cloudflare Worker that hosts shared agent-canvas snapshots.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
- **R2 (`BLOBS`)** stores share payloads, compiled canvas JS, JSX source, and reviewer-uploaded annotation images.
|
|
8
|
+
- **KV (`FEEDBACK`)** stores individual feedback entries (sortable keys for `since` queries) and rate limit counters (TTL 120s).
|
|
9
|
+
- **Static assets (`ASSETS`)** serve runtime bundles (`runtime.js`, `components.js`, `preact-compat.js`, `client.js`, `client.css`) — these are mirrored from `daemon/dist/` automatically by `bun run build`.
|
|
10
|
+
|
|
11
|
+
Validation is enforced with [Zod](https://zod.dev). Limits are centralized in [`src/types.ts`](src/types.ts).
|
|
12
|
+
|
|
13
|
+
## Infrastructure as code
|
|
14
|
+
|
|
15
|
+
The Cloudflare config is defined programmatically in [`oblaka.ts`](oblaka.ts)
|
|
16
|
+
using [`oblaka-iac`](https://github.com/contember/oblaka). The committed
|
|
17
|
+
[`oblaka.ts`](oblaka.ts) is the source of truth; `wrangler.jsonc` is
|
|
18
|
+
auto-generated and **gitignored** — never edit it by hand.
|
|
19
|
+
|
|
20
|
+
| Command | Generates | Used for |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `bun run oblaka` | `wrangler.jsonc` (env `local`) | local dev via Miniflare |
|
|
23
|
+
| `bun run oblaka:stage` | `wrangler.jsonc` (env `stage`) | staging deploy |
|
|
24
|
+
| `bun run oblaka:prod` | `wrangler.jsonc` (env `prod`) | production deploy |
|
|
25
|
+
|
|
26
|
+
`oblaka-iac` auto-prefixes R2 bucket and KV namespace names with the env
|
|
27
|
+
(e.g. `canvas-share-blobs` becomes `stage-canvas-share-blobs`) so the same
|
|
28
|
+
config produces isolated stage and prod resources.
|
|
29
|
+
|
|
30
|
+
## Local development
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cd workers/canvas-share
|
|
34
|
+
bun install
|
|
35
|
+
|
|
36
|
+
# Build daemon bundles AND auto-copy them into ./public/assets
|
|
37
|
+
cd ../..
|
|
38
|
+
bun run build
|
|
39
|
+
|
|
40
|
+
# Start the worker locally via Miniflare (no Cloudflare account needed).
|
|
41
|
+
# `bun run dev` runs `oblaka` first to regenerate wrangler.jsonc, then
|
|
42
|
+
# starts wrangler dev --local.
|
|
43
|
+
cd workers/canvas-share
|
|
44
|
+
bun run dev
|
|
45
|
+
|
|
46
|
+
# Point the daemon at it
|
|
47
|
+
cd ../..
|
|
48
|
+
CANVAS_SHARE_ENDPOINT=http://127.0.0.1:8787 bun bin/agent-canvas.ts daemon restart
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then click **Share** on any revision in the canvas UI.
|
|
52
|
+
|
|
53
|
+
## Testing
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd workers/canvas-share
|
|
57
|
+
bun test # unit tests for validation, util
|
|
58
|
+
bun run typecheck # typecheck src/
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Continuous deployment
|
|
62
|
+
|
|
63
|
+
The repo ships GitHub Actions workflows that deploy the worker automatically:
|
|
64
|
+
|
|
65
|
+
| Workflow | Trigger | Env |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `deploy-worker-stage.yml` | Push to `main` touching `workers/canvas-share/**`, `daemon/client/**`, or `daemon/build.ts` | `stage` |
|
|
68
|
+
| `deploy-worker-prod.yml` | Version tag push (`v*`) | `prod` |
|
|
69
|
+
|
|
70
|
+
Both call the reusable `deploy-worker.yml` which:
|
|
71
|
+
|
|
72
|
+
1. Installs deps + builds the daemon runtime bundles (auto-mirrored into `public/assets/`)
|
|
73
|
+
2. Runs `bunx oblaka oblaka.ts --env <env> --remote` to create or update the R2 bucket and KV namespace on Cloudflare
|
|
74
|
+
3. Sets `SHARE_AUTH_TOKEN` via `wrangler secret bulk` (skipped if no token is configured for the env)
|
|
75
|
+
4. Runs `bunx wrangler deploy`
|
|
76
|
+
|
|
77
|
+
### Required GitHub secrets
|
|
78
|
+
|
|
79
|
+
| Secret | Purpose |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `CLOUDFLARE_API_TOKEN` | Token with Workers + R2 + KV permissions on the target account |
|
|
82
|
+
| `CLOUDFLARE_ACCOUNT_ID` | Account id the worker deploys to |
|
|
83
|
+
| `SHARE_AUTH_TOKEN_STAGE` | Bearer token daemons must use to POST to stage (optional) |
|
|
84
|
+
| `SHARE_AUTH_TOKEN_PROD` | Bearer token daemons must use to POST to prod (optional) |
|
|
85
|
+
|
|
86
|
+
Both stage and prod use the same Cloudflare account; oblaka auto-prefixes resource names so stage and prod get separate `stage-canvas-share-blobs` and `prod-canvas-share-blobs` buckets (same for KV).
|
|
87
|
+
|
|
88
|
+
Use GitHub Environments (`Settings → Environments → stage / prod`) to gate prod deploys behind a manual approval if you want belt-and-suspenders safety.
|
|
89
|
+
|
|
90
|
+
## Manual production deploy
|
|
91
|
+
|
|
92
|
+
If you'd rather deploy by hand instead of via CI:
|
|
93
|
+
|
|
94
|
+
1. (Recommended) Generate a shared bearer token to gate share creation:
|
|
95
|
+
```bash
|
|
96
|
+
openssl rand -hex 32 | bunx wrangler secret put SHARE_AUTH_TOKEN --env prod
|
|
97
|
+
```
|
|
98
|
+
Without this, anyone on the internet can pump shares into your R2 bucket.
|
|
99
|
+
Set the same value as `CANVAS_SHARE_AUTH_TOKEN` on the daemon side.
|
|
100
|
+
|
|
101
|
+
2. (Optional) Override default 30-day share expiration:
|
|
102
|
+
```bash
|
|
103
|
+
echo "604800" | bunx wrangler secret put SHARE_TTL_SECONDS --env prod # 7 days
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
3. Build daemon bundles + deploy. The deploy script regenerates
|
|
107
|
+
`wrangler.jsonc` for the target env, then runs `wrangler deploy`.
|
|
108
|
+
The R2 bucket and KV namespace are created automatically by oblaka
|
|
109
|
+
on first deploy:
|
|
110
|
+
```bash
|
|
111
|
+
cd ../..
|
|
112
|
+
bun run build
|
|
113
|
+
cd workers/canvas-share
|
|
114
|
+
bun run deploy:prod # or deploy:stage
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
4. Configure the daemon:
|
|
118
|
+
```bash
|
|
119
|
+
export CANVAS_SHARE_ENDPOINT=https://canvas-share.<subdomain>.workers.dev
|
|
120
|
+
export CANVAS_SHARE_AUTH_TOKEN=<the token you generated>
|
|
121
|
+
bunx agent-canvas daemon restart
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The Share button in the canvas UI lights up once the daemon sees `CANVAS_SHARE_ENDPOINT`.
|
|
125
|
+
|
|
126
|
+
## Endpoints
|
|
127
|
+
|
|
128
|
+
| Method | Path | Purpose | Auth |
|
|
129
|
+
|--------|------------------------------------|-------------------------------------------------|---------------------|
|
|
130
|
+
| GET | `/health` | Health check | none |
|
|
131
|
+
| POST | `/shares` | Create a new share | `SHARE_AUTH_TOKEN` |
|
|
132
|
+
| POST | `/shares/:shareId/revoke` | Revoke / delete a share | `ownerToken` |
|
|
133
|
+
| GET | `/shares/:shareId/feedback?since=` | List feedback entries (called by daemon poller) | none (capability) |
|
|
134
|
+
| GET | `/s/:shareId` | HTML shell for reviewers | none (capability) |
|
|
135
|
+
| GET | `/s/:shareId/meta` | Share metadata | none |
|
|
136
|
+
| GET | `/s/:shareId/canvas/:filename` | Serve compiled canvas JS / JSX source | none |
|
|
137
|
+
| POST | `/s/:shareId/feedback` | Submit feedback (called by reviewer browser) | none |
|
|
138
|
+
| POST | `/s/:shareId/upload` | Upload annotation image | none |
|
|
139
|
+
| GET | `/s/:shareId/uploads/:filename` | Serve uploaded image | none |
|
|
140
|
+
|
|
141
|
+
## Rate limits
|
|
142
|
+
|
|
143
|
+
Per-IP, per-minute, sliding window (KV-backed). Tweak in [`src/types.ts`](src/types.ts).
|
|
144
|
+
|
|
145
|
+
| Bucket | Limit |
|
|
146
|
+
|-----------------|-------|
|
|
147
|
+
| Share creation | 5/min |
|
|
148
|
+
| Feedback POST | 20/min/share |
|
|
149
|
+
| Upload | 30/min/share |
|
|
150
|
+
|
|
151
|
+
## Security model
|
|
152
|
+
|
|
153
|
+
- **Capability URLs** — anyone with `/s/:shareId` (24-char hex, ~96 bits entropy) has full view+comment access. Brute-forcing is impractical.
|
|
154
|
+
- **Owner token** — returned to the daemon at create time, required to revoke. Stored in the daemon's session metadata so the canvas author can revoke from the Share dialog.
|
|
155
|
+
- **Optional shared bearer token** (`SHARE_AUTH_TOKEN`) — gates share creation so only authorized daemons can push to the worker.
|
|
156
|
+
- **No reviewer auth** — reviewers self-report a name (stored locally in their browser), there is no signup. Suitable for "send to a colleague" flows, **not** public publishing.
|
|
157
|
+
- **Per-share TTL** — defaults to 30 days. Expired shares 404 and are eventually purged by KV's TTL.
|
|
158
|
+
- **Size limits** — share payload max 2 MB, feedback body max 256 KB, image upload max 5 MB.
|
|
159
|
+
- **Validation** — all incoming bodies are validated by Zod schemas before any storage write.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { define, KVNamespace, R2Bucket, Worker } from 'oblaka-iac'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-environment overrides. `local` is what `bunx oblaka oblaka.ts` uses
|
|
5
|
+
* when generating wrangler.jsonc for `wrangler dev --local`. `stage` and
|
|
6
|
+
* `prod` are picked when running `bunx oblaka oblaka.ts --env stage|prod`
|
|
7
|
+
* for actual Cloudflare deploys.
|
|
8
|
+
*
|
|
9
|
+
* Secrets (SHARE_AUTH_TOKEN, SHARE_TTL_SECONDS overrides, etc.) are NOT
|
|
10
|
+
* declared here — set them with `wrangler secret put` after deploy.
|
|
11
|
+
*/
|
|
12
|
+
const vars = {
|
|
13
|
+
local: {
|
|
14
|
+
// Empty share auth in local dev so smoke tests don't need a token.
|
|
15
|
+
},
|
|
16
|
+
stage: {},
|
|
17
|
+
prod: {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default define(({ env }) => {
|
|
21
|
+
if (!(env in vars)) {
|
|
22
|
+
throw new Error(`Unknown environment ${env}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const blobs = new R2Bucket({
|
|
26
|
+
name: 'canvas-share-blobs',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const feedback = new KVNamespace({
|
|
30
|
+
name: 'canvas-share-feedback',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return new Worker({
|
|
34
|
+
dir: '.',
|
|
35
|
+
name: 'canvas-share',
|
|
36
|
+
main: './src/index.ts',
|
|
37
|
+
compatibility_flags: ['nodejs_compat'],
|
|
38
|
+
compatibility_date: '2025-01-01',
|
|
39
|
+
observability: { enabled: true },
|
|
40
|
+
// Runtime bundles (preact-compat, runtime, components, client.js + css)
|
|
41
|
+
// are mirrored here from daemon/dist/ by `bun run build` in the repo
|
|
42
|
+
// root. The worker serves them via the ASSETS binding.
|
|
43
|
+
assets: {
|
|
44
|
+
directory: './public',
|
|
45
|
+
binding: 'ASSETS',
|
|
46
|
+
},
|
|
47
|
+
bindings: {
|
|
48
|
+
BLOBS: blobs,
|
|
49
|
+
FEEDBACK: feedback,
|
|
50
|
+
},
|
|
51
|
+
vars: {
|
|
52
|
+
ENVIRONMENT: env,
|
|
53
|
+
...vars[env as keyof typeof vars],
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
})
|