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
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ When Claude needs to present a plan, review, explanation, or decision — instea
|
|
|
16
16
|
- **Browse files** referenced in the canvas
|
|
17
17
|
- **Review revisions** as Claude iterates
|
|
18
18
|
- **Send structured feedback** back to Claude in one click
|
|
19
|
+
- **Hand off secrets locally** without placing values in model context or feedback
|
|
19
20
|
|
|
20
21
|
## Install
|
|
21
22
|
|
|
@@ -54,6 +55,22 @@ Claude uses different canvas flows depending on the task:
|
|
|
54
55
|
| **Explain** | "How does X work?" | Single canvas |
|
|
55
56
|
| **Review** | Code/architecture review | Findings → Fix Plan → Summary |
|
|
56
57
|
| **Decision** | Choosing between options | Single canvas with comparison |
|
|
58
|
+
| **Runbook** | User must complete manual setup or provide a secret | Instructions → secure handoff → command → result |
|
|
59
|
+
|
|
60
|
+
### Local secret handoff
|
|
61
|
+
|
|
62
|
+
Runbooks can use `SecretInput` when a command needs a token that must stay out of feedback and model context. The browser stores the value only in the local daemon's memory. Feedback contains the field ID and environment variable name, not the value.
|
|
63
|
+
|
|
64
|
+
The agent then runs the target command through the local wrapper:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bunx agent-canvas exec \
|
|
68
|
+
--session <session-id> \
|
|
69
|
+
--secret SERVICE_TOKEN=service-token \
|
|
70
|
+
-- service-cli deploy
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Secret resolution requires a daemon capability available to the CLI but not to canvas JavaScript. A new canvas revision, daemon shutdown, or session cleanup clears the value. The target command must not print its environment or credentials.
|
|
57
74
|
|
|
58
75
|
## Theming
|
|
59
76
|
|
|
@@ -72,7 +89,7 @@ bun daemon/build.ts --watch # watch mode
|
|
|
72
89
|
|
|
73
90
|
# Test with demo canvas
|
|
74
91
|
bun bin/agent-canvas.ts daemon start
|
|
75
|
-
CANVAS_SESSION_ID=demo bun bin/agent-canvas.ts push example
|
|
92
|
+
CANVAS_SESSION_ID=demo bun bin/agent-canvas.ts push example --label "Demo"
|
|
76
93
|
# Open http://localhost:19400/s/demo
|
|
77
94
|
```
|
|
78
95
|
|
package/bin/agent-canvas.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { handleWatch } from "./lib/commands/watch.ts";
|
|
|
6
6
|
import { handleDaemon } from "./lib/commands/daemon.ts";
|
|
7
7
|
import { handleInstall } from "./lib/commands/install.ts";
|
|
8
8
|
import { handleInstructions } from "./lib/commands/instructions.ts";
|
|
9
|
+
import { handleExec } from "./lib/commands/exec.ts";
|
|
9
10
|
import { VERSION } from "./lib/config.ts";
|
|
10
11
|
|
|
11
12
|
function printUsage() {
|
|
@@ -14,9 +15,11 @@ function printUsage() {
|
|
|
14
15
|
Commands:
|
|
15
16
|
agent-canvas install [local|global] Install skill for Claude Code
|
|
16
17
|
agent-canvas instructions [topic] Show usage instructions (for AI agents)
|
|
17
|
-
agent-canvas push <
|
|
18
|
+
agent-canvas push <directory> Push canvases, open browser
|
|
18
19
|
agent-canvas fetch [--session <id>] Check for feedback (returns immediately)
|
|
19
20
|
agent-canvas watch [--session <id>] Wait for user feedback (blocks until submitted)
|
|
21
|
+
agent-canvas exec --secret ENV=id -- <command>
|
|
22
|
+
Run a command with an in-memory secret
|
|
20
23
|
agent-canvas daemon status Show daemon status
|
|
21
24
|
agent-canvas daemon stop Stop the daemon
|
|
22
25
|
agent-canvas daemon start Start the daemon
|
|
@@ -46,6 +49,7 @@ async function main() {
|
|
|
46
49
|
case "push": return handlePush(rest);
|
|
47
50
|
case "fetch": return handleFetch(rest);
|
|
48
51
|
case "watch": return handleWatch(rest);
|
|
52
|
+
case "exec": return handleExec(rest);
|
|
49
53
|
case "instructions": return handleInstructions(rest);
|
|
50
54
|
case "install": return handleInstall(rest);
|
|
51
55
|
case "daemon": return handleDaemon(rest);
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { parseArgs } from "util";
|
|
4
|
+
import { BASE_URL, CLI_AUTH_FILE } from "../config.ts";
|
|
5
|
+
import { ensureDaemon } from "../daemon-lifecycle.ts";
|
|
6
|
+
import { getSessionId } from "../helpers.ts";
|
|
7
|
+
|
|
8
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9
|
+
const FIELD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
10
|
+
|
|
11
|
+
export interface SecretAssignment {
|
|
12
|
+
envName: string;
|
|
13
|
+
fieldId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseSecretAssignments(rawAssignments: string[]): SecretAssignment[] {
|
|
21
|
+
if (rawAssignments.length === 0) {
|
|
22
|
+
throw new Error("At least one --secret ENV_NAME=field-id mapping is required");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const assignments: SecretAssignment[] = [];
|
|
26
|
+
const environmentNames = new Set<string>();
|
|
27
|
+
for (const raw of rawAssignments) {
|
|
28
|
+
const separator = raw.indexOf("=");
|
|
29
|
+
const envName = separator === -1 ? "" : raw.slice(0, separator);
|
|
30
|
+
const fieldId = separator === -1 ? "" : raw.slice(separator + 1);
|
|
31
|
+
if (!ENV_NAME_RE.test(envName)) {
|
|
32
|
+
throw new Error(`Invalid environment variable name in --secret mapping: ${raw}`);
|
|
33
|
+
}
|
|
34
|
+
if (!FIELD_ID_RE.test(fieldId)) {
|
|
35
|
+
throw new Error(`Invalid secret field ID in --secret mapping: ${raw}`);
|
|
36
|
+
}
|
|
37
|
+
if (environmentNames.has(envName)) {
|
|
38
|
+
throw new Error(`Duplicate environment variable mapping: ${envName}`);
|
|
39
|
+
}
|
|
40
|
+
environmentNames.add(envName);
|
|
41
|
+
assignments.push({ envName, fieldId });
|
|
42
|
+
}
|
|
43
|
+
return assignments;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function resolveSecretValues(sessionId: string, fieldIds: string[]): Promise<Map<string, string>> {
|
|
47
|
+
if (!existsSync(CLI_AUTH_FILE)) throw new Error("Canvas daemon CLI capability is unavailable");
|
|
48
|
+
const cliAuthToken = readFileSync(CLI_AUTH_FILE, "utf-8").trim();
|
|
49
|
+
const response = await fetch(`${BASE_URL}/api/session/${encodeURIComponent(sessionId)}/secrets/resolve`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
"X-Agent-Canvas-CLI-Token": cliAuthToken,
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({ fields: fieldIds }),
|
|
56
|
+
});
|
|
57
|
+
const raw: unknown = await response.json().catch(() => null);
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const message = isRecord(raw) && typeof raw.error === "string"
|
|
60
|
+
? raw.error
|
|
61
|
+
: `Secret API request failed with status ${response.status}`;
|
|
62
|
+
const missing = isRecord(raw) && Array.isArray(raw.missing)
|
|
63
|
+
? raw.missing.filter((field): field is string => typeof field === "string")
|
|
64
|
+
: [];
|
|
65
|
+
throw new Error(missing.length > 0 ? `${message}: ${missing.join(", ")}` : message);
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(raw) || !Array.isArray(raw.values)) {
|
|
68
|
+
throw new Error("Secret API returned an invalid response");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const values = new Map<string, string>();
|
|
72
|
+
for (const entry of raw.values) {
|
|
73
|
+
if (!isRecord(entry) || typeof entry.id !== "string" || typeof entry.value !== "string") {
|
|
74
|
+
throw new Error("Secret API returned an invalid value entry");
|
|
75
|
+
}
|
|
76
|
+
values.set(entry.id, entry.value);
|
|
77
|
+
}
|
|
78
|
+
return values;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function executableExists(command: string): boolean {
|
|
82
|
+
if (command.includes("/")) return existsSync(resolve(command));
|
|
83
|
+
return Bun.spawnSync(["which", command], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function spawnWithSecretEnvironment(
|
|
87
|
+
commandArgs: string[],
|
|
88
|
+
assignments: SecretAssignment[],
|
|
89
|
+
values: Map<string, string>,
|
|
90
|
+
): Promise<number> {
|
|
91
|
+
const command = commandArgs[0];
|
|
92
|
+
if (!command) throw new Error("No command specified after --");
|
|
93
|
+
if (!executableExists(command)) throw new Error(`Executable not found: ${command}`);
|
|
94
|
+
|
|
95
|
+
const environment = { ...process.env };
|
|
96
|
+
for (const assignment of assignments) {
|
|
97
|
+
const value = values.get(assignment.fieldId);
|
|
98
|
+
if (value === undefined) throw new Error(`Secret is not ready: ${assignment.fieldId}`);
|
|
99
|
+
environment[assignment.envName] = value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const child = Bun.spawn(commandArgs, {
|
|
103
|
+
env: environment,
|
|
104
|
+
stdin: "inherit",
|
|
105
|
+
stdout: "inherit",
|
|
106
|
+
stderr: "inherit",
|
|
107
|
+
});
|
|
108
|
+
return child.exited;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function handleExec(args: string[]): Promise<void> {
|
|
112
|
+
const { values, positionals } = parseArgs({
|
|
113
|
+
args,
|
|
114
|
+
options: {
|
|
115
|
+
session: { type: "string" },
|
|
116
|
+
secret: { type: "string", multiple: true },
|
|
117
|
+
},
|
|
118
|
+
allowPositionals: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const assignments = parseSecretAssignments(values.secret ?? []);
|
|
122
|
+
if (positionals.length === 0) throw new Error("No command specified. Put the command after --");
|
|
123
|
+
|
|
124
|
+
const sessionId = getSessionId(values.session);
|
|
125
|
+
await ensureDaemon();
|
|
126
|
+
const secretValues = await resolveSecretValues(sessionId, assignments.map((assignment) => assignment.fieldId));
|
|
127
|
+
const exitCode = await spawnWithSecretEnvironment(positionals, assignments, secretValues);
|
|
128
|
+
process.exit(exitCode);
|
|
129
|
+
}
|
|
@@ -19,5 +19,12 @@ export async function handleWatch(args: string[]) {
|
|
|
19
19
|
process.exit(0);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
try {
|
|
23
|
+
const submittedFeedback = await waitForFeedback(sessionId);
|
|
24
|
+
process.stdout.write(submittedFeedback);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
console.error(`Error: ${message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
23
30
|
}
|
package/bin/lib/config.ts
CHANGED
|
@@ -10,3 +10,4 @@ export const BASE_URL = `http://localhost:${DAEMON_PORT}`;
|
|
|
10
10
|
export const WS_URL = `ws://localhost:${DAEMON_PORT}`;
|
|
11
11
|
export const TIMEOUT_MS = parseInt(process.env.CANVAS_TIMEOUT || String(60 * 60 * 1000), 10);
|
|
12
12
|
export const PID_FILE = join(TEMP_DIR, "daemon.pid");
|
|
13
|
+
export const CLI_AUTH_FILE = join(TEMP_DIR, "daemon-auth-token");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { openSync, closeSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
|
-
import { PACKAGE_ROOT, TEMP_DIR, BASE_URL, PID_FILE, VERSION } from "./config.ts";
|
|
4
|
+
import { PACKAGE_ROOT, TEMP_DIR, BASE_URL, PID_FILE, CLI_AUTH_FILE, VERSION } from "./config.ts";
|
|
5
5
|
|
|
6
6
|
export async function isDaemonRunning(): Promise<boolean> {
|
|
7
7
|
try {
|
|
@@ -79,10 +79,12 @@ export function stopDaemon(): boolean {
|
|
|
79
79
|
process.kill(pid, "SIGTERM");
|
|
80
80
|
console.log("Daemon stopped.");
|
|
81
81
|
try { unlinkSync(PID_FILE); } catch {}
|
|
82
|
+
try { unlinkSync(CLI_AUTH_FILE); } catch {}
|
|
82
83
|
return true;
|
|
83
84
|
} catch {
|
|
84
85
|
console.log("Daemon was not running (stale PID file).");
|
|
85
86
|
try { unlinkSync(PID_FILE); } catch {}
|
|
87
|
+
try { unlinkSync(CLI_AUTH_FILE); } catch {}
|
|
86
88
|
return false;
|
|
87
89
|
}
|
|
88
90
|
} else {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { waitForFeedback } from "./helpers";
|
|
3
|
+
|
|
4
|
+
class FakeSocket {
|
|
5
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
6
|
+
onerror: ((event: Event) => void) | null = null;
|
|
7
|
+
onclose: ((event: CloseEvent) => void) | null = null;
|
|
8
|
+
|
|
9
|
+
close(): void {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("waitForFeedback", () => {
|
|
13
|
+
test("reconnects after an unexpected close", async () => {
|
|
14
|
+
const sockets: FakeSocket[] = [];
|
|
15
|
+
const result = waitForFeedback("session", {
|
|
16
|
+
createSocket: () => {
|
|
17
|
+
const socket = new FakeSocket();
|
|
18
|
+
sockets.push(socket);
|
|
19
|
+
queueMicrotask(() => {
|
|
20
|
+
if (sockets.length === 1) socket.onclose?.(new CloseEvent("close"));
|
|
21
|
+
else socket.onmessage?.(new MessageEvent("message", { data: JSON.stringify({ type: "submit", feedback: "Done" }) }));
|
|
22
|
+
});
|
|
23
|
+
return socket;
|
|
24
|
+
},
|
|
25
|
+
timeoutMs: 1_000,
|
|
26
|
+
reconnectAttempts: 1,
|
|
27
|
+
reconnectDelayMs: 0,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await expect(result).resolves.toBe("Done");
|
|
31
|
+
expect(sockets).toHaveLength(2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rejects when the connection keeps closing", async () => {
|
|
35
|
+
const result = waitForFeedback("session", {
|
|
36
|
+
createSocket: () => {
|
|
37
|
+
const socket = new FakeSocket();
|
|
38
|
+
queueMicrotask(() => socket.onclose?.(new CloseEvent("close")));
|
|
39
|
+
return socket;
|
|
40
|
+
},
|
|
41
|
+
timeoutMs: 1_000,
|
|
42
|
+
reconnectAttempts: 2,
|
|
43
|
+
reconnectDelayMs: 0,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await expect(result).rejects.toThrow("WebSocket closed before feedback was submitted.");
|
|
47
|
+
});
|
|
48
|
+
});
|
package/bin/lib/helpers.ts
CHANGED
|
@@ -33,39 +33,89 @@ export function openBrowser(url: string) {
|
|
|
33
33
|
console.error(`Open this URL in your browser: ${url}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
interface FeedbackSocket {
|
|
37
|
+
close(): void;
|
|
38
|
+
onmessage: ((event: MessageEvent) => void) | null;
|
|
39
|
+
onerror: ((event: Event) => void) | null;
|
|
40
|
+
onclose: ((event: CloseEvent) => void) | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FeedbackWaitOptions {
|
|
44
|
+
createSocket?: (url: string) => FeedbackSocket;
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
reconnectAttempts?: number;
|
|
47
|
+
reconnectDelayMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class FeedbackWaitError extends Error {
|
|
51
|
+
constructor(message: string, readonly retryable: boolean) {
|
|
52
|
+
super(message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function waitForFeedbackConnection(
|
|
57
|
+
sessionId: string,
|
|
58
|
+
timeoutMs: number,
|
|
59
|
+
createSocket: (url: string) => FeedbackSocket,
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const ws = createSocket(`${WS_URL}/ws/wait/${sessionId}`);
|
|
63
|
+
let settled = false;
|
|
64
|
+
|
|
65
|
+
const finish = (result: string | FeedbackWaitError) => {
|
|
66
|
+
if (settled) return;
|
|
67
|
+
settled = true;
|
|
68
|
+
clearTimeout(timeout);
|
|
40
69
|
ws.close();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
70
|
+
if (result instanceof FeedbackWaitError) reject(result);
|
|
71
|
+
else resolve(result);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const timeout = setTimeout(() => {
|
|
75
|
+
finish(new FeedbackWaitError("Timeout waiting for feedback.", false));
|
|
76
|
+
}, timeoutMs);
|
|
44
77
|
|
|
45
78
|
ws.onmessage = (event) => {
|
|
46
79
|
try {
|
|
47
80
|
const data = JSON.parse(typeof event.data === "string" ? event.data : "");
|
|
48
81
|
if (data.type === "submit") {
|
|
49
|
-
|
|
50
|
-
process.stdout.write(data.feedback);
|
|
51
|
-
ws.close();
|
|
52
|
-
process.exit(0);
|
|
82
|
+
finish(data.feedback);
|
|
53
83
|
}
|
|
54
84
|
} catch {}
|
|
55
85
|
};
|
|
56
86
|
|
|
57
87
|
ws.onerror = () => {
|
|
58
|
-
|
|
59
|
-
console.error("Error: WebSocket connection failed.");
|
|
60
|
-
process.exit(1);
|
|
88
|
+
finish(new FeedbackWaitError("WebSocket connection failed.", true));
|
|
61
89
|
};
|
|
62
90
|
|
|
63
91
|
ws.onclose = () => {
|
|
64
|
-
|
|
92
|
+
finish(new FeedbackWaitError("WebSocket closed before feedback was submitted.", true));
|
|
65
93
|
};
|
|
66
94
|
});
|
|
67
95
|
}
|
|
68
96
|
|
|
97
|
+
export async function waitForFeedback(sessionId: string, options: FeedbackWaitOptions = {}): Promise<string> {
|
|
98
|
+
const createSocket = options.createSocket ?? ((url: string) => new WebSocket(url));
|
|
99
|
+
const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
|
|
100
|
+
const reconnectAttempts = options.reconnectAttempts ?? 3;
|
|
101
|
+
const reconnectDelayMs = options.reconnectDelayMs ?? 1_000;
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
|
|
104
|
+
for (let attempt = 0; ; attempt++) {
|
|
105
|
+
const remainingMs = deadline - Date.now();
|
|
106
|
+
if (remainingMs <= 0) throw new FeedbackWaitError("Timeout waiting for feedback.", false);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
return await waitForFeedbackConnection(sessionId, remainingMs, createSocket);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!(error instanceof FeedbackWaitError) || !error.retryable || attempt >= reconnectAttempts) {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
await Bun.sleep(Math.min(reconnectDelayMs, Math.max(0, deadline - Date.now())));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
69
119
|
export async function readLine(): Promise<string> {
|
|
70
120
|
const buf: number[] = [];
|
|
71
121
|
for await (const chunk of Bun.stdin.stream()) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Component: SecretInput
|
|
2
|
+
|
|
3
|
+
Local-only secret handoff for runbooks. Use it when the user must create or retrieve a token, password, private key, or other value that a command needs but the agent must not receive in its context.
|
|
4
|
+
|
|
5
|
+
The value stays outside normal canvas response state. It is not written to `localStorage`, annotations, feedback markdown, canvas revisions, or session metadata. The browser deposits it in the local daemon's in-memory vault. Feedback contains only the field ID and environment variable name.
|
|
6
|
+
|
|
7
|
+
## Props
|
|
8
|
+
|
|
9
|
+
- `id` (string, required) — unique field ID. Use letters, numbers, dots, underscores, and hyphens.
|
|
10
|
+
- `label` (string, required) — user-facing description.
|
|
11
|
+
- `env` (string, required) — environment variable used by the target command.
|
|
12
|
+
- `placeholder` (string) — input hint. Never put an example real secret here.
|
|
13
|
+
- `required` (boolean) — prevents feedback submission until the secret is stored.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```jsx
|
|
18
|
+
<Section title="Create an API token">
|
|
19
|
+
<Item id="open-settings" label="1. Open token settings">
|
|
20
|
+
Open the service's token settings. Create a token with the minimum scopes needed for this task.
|
|
21
|
+
</Item>
|
|
22
|
+
|
|
23
|
+
<SecretInput
|
|
24
|
+
id="service-token"
|
|
25
|
+
label="2. Paste the new token"
|
|
26
|
+
env="SERVICE_TOKEN"
|
|
27
|
+
placeholder="Paste token"
|
|
28
|
+
required
|
|
29
|
+
/>
|
|
30
|
+
</Section>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
After feedback says the field is ready, run the target command through the wrapper:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bunx agent-canvas exec \
|
|
37
|
+
--session <session-id> \
|
|
38
|
+
--secret SERVICE_TOKEN=service-token \
|
|
39
|
+
-- service-cli deploy
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Repeat `--secret` for multiple values:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bunx agent-canvas exec \
|
|
46
|
+
--session <session-id> \
|
|
47
|
+
--secret CLIENT_ID=client-id \
|
|
48
|
+
--secret CLIENT_SECRET=client-secret \
|
|
49
|
+
-- service-cli login
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Security contract
|
|
53
|
+
|
|
54
|
+
- Use `SecretInput`, never `UserInput`, for secrets.
|
|
55
|
+
- Secret inputs work only on the local `localhost` canvas. They are unavailable in shared canvases.
|
|
56
|
+
- The CLI starts the command directly without a shell. The secret does not appear in the generated command or its arguments.
|
|
57
|
+
- Secret resolution requires a daemon capability that is available to the CLI but not to canvas JavaScript.
|
|
58
|
+
- The target program can still reveal its environment or print the secret. Use commands that do not echo credentials and do not enable verbose shell tracing.
|
|
59
|
+
- A new canvas revision clears all secrets for the session. Run the required commands before pushing the next revision.
|
|
60
|
+
- The same secret can be used by more than one wrapped command in the current revision. The user can replace or clear it in the browser.
|
|
61
|
+
- Daemon shutdown, session deletion, and stale-session cleanup clear the in-memory value.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# RUNBOOK Flow
|
|
2
|
+
|
|
3
|
+
**When**: The agent needs the user to complete manual browser or account steps before automated work can continue. Examples: create an API token, approve an OAuth application, configure a webhook, or retrieve a credential.
|
|
4
|
+
|
|
5
|
+
**Phases**: Instructions → user action and secure handoff → agent command → result or next runbook revision
|
|
6
|
+
|
|
7
|
+
Runbooks are local-only when they include secrets. Read `component-secretinput` before authoring one.
|
|
8
|
+
|
|
9
|
+
## Phase 1: Instructions (`runbook.jsx`)
|
|
10
|
+
|
|
11
|
+
Give the user ordered, concrete steps. Include the exact destination, required permissions, expected result, and a safe secret field. Do not ask the user to paste a secret into annotations, general notes, `UserInput`, chat, or a terminal command.
|
|
12
|
+
|
|
13
|
+
```jsx
|
|
14
|
+
<Section title="Connect the service">
|
|
15
|
+
<Callout type="info">
|
|
16
|
+
Complete these steps in order. The token value will stay outside agent feedback and model context.
|
|
17
|
+
</Callout>
|
|
18
|
+
|
|
19
|
+
<Item id="open-token-page" label="1. Open token settings" status="todo">
|
|
20
|
+
Open https://service.example/settings/tokens while signed in to the target account.
|
|
21
|
+
</Item>
|
|
22
|
+
|
|
23
|
+
<Item id="create-token" label="2. Create a scoped token" status="todo">
|
|
24
|
+
Name it for this task. Grant only read:projects and write:deployments. Set the shortest practical expiry.
|
|
25
|
+
</Item>
|
|
26
|
+
|
|
27
|
+
<SecretInput
|
|
28
|
+
id="service-token"
|
|
29
|
+
label="3. Paste the token"
|
|
30
|
+
env="SERVICE_TOKEN"
|
|
31
|
+
required
|
|
32
|
+
/>
|
|
33
|
+
|
|
34
|
+
<Choice
|
|
35
|
+
id="target-confirmation"
|
|
36
|
+
label="Which environment did you configure?"
|
|
37
|
+
options={["Staging", "Production"]}
|
|
38
|
+
required
|
|
39
|
+
/>
|
|
40
|
+
</Section>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Push and immediately watch as usual. The user first stores the secret, then submits feedback. The watcher receives a safe marker such as:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
Service token
|
|
47
|
+
Secret is ready. Field: service-token. Environment variable: SERVICE_TOKEN.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
It never receives the value.
|
|
51
|
+
|
|
52
|
+
The raw-value resolver requires a daemon capability stored outside the browser. Canvas JSX can deposit, replace, clear, and check readiness, but it cannot read the value back.
|
|
53
|
+
|
|
54
|
+
## Phase 2: Run the command
|
|
55
|
+
|
|
56
|
+
Use the exact session ID and field-to-environment mapping from the runbook:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bunx agent-canvas exec \
|
|
60
|
+
--session <session-id> \
|
|
61
|
+
--secret SERVICE_TOKEN=service-token \
|
|
62
|
+
-- service-cli configure --environment staging
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Rules:
|
|
66
|
+
|
|
67
|
+
1. Put the target command after `--`.
|
|
68
|
+
2. Never interpolate the secret into the shell command, arguments, generated files, or logs.
|
|
69
|
+
3. Do not use commands that print their complete environment or enable debug output that includes credentials.
|
|
70
|
+
4. Preserve the wrapped command's output and exit code when reporting the result.
|
|
71
|
+
5. Run every command that needs the secret before pushing another revision. A new revision clears the vault.
|
|
72
|
+
|
|
73
|
+
## Phase 3: Continue or finish
|
|
74
|
+
|
|
75
|
+
If another manual action is needed, update the existing runbook with the next steps, push a new revision, and watch again. The user must deposit any required secrets again because revision changes clear them.
|
|
76
|
+
|
|
77
|
+
When the operation is complete, push the standard implementation summary. Report what succeeded, what remains, and whether the user should revoke the temporary token. Never include the token value.
|
|
@@ -138,6 +138,7 @@ All components are globally available in canvas JSX — no imports needed. For i
|
|
|
138
138
|
- **Choice** — single-select radio group. Props: `id`, `label`, `options`, `required`
|
|
139
139
|
- **MultiChoice** — multi-select checkboxes. Props: `id`, `label`, `options`, `required`
|
|
140
140
|
- **UserInput** — free text input. Props: `id`, `label`, `placeholder`, `required`
|
|
141
|
+
- **SecretInput** — local in-memory secret handoff. Props: `id`, `label`, `env`, `placeholder`, `required`
|
|
141
142
|
- **RangeInput** — numeric slider. Props: `id`, `label`, `min`, `max`, `minLabel`, `maxLabel`, `required`
|
|
142
143
|
|
|
143
144
|
### Advanced
|
|
@@ -163,6 +164,7 @@ User wants something built/changed?
|
|
|
163
164
|
User wants to understand something? → EXPLAIN flow
|
|
164
165
|
User wants review/audit? → REVIEW flow
|
|
165
166
|
User wants to make a decision? → DECISION flow
|
|
167
|
+
Agent needs the user to complete manual steps? → RUNBOOK flow
|
|
166
168
|
```
|
|
167
169
|
|
|
168
170
|
**Adapt dynamically**: flows are guidelines, not rigid pipelines. Skip or combine phases when appropriate:
|
|
@@ -195,6 +197,7 @@ User wants to make a decision? → DECISION flow
|
|
|
195
197
|
- **Write** canvas files using the Write tool. Never use bash heredocs.
|
|
196
198
|
- **Edit** canvas files using the Edit tool. Never rewrite entire files.
|
|
197
199
|
- Every `<Item>` and interactive component needs a unique `id`.
|
|
200
|
+
- Use `<SecretInput>`, never `<UserInput>`, for credentials. Read `component-secretinput` and `flow-runbook` first.
|
|
198
201
|
- `<FilePreview path="...">` paths are relative to project root.
|
|
199
202
|
- Read any files the user added to context (listed under "Added context" in feedback).
|
|
200
203
|
- Always push a summary canvas after implementation, even if brief.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
2
2
|
import { AnnotationCtx } from "#canvas/runtime";
|
|
3
3
|
import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
|
|
4
|
+
import { clearPersistedDraft } from "./annotationDraft";
|
|
4
5
|
import { generateAnnotationId } from "./utils";
|
|
5
6
|
|
|
6
7
|
// Re-export types for convenience
|
|
@@ -44,12 +45,6 @@ function savePersisted(sessionId: string, revision: number, state: PersistedStat
|
|
|
44
45
|
} catch {}
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function clearPersisted(sessionId: string, revision: number) {
|
|
48
|
-
try {
|
|
49
|
-
localStorage.removeItem(storageKey(sessionId, revision));
|
|
50
|
-
} catch {}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
48
|
export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnnotations, children }: AnnotationProviderProps) {
|
|
54
49
|
const [localAnnotations, setAnnotations] = useState<Annotation[]>(() => {
|
|
55
50
|
const saved = loadPersisted(sessionId, revision);
|
|
@@ -164,7 +159,7 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnno
|
|
|
164
159
|
setActiveAnnotationId(null);
|
|
165
160
|
setResponses(new Map());
|
|
166
161
|
setFeedbackEntries(new Map());
|
|
167
|
-
|
|
162
|
+
clearPersistedDraft(localStorage, sessionId, revision);
|
|
168
163
|
}, [sessionId, revision]);
|
|
169
164
|
|
|
170
165
|
return (
|
package/daemon/client/App.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
|
|
|
21
21
|
import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
|
|
22
22
|
import type { Annotation } from "#canvas/runtime";
|
|
23
23
|
import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
|
|
24
|
-
import { carryUnsubmittedDraft } from "./annotationDraft";
|
|
24
|
+
import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
|
|
25
25
|
|
|
26
26
|
export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
|
|
27
27
|
|
|
@@ -314,6 +314,9 @@ function App() {
|
|
|
314
314
|
.then((data: any) => {
|
|
315
315
|
if (data.currentRevision) {
|
|
316
316
|
const nextRevisions: RevisionInfo[] = data.revisions || [];
|
|
317
|
+
for (const revision of nextRevisions) {
|
|
318
|
+
if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
|
|
319
|
+
}
|
|
317
320
|
carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, nextRevisions);
|
|
318
321
|
setCurrentRevision(data.currentRevision);
|
|
319
322
|
setSelectedRevision(data.currentRevision);
|
|
@@ -407,7 +410,13 @@ function App() {
|
|
|
407
410
|
}
|
|
408
411
|
}
|
|
409
412
|
if (data.type === "revision-updated") {
|
|
410
|
-
if (
|
|
413
|
+
if (Array.isArray(data.revisions)) {
|
|
414
|
+
const nextRevisions: RevisionInfo[] = data.revisions;
|
|
415
|
+
for (const revision of nextRevisions) {
|
|
416
|
+
if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
|
|
417
|
+
}
|
|
418
|
+
setRevisions(nextRevisions);
|
|
419
|
+
}
|
|
411
420
|
}
|
|
412
421
|
if (data.type === "watcher-status") {
|
|
413
422
|
setAgentWatching(!!data.watching);
|
|
@@ -448,6 +457,7 @@ function App() {
|
|
|
448
457
|
annotations: structuredAnnotations,
|
|
449
458
|
generalNote: feedback,
|
|
450
459
|
});
|
|
460
|
+
clearPersistedDraft(localStorage, sessionId, selectedRevision);
|
|
451
461
|
setSharedFeedbackSubmitted(true);
|
|
452
462
|
setToast({ kind: "success", message: "Feedback submitted. The canvas author will see it shortly." });
|
|
453
463
|
} catch (e: any) {
|