agent-canvas 0.1.41 → 0.1.42
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 +18 -1
- package/bin/agent-canvas.ts +5 -1
- package/bin/lib/commands/exec.test.ts +27 -0
- package/bin/lib/commands/exec.ts +129 -0
- package/bin/lib/commands/watch.ts +8 -1
- package/bin/lib/config.ts +1 -0
- package/bin/lib/daemon-lifecycle.ts +3 -1
- package/bin/lib/helpers.test.ts +48 -0
- package/bin/lib/helpers.ts +65 -15
- package/bin/lib/instructions/component-secretinput.md +61 -0
- package/bin/lib/instructions/flow-runbook.md +77 -0
- package/bin/lib/instructions/overview.md +3 -0
- package/daemon/client/AnnotationProvider.tsx +2 -7
- package/daemon/client/App.tsx +12 -2
- package/daemon/client/annotationDraft.test.ts +16 -1
- package/daemon/client/annotationDraft.ts +15 -0
- package/daemon/client/clientApi.ts +41 -0
- package/daemon/client/components/SecretInput.tsx +156 -0
- package/daemon/client/components/index.ts +1 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +28 -28
- package/daemon/dist/components.js +42 -40
- package/daemon/src/compiler.test.ts +5 -0
- package/daemon/src/compiler.ts +2 -2
- package/daemon/src/handlers/api.ts +81 -0
- package/daemon/src/handlers/utils.ts +1 -1
- package/daemon/src/paths.ts +3 -0
- package/daemon/src/secrets-api.test.ts +123 -0
- package/daemon/src/server.ts +9 -5
- package/daemon/src/session-secrets.test.ts +83 -0
- package/daemon/src/session.ts +55 -11
- package/package.json +1 -1
- package/skills/canvas/SKILL.md +2 -1
- package/workers/canvas-share/public/assets/client.css +1 -1
- package/workers/canvas-share/public/assets/client.js +28 -28
- package/workers/canvas-share/public/assets/components.js +42 -40
|
@@ -37,3 +37,8 @@ test("well-formed canvas compiles", async () => {
|
|
|
37
37
|
const result = await compilePlan(`<Section title="Hello"><Item id="a" label="b">text</Item></Section>`);
|
|
38
38
|
expect(result.ok).toBe(true);
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
test("runbook secret input compiles", async () => {
|
|
42
|
+
const result = await compilePlan(`<SecretInput id="service-token" label="Service token" env="SERVICE_TOKEN" required />`);
|
|
43
|
+
expect(result.ok).toBe(true);
|
|
44
|
+
});
|
package/daemon/src/compiler.ts
CHANGED
|
@@ -14,7 +14,7 @@ const COMPONENT_IMPORTS = `import React from 'react';
|
|
|
14
14
|
import * as C from '#canvas/components';
|
|
15
15
|
const { Section, Item, Task, FilePreview, CodeBlock, Callout,
|
|
16
16
|
Mermaid, Table, Priority, Checklist, Note, Diff,
|
|
17
|
-
Choice, MultiChoice, UserInput, RangeInput, ImageView,
|
|
17
|
+
Choice, MultiChoice, UserInput, RangeInput, SecretInput, ImageView,
|
|
18
18
|
Markdown, useFeedback, useAnnotations } = C;
|
|
19
19
|
`;
|
|
20
20
|
|
|
@@ -40,7 +40,7 @@ const STUB_COMPONENTS: Record<string, any> = {};
|
|
|
40
40
|
for (const name of [
|
|
41
41
|
"Section", "Item", "Task", "FilePreview", "CodeBlock", "Callout",
|
|
42
42
|
"Table", "Priority", "Checklist", "Note", "Diff",
|
|
43
|
-
"Choice", "MultiChoice", "UserInput", "RangeInput", "ImageView", "Markdown",
|
|
43
|
+
"Choice", "MultiChoice", "UserInput", "RangeInput", "SecretInput", "ImageView", "Markdown",
|
|
44
44
|
]) {
|
|
45
45
|
STUB_COMPONENTS[name] = Stub;
|
|
46
46
|
}
|
|
@@ -6,12 +6,31 @@ import { jsonResponse } from "./utils";
|
|
|
6
6
|
import { loadShareConfig, shareRevision, revokeShare } from "../share";
|
|
7
7
|
import type { Route } from "../router";
|
|
8
8
|
|
|
9
|
+
const SECRET_FIELD_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
10
|
+
const MAX_SECRET_BYTES = 64 * 1024;
|
|
11
|
+
const MAX_RESOLVED_SECRETS = 32;
|
|
12
|
+
|
|
13
|
+
function secretJsonResponse(data: unknown, status = 200): Response {
|
|
14
|
+
const response = jsonResponse(data, status);
|
|
15
|
+
response.headers.set("Cache-Control", "no-store");
|
|
16
|
+
return response;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isValidSecretField(fieldId: string): boolean {
|
|
24
|
+
return SECRET_FIELD_RE.test(fieldId);
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
export interface ApiContext {
|
|
10
28
|
sessionManager: SessionManager;
|
|
11
29
|
broadcastPlanUpdate: (id: string) => void;
|
|
12
30
|
broadcastRevisionUpdate: (id: string) => void;
|
|
13
31
|
port: number;
|
|
14
32
|
version: string;
|
|
33
|
+
cliAuthToken: string;
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
@@ -242,6 +261,64 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
|
242
261
|
return jsonResponse({ found: true, revision: result.revision, feedback: result.feedback });
|
|
243
262
|
}
|
|
244
263
|
|
|
264
|
+
async function handleSecretStore(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
265
|
+
const sessionId = match.pathname.groups.id!;
|
|
266
|
+
const fieldId = match.pathname.groups.field!;
|
|
267
|
+
if (!sessionManager.get(sessionId)) return secretJsonResponse({ error: "Session not found" }, 404);
|
|
268
|
+
if (!isValidSecretField(fieldId)) return secretJsonResponse({ error: "Invalid secret field ID" }, 400);
|
|
269
|
+
|
|
270
|
+
const raw: unknown = await req.json();
|
|
271
|
+
if (!isRecord(raw) || typeof raw.value !== "string" || raw.value.length === 0) {
|
|
272
|
+
return secretJsonResponse({ error: "Secret value must be a non-empty string" }, 400);
|
|
273
|
+
}
|
|
274
|
+
if (new TextEncoder().encode(raw.value).byteLength > MAX_SECRET_BYTES) {
|
|
275
|
+
return secretJsonResponse({ error: `Secret exceeds ${MAX_SECRET_BYTES} bytes` }, 413);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
sessionManager.setSecret(sessionId, fieldId, raw.value);
|
|
279
|
+
return secretJsonResponse({ ok: true, ready: true });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function handleSecretStatus(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
283
|
+
const sessionId = match.pathname.groups.id!;
|
|
284
|
+
const fieldId = match.pathname.groups.field!;
|
|
285
|
+
if (!sessionManager.get(sessionId)) return secretJsonResponse({ error: "Session not found" }, 404);
|
|
286
|
+
if (!isValidSecretField(fieldId)) return secretJsonResponse({ error: "Invalid secret field ID" }, 400);
|
|
287
|
+
return secretJsonResponse({ ready: sessionManager.hasSecret(sessionId, fieldId) });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function handleSecretClear(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
291
|
+
const sessionId = match.pathname.groups.id!;
|
|
292
|
+
const fieldId = match.pathname.groups.field!;
|
|
293
|
+
if (!sessionManager.get(sessionId)) return secretJsonResponse({ error: "Session not found" }, 404);
|
|
294
|
+
if (!isValidSecretField(fieldId)) return secretJsonResponse({ error: "Invalid secret field ID" }, 400);
|
|
295
|
+
sessionManager.clearSecret(sessionId, fieldId);
|
|
296
|
+
return secretJsonResponse({ ok: true, ready: false });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function handleSecretResolve(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
300
|
+
if (req.headers.get("X-Agent-Canvas-CLI-Token") !== ctx.cliAuthToken) {
|
|
301
|
+
return secretJsonResponse({ error: "Secret resolution is available only to the local CLI" }, 403);
|
|
302
|
+
}
|
|
303
|
+
const sessionId = match.pathname.groups.id!;
|
|
304
|
+
if (!sessionManager.get(sessionId)) return secretJsonResponse({ error: "Session not found" }, 404);
|
|
305
|
+
|
|
306
|
+
const raw: unknown = await req.json();
|
|
307
|
+
const fields = isRecord(raw) && Array.isArray(raw.fields) ? raw.fields : null;
|
|
308
|
+
if (!fields || fields.length === 0 || fields.length > MAX_RESOLVED_SECRETS || !fields.every((field) => typeof field === "string" && isValidSecretField(field))) {
|
|
309
|
+
return secretJsonResponse({ error: `fields must contain 1-${MAX_RESOLVED_SECRETS} valid secret field IDs` }, 400);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const uniqueFields = [...new Set(fields)];
|
|
313
|
+
const result = sessionManager.resolveSecrets(sessionId, uniqueFields);
|
|
314
|
+
if (!result) return secretJsonResponse({ error: "Session not found" }, 404);
|
|
315
|
+
if (!result.ok) return secretJsonResponse({ error: "Required secrets are not ready", missing: result.missing }, 409);
|
|
316
|
+
|
|
317
|
+
return secretJsonResponse({
|
|
318
|
+
values: [...result.values].map(([id, value]) => ({ id, value })),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
245
322
|
return [
|
|
246
323
|
{ method: "GET", pattern: new URLPattern({ pathname: "/health" }), handler: handleHealth },
|
|
247
324
|
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/plan" }), handler: handlePlanPost },
|
|
@@ -249,6 +326,10 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
|
249
326
|
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/meta" }), handler: handleMeta },
|
|
250
327
|
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/feedback" }), handler: handleFeedbackGet },
|
|
251
328
|
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/feedback/consume" }), handler: handleFeedbackConsume },
|
|
329
|
+
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/secrets/resolve" }), handler: handleSecretResolve },
|
|
330
|
+
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/secrets/:field/value" }), handler: handleSecretStore },
|
|
331
|
+
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/secrets/:field/status" }), handler: handleSecretStatus },
|
|
332
|
+
{ method: "DELETE", pattern: new URLPattern({ pathname: "/api/session/:id/secrets/:field/value" }), handler: handleSecretClear },
|
|
252
333
|
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/share" }), handler: handleShareRevision },
|
|
253
334
|
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/shares/:shareId/revoke" }), handler: handleShareRevoke },
|
|
254
335
|
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/shares" }), handler: handleSharesList },
|
|
@@ -8,7 +8,7 @@ export function jsonResponse(data: unknown, status = 200): Response {
|
|
|
8
8
|
export function corsHeaders(port: number): Record<string, string> {
|
|
9
9
|
return {
|
|
10
10
|
"Access-Control-Allow-Origin": "http://localhost:" + port,
|
|
11
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
11
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
12
12
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
13
13
|
};
|
|
14
14
|
}
|
package/daemon/src/paths.ts
CHANGED
|
@@ -14,6 +14,9 @@ export const TEMP_DIR = join(tmpdir(), "agent-canvas");
|
|
|
14
14
|
/** PID file for daemon process */
|
|
15
15
|
export const PID_FILE = join(TEMP_DIR, "daemon.pid");
|
|
16
16
|
|
|
17
|
+
/** Capability used only by the CLI to resolve secret values */
|
|
18
|
+
export const CLI_AUTH_FILE = join(TEMP_DIR, "daemon-auth-token");
|
|
19
|
+
|
|
17
20
|
/** Temp dir for JSX compilation */
|
|
18
21
|
export const COMPILE_TEMP_DIR = join(TEMP_DIR, "compile");
|
|
19
22
|
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { dispatch, type Route } from "./router";
|
|
7
|
+
import { SessionManager } from "./session";
|
|
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
|
+
});
|
package/daemon/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, readFileSync } from "fs";
|
|
1
|
+
import { writeFileSync, mkdirSync, readFileSync, chmodSync } from "fs";
|
|
2
2
|
import { join, dirname } from "path";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
4
5
|
import { SessionManager } from "./session";
|
|
5
6
|
import { createWebSocketManager, type WSData } from "./websocket";
|
|
6
7
|
import { dispatch } from "./router";
|
|
@@ -10,20 +11,25 @@ import { createFileHandlers } from "./handlers/files";
|
|
|
10
11
|
import { createStaticHandlers } from "./handlers/static";
|
|
11
12
|
import { createUploadHandlers } from "./handlers/uploads";
|
|
12
13
|
import { startRemoteFeedbackPoller } from "./remote-feedback";
|
|
14
|
+
import { CLI_AUTH_FILE } from "./paths";
|
|
13
15
|
|
|
14
16
|
const PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
|
|
15
17
|
const VERSION = JSON.parse(readFileSync(join(dirname(import.meta.dir), "..", "package.json"), "utf-8")).version as string;
|
|
16
18
|
|
|
17
19
|
// Write PID file so CLI can find and stop us
|
|
18
20
|
const pidDir = join(tmpdir(), "agent-canvas");
|
|
19
|
-
mkdirSync(pidDir, { recursive: true });
|
|
21
|
+
mkdirSync(pidDir, { recursive: true, mode: 0o700 });
|
|
22
|
+
chmodSync(pidDir, 0o700);
|
|
20
23
|
writeFileSync(join(pidDir, "daemon.pid"), String(process.pid));
|
|
24
|
+
const cliAuthToken = randomBytes(32).toString("base64url");
|
|
25
|
+
writeFileSync(CLI_AUTH_FILE, cliAuthToken, { mode: 0o600 });
|
|
26
|
+
chmodSync(CLI_AUTH_FILE, 0o600);
|
|
21
27
|
|
|
22
28
|
const sessionManager = new SessionManager();
|
|
23
29
|
const wsManager = createWebSocketManager(sessionManager);
|
|
24
30
|
|
|
25
31
|
const routes = [
|
|
26
|
-
...createApiHandlers({ sessionManager, broadcastPlanUpdate: wsManager.broadcastPlanUpdate, broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate, port: PORT, version: VERSION }),
|
|
32
|
+
...createApiHandlers({ sessionManager, broadcastPlanUpdate: wsManager.broadcastPlanUpdate, broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate, port: PORT, version: VERSION, cliAuthToken }),
|
|
27
33
|
...createFileHandlers(sessionManager),
|
|
28
34
|
...createUploadHandlers(sessionManager),
|
|
29
35
|
...createStaticHandlers(),
|
|
@@ -81,8 +87,6 @@ const server = Bun.serve<WSData>({
|
|
|
81
87
|
export { sessionManager, server };
|
|
82
88
|
export const broadcastPlanUpdate = wsManager.broadcastPlanUpdate;
|
|
83
89
|
|
|
84
|
-
setInterval(() => sessionManager.cleanupStale(), 60 * 60 * 1000);
|
|
85
|
-
|
|
86
90
|
// Start polling for remote feedback from shared canvases.
|
|
87
91
|
// No-ops if CANVAS_SHARE_ENDPOINT is not set.
|
|
88
92
|
startRemoteFeedbackPoller(sessionManager, wsManager.broadcastRemoteFeedback, VERSION);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { SessionManager } from "./session";
|
|
6
|
+
|
|
7
|
+
const testDirectories: string[] = [];
|
|
8
|
+
|
|
9
|
+
function createManager(): SessionManager {
|
|
10
|
+
const directory = mkdtempSync(join(tmpdir(), "agent-canvas-secrets-"));
|
|
11
|
+
testDirectories.push(directory);
|
|
12
|
+
return new SessionManager(directory);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createSession(manager: SessionManager, id = "runbook"): void {
|
|
16
|
+
manager.upsert(id, new Map([["runbook.jsx", "<Section title=\"Runbook\" />"]]), process.cwd());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readTextFiles(directory: string): string {
|
|
20
|
+
const parts: string[] = [];
|
|
21
|
+
for (const entry of readdirSync(directory)) {
|
|
22
|
+
const path = join(directory, entry);
|
|
23
|
+
if (statSync(path).isDirectory()) {
|
|
24
|
+
parts.push(readTextFiles(path));
|
|
25
|
+
} else {
|
|
26
|
+
parts.push(readFileSync(path, "utf-8"));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return parts.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const directory of testDirectories.splice(0)) {
|
|
34
|
+
rmSync(directory, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("session secret vault", () => {
|
|
39
|
+
test("resolves a deposited secret without persisting it", () => {
|
|
40
|
+
const manager = createManager();
|
|
41
|
+
createSession(manager);
|
|
42
|
+
|
|
43
|
+
expect(manager.setSecret("runbook", "service-token", "test-secret-value")).toBe(true);
|
|
44
|
+
expect(manager.hasSecret("runbook", "service-token")).toBe(true);
|
|
45
|
+
expect(manager.resolveSecrets("runbook", ["service-token"])).toEqual({
|
|
46
|
+
ok: true,
|
|
47
|
+
values: new Map([["service-token", "test-secret-value"]]),
|
|
48
|
+
});
|
|
49
|
+
expect(readTextFiles(testDirectories[0])).not.toContain("test-secret-value");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("reports every missing field without returning partial values", () => {
|
|
53
|
+
const manager = createManager();
|
|
54
|
+
createSession(manager);
|
|
55
|
+
manager.setSecret("runbook", "ready-token", "available");
|
|
56
|
+
|
|
57
|
+
expect(manager.resolveSecrets("runbook", ["ready-token", "missing-token"])).toEqual({
|
|
58
|
+
ok: false,
|
|
59
|
+
missing: ["missing-token"],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("clears secrets when a new revision is pushed", () => {
|
|
64
|
+
const manager = createManager();
|
|
65
|
+
createSession(manager);
|
|
66
|
+
manager.setSecret("runbook", "service-token", "test-secret-value");
|
|
67
|
+
|
|
68
|
+
manager.upsert("runbook", new Map([["runbook.jsx", "<Section title=\"Updated\" />"]]), process.cwd());
|
|
69
|
+
|
|
70
|
+
expect(manager.hasSecret("runbook", "service-token")).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("clears one field without affecting the rest", () => {
|
|
74
|
+
const manager = createManager();
|
|
75
|
+
createSession(manager);
|
|
76
|
+
manager.setSecret("runbook", "first-token", "first");
|
|
77
|
+
manager.setSecret("runbook", "second-token", "second");
|
|
78
|
+
|
|
79
|
+
expect(manager.clearSecret("runbook", "first-token")).toBe(true);
|
|
80
|
+
expect(manager.hasSecret("runbook", "first-token")).toBe(false);
|
|
81
|
+
expect(manager.hasSecret("runbook", "second-token")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/daemon/src/session.ts
CHANGED
|
@@ -96,6 +96,10 @@ export interface SessionData {
|
|
|
96
96
|
shares?: ShareEntry[];
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export type SecretResolution =
|
|
100
|
+
| { ok: true; values: Map<string, string> }
|
|
101
|
+
| { ok: false; missing: string[] };
|
|
102
|
+
|
|
99
103
|
interface SessionMeta {
|
|
100
104
|
projectRoot: string;
|
|
101
105
|
createdAt: string;
|
|
@@ -125,8 +129,6 @@ interface LegacyRevisionInfo {
|
|
|
125
129
|
diffStats?: DiffStats;
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
const STALE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
129
|
-
|
|
130
132
|
function computeLineDiffStats(oldText: string, newText: string): DiffStats {
|
|
131
133
|
const oldLines = oldText.split("\n");
|
|
132
134
|
const newLines = newText.split("\n");
|
|
@@ -150,6 +152,7 @@ function computeLineDiffStats(oldText: string, newText: string): DiffStats {
|
|
|
150
152
|
|
|
151
153
|
export class SessionManager {
|
|
152
154
|
private sessions = new Map<string, SessionData>();
|
|
155
|
+
private sessionSecrets = new Map<string, { revision: number; values: Map<string, string> }>();
|
|
153
156
|
private readonly sessionsDir: string;
|
|
154
157
|
|
|
155
158
|
/**
|
|
@@ -336,6 +339,7 @@ export class SessionManager {
|
|
|
336
339
|
upsert(id: string, canvasFiles: Map<string, string>, projectRoot: string, label?: string, response?: string): SessionData {
|
|
337
340
|
const existing = this.sessions.get(id);
|
|
338
341
|
const now = new Date().toISOString();
|
|
342
|
+
// Stable session IDs must never recycle revision numbers used as browser draft keys.
|
|
339
343
|
const revision = existing ? existing.currentRevision + 1 : 1;
|
|
340
344
|
|
|
341
345
|
// Compute per-file diffStats against the previous revision
|
|
@@ -383,6 +387,8 @@ export class SessionManager {
|
|
|
383
387
|
updatedAt: now,
|
|
384
388
|
};
|
|
385
389
|
|
|
390
|
+
// Secrets belong to one revision and must never carry into a new runbook.
|
|
391
|
+
this.sessionSecrets.delete(id);
|
|
386
392
|
this.sessions.set(id, session);
|
|
387
393
|
this.persistToDisk(session, canvasFiles);
|
|
388
394
|
return session;
|
|
@@ -454,9 +460,56 @@ export class SessionManager {
|
|
|
454
460
|
|
|
455
461
|
remove(id: string) {
|
|
456
462
|
this.sessions.delete(id);
|
|
463
|
+
this.sessionSecrets.delete(id);
|
|
457
464
|
rmSync(this.sessionDir(id), { recursive: true, force: true });
|
|
458
465
|
}
|
|
459
466
|
|
|
467
|
+
setSecret(id: string, fieldId: string, value: string): boolean {
|
|
468
|
+
const session = this.sessions.get(id);
|
|
469
|
+
if (!session) return false;
|
|
470
|
+
|
|
471
|
+
let secrets = this.sessionSecrets.get(id);
|
|
472
|
+
if (!secrets || secrets.revision !== session.currentRevision) {
|
|
473
|
+
secrets = { revision: session.currentRevision, values: new Map() };
|
|
474
|
+
this.sessionSecrets.set(id, secrets);
|
|
475
|
+
}
|
|
476
|
+
secrets.values.set(fieldId, value);
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
hasSecret(id: string, fieldId: string): boolean {
|
|
481
|
+
const session = this.sessions.get(id);
|
|
482
|
+
const secrets = this.sessionSecrets.get(id);
|
|
483
|
+
return !!session
|
|
484
|
+
&& secrets?.revision === session.currentRevision
|
|
485
|
+
&& secrets.values.has(fieldId);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
clearSecret(id: string, fieldId: string): boolean {
|
|
489
|
+
const session = this.sessions.get(id);
|
|
490
|
+
const secrets = this.sessionSecrets.get(id);
|
|
491
|
+
if (!session || secrets?.revision !== session.currentRevision) return false;
|
|
492
|
+
return secrets.values.delete(fieldId);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
resolveSecrets(id: string, fieldIds: string[]): SecretResolution | null {
|
|
496
|
+
const session = this.sessions.get(id);
|
|
497
|
+
if (!session) return null;
|
|
498
|
+
|
|
499
|
+
const secrets = this.sessionSecrets.get(id);
|
|
500
|
+
const missing = fieldIds.filter((fieldId) =>
|
|
501
|
+
secrets?.revision !== session.currentRevision || !secrets.values.has(fieldId)
|
|
502
|
+
);
|
|
503
|
+
if (missing.length > 0) return { ok: false, missing };
|
|
504
|
+
|
|
505
|
+
const values = new Map<string, string>();
|
|
506
|
+
for (const fieldId of fieldIds) {
|
|
507
|
+
const value = secrets?.values.get(fieldId);
|
|
508
|
+
if (value !== undefined) values.set(fieldId, value);
|
|
509
|
+
}
|
|
510
|
+
return { ok: true, values };
|
|
511
|
+
}
|
|
512
|
+
|
|
460
513
|
saveCompiled(id: string, filename: string, js: string, rev?: number) {
|
|
461
514
|
const session = this.sessions.get(id);
|
|
462
515
|
const revision = rev ?? session?.currentRevision;
|
|
@@ -556,13 +609,4 @@ export class SessionManager {
|
|
|
556
609
|
return [];
|
|
557
610
|
}
|
|
558
611
|
}
|
|
559
|
-
|
|
560
|
-
cleanupStale(maxAge = STALE_TIMEOUT_MS) {
|
|
561
|
-
const now = Date.now();
|
|
562
|
-
for (const [id, session] of this.sessions) {
|
|
563
|
-
if (now - new Date(session.updatedAt).getTime() > maxAge) {
|
|
564
|
-
this.remove(id);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
612
|
}
|
package/package.json
CHANGED
package/skills/canvas/SKILL.md
CHANGED
|
@@ -6,7 +6,8 @@ description: >
|
|
|
6
6
|
comments, answers questions, and submits feedback.
|
|
7
7
|
|
|
8
8
|
Supports planning, architecture reviews, code reviews, discovery interviews,
|
|
9
|
-
implementation summaries, proposals, decision documents, and
|
|
9
|
+
implementation summaries, proposals, decision documents, explanations, and
|
|
10
|
+
runbooks that hand local secrets to commands without putting values in context.
|
|
10
11
|
disable-model-invocation: true
|
|
11
12
|
|
|
12
13
|
---
|