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.
Files changed (36) hide show
  1. package/README.md +18 -1
  2. package/bin/agent-canvas.ts +5 -1
  3. package/bin/lib/commands/exec.test.ts +27 -0
  4. package/bin/lib/commands/exec.ts +129 -0
  5. package/bin/lib/commands/watch.ts +8 -1
  6. package/bin/lib/config.ts +1 -0
  7. package/bin/lib/daemon-lifecycle.ts +3 -1
  8. package/bin/lib/helpers.test.ts +48 -0
  9. package/bin/lib/helpers.ts +65 -15
  10. package/bin/lib/instructions/component-secretinput.md +61 -0
  11. package/bin/lib/instructions/flow-runbook.md +77 -0
  12. package/bin/lib/instructions/overview.md +3 -0
  13. package/daemon/client/AnnotationProvider.tsx +2 -7
  14. package/daemon/client/App.tsx +12 -2
  15. package/daemon/client/annotationDraft.test.ts +16 -1
  16. package/daemon/client/annotationDraft.ts +15 -0
  17. package/daemon/client/clientApi.ts +41 -0
  18. package/daemon/client/components/SecretInput.tsx +156 -0
  19. package/daemon/client/components/index.ts +1 -0
  20. package/daemon/dist/client.css +1 -1
  21. package/daemon/dist/client.js +28 -28
  22. package/daemon/dist/components.js +42 -40
  23. package/daemon/src/compiler.test.ts +5 -0
  24. package/daemon/src/compiler.ts +2 -2
  25. package/daemon/src/handlers/api.ts +81 -0
  26. package/daemon/src/handlers/utils.ts +1 -1
  27. package/daemon/src/paths.ts +3 -0
  28. package/daemon/src/secrets-api.test.ts +123 -0
  29. package/daemon/src/server.ts +9 -5
  30. package/daemon/src/session-secrets.test.ts +83 -0
  31. package/daemon/src/session.ts +55 -11
  32. package/package.json +1 -1
  33. package/skills/canvas/SKILL.md +2 -1
  34. package/workers/canvas-share/public/assets/client.css +1 -1
  35. package/workers/canvas-share/public/assets/client.js +28 -28
  36. 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
+ });
@@ -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
  }
@@ -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
+ });
@@ -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
+ });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-canvas",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
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"
@@ -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 explanations.
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
  ---