agent-canvas 0.1.40 → 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-feature.md +1 -1
- package/bin/lib/instructions/flow-plan.md +1 -1
- package/bin/lib/instructions/flow-runbook.md +77 -0
- package/bin/lib/instructions/overview.md +32 -12
- 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 +18 -7
- 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.
|
|
@@ -151,7 +151,7 @@ Goal: detailed implementation plan with file-level changes. The user approves be
|
|
|
151
151
|
|
|
152
152
|
## Phase 4: Summary (`summary.jsx`)
|
|
153
153
|
|
|
154
|
-
Push AFTER implementation is complete. Always do this, even briefly. **After pushing the summary, start
|
|
154
|
+
Push AFTER implementation is complete. Always do this, even briefly. **After pushing the summary, start the host-specific watch described in the overview** — the user may want to provide feedback (follow-up requests, corrections, questions about what was done).
|
|
155
155
|
|
|
156
156
|
```jsx
|
|
157
157
|
<Section title="Implementation Summary: [Feature Name]">
|
|
@@ -58,7 +58,7 @@ Goal: detailed implementation plan with file-level changes. The user approves be
|
|
|
58
58
|
|
|
59
59
|
## Phase 2: Summary (`summary.jsx`)
|
|
60
60
|
|
|
61
|
-
Push AFTER implementation is complete. Always do this, even briefly. **After pushing the summary, start
|
|
61
|
+
Push AFTER implementation is complete. Always do this, even briefly. **After pushing the summary, start the host-specific watch described in the overview** — the user may want to provide feedback (follow-up requests, corrections, questions about what was done).
|
|
62
62
|
|
|
63
63
|
```jsx
|
|
64
64
|
<Section title="Implementation Summary: [Feature Name]">
|
|
@@ -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.
|