agent-canvas 0.1.41 → 0.1.43
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 +78 -0
- package/bin/lib/helpers.ts +92 -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 +8 -0
- package/daemon/client/AnnotationProvider.tsx +2 -7
- package/daemon/client/App.tsx +49 -13
- package/daemon/client/PlanRenderer.tsx +67 -10
- package/daemon/client/RenderErrorContext.ts +11 -0
- 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 +39 -39
- package/daemon/dist/components.js +42 -40
- package/daemon/src/compiler.test.ts +18 -0
- package/daemon/src/compiler.ts +40 -6
- 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/daemon/src/websocket.test.ts +99 -0
- package/daemon/src/websocket.ts +64 -10
- 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 +39 -39
- package/workers/canvas-share/public/assets/components.js +42 -40
|
@@ -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,8 @@ 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
|
+
import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
|
|
25
26
|
|
|
26
27
|
export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
|
|
27
28
|
|
|
@@ -232,6 +233,8 @@ function App() {
|
|
|
232
233
|
const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "overview" });
|
|
233
234
|
const [openFiles, setOpenFiles] = useState<string[]>([]);
|
|
234
235
|
const wsRef = useRef<WebSocket | null>(null);
|
|
236
|
+
const pendingRenderErrorsRef = useRef<CanvasRenderError[]>([]);
|
|
237
|
+
const reportedRenderErrorsRef = useRef(new Set<string>());
|
|
235
238
|
|
|
236
239
|
const [projectRoot, setProjectRoot] = useState<string | undefined>();
|
|
237
240
|
const [mobileSidebar, setMobileSidebar] = useState(false);
|
|
@@ -247,6 +250,21 @@ function App() {
|
|
|
247
250
|
const [reviewerDialogOpen, setReviewerDialogOpen] = useState(false);
|
|
248
251
|
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
|
|
249
252
|
const [toast, setToast] = useState<{ kind: "error" | "success"; message: string } | null>(null);
|
|
253
|
+
|
|
254
|
+
const reportRenderError = useCallback((error: CanvasRenderError) => {
|
|
255
|
+
if (isSharedMode) return;
|
|
256
|
+
const signature = `${error.revision}\0${error.filename}\0${error.message}`;
|
|
257
|
+
if (reportedRenderErrorsRef.current.has(signature)) return;
|
|
258
|
+
reportedRenderErrorsRef.current.add(signature);
|
|
259
|
+
|
|
260
|
+
const payload = JSON.stringify({ type: "render-error", error });
|
|
261
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
262
|
+
wsRef.current.send(payload);
|
|
263
|
+
} else {
|
|
264
|
+
pendingRenderErrorsRef.current.push(error);
|
|
265
|
+
}
|
|
266
|
+
}, [isSharedMode]);
|
|
267
|
+
|
|
250
268
|
useEffect(() => {
|
|
251
269
|
if (!toast) return;
|
|
252
270
|
const t = setTimeout(() => setToast(null), 4000);
|
|
@@ -314,6 +332,9 @@ function App() {
|
|
|
314
332
|
.then((data: any) => {
|
|
315
333
|
if (data.currentRevision) {
|
|
316
334
|
const nextRevisions: RevisionInfo[] = data.revisions || [];
|
|
335
|
+
for (const revision of nextRevisions) {
|
|
336
|
+
if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
|
|
337
|
+
}
|
|
317
338
|
carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, nextRevisions);
|
|
318
339
|
setCurrentRevision(data.currentRevision);
|
|
319
340
|
setSelectedRevision(data.currentRevision);
|
|
@@ -375,7 +396,13 @@ function App() {
|
|
|
375
396
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
376
397
|
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/session/${sessionId}`);
|
|
377
398
|
wsRef.current = ws;
|
|
378
|
-
ws.onopen = () =>
|
|
399
|
+
ws.onopen = () => {
|
|
400
|
+
setConnected(true);
|
|
401
|
+
for (const error of pendingRenderErrorsRef.current) {
|
|
402
|
+
ws.send(JSON.stringify({ type: "render-error", error }));
|
|
403
|
+
}
|
|
404
|
+
pendingRenderErrorsRef.current = [];
|
|
405
|
+
};
|
|
379
406
|
ws.onclose = () => { setConnected(false); reconnectTimer = setTimeout(connect, 2000); };
|
|
380
407
|
ws.onmessage = (event) => {
|
|
381
408
|
try {
|
|
@@ -407,7 +434,13 @@ function App() {
|
|
|
407
434
|
}
|
|
408
435
|
}
|
|
409
436
|
if (data.type === "revision-updated") {
|
|
410
|
-
if (
|
|
437
|
+
if (Array.isArray(data.revisions)) {
|
|
438
|
+
const nextRevisions: RevisionInfo[] = data.revisions;
|
|
439
|
+
for (const revision of nextRevisions) {
|
|
440
|
+
if (revision.hasFeedback) clearPersistedDraft(localStorage, sessionId, revision.revision);
|
|
441
|
+
}
|
|
442
|
+
setRevisions(nextRevisions);
|
|
443
|
+
}
|
|
411
444
|
}
|
|
412
445
|
if (data.type === "watcher-status") {
|
|
413
446
|
setAgentWatching(!!data.watching);
|
|
@@ -448,6 +481,7 @@ function App() {
|
|
|
448
481
|
annotations: structuredAnnotations,
|
|
449
482
|
generalNote: feedback,
|
|
450
483
|
});
|
|
484
|
+
clearPersistedDraft(localStorage, sessionId, selectedRevision);
|
|
451
485
|
setSharedFeedbackSubmitted(true);
|
|
452
486
|
setToast({ kind: "success", message: "Feedback submitted. The canvas author will see it shortly." });
|
|
453
487
|
} catch (e: any) {
|
|
@@ -482,14 +516,15 @@ function App() {
|
|
|
482
516
|
|
|
483
517
|
return (
|
|
484
518
|
<SessionContext.Provider value={sessionId}>
|
|
485
|
-
<
|
|
486
|
-
<
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
519
|
+
<RenderErrorContext.Provider value={reportRenderError}>
|
|
520
|
+
<RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
|
|
521
|
+
<AnnotationProvider
|
|
522
|
+
key={`${sessionId}:${selectedRevision}`}
|
|
523
|
+
sessionId={sessionId}
|
|
524
|
+
revision={selectedRevision}
|
|
525
|
+
isReadOnly={isReadOnly}
|
|
526
|
+
remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
|
|
527
|
+
>
|
|
493
528
|
<ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
|
|
494
529
|
<ActiveViewCtx.Provider value={{ setActiveView }}>
|
|
495
530
|
<div className="min-h-screen bg-bg-base">
|
|
@@ -663,8 +698,9 @@ function App() {
|
|
|
663
698
|
</div>
|
|
664
699
|
</ActiveViewCtx.Provider>
|
|
665
700
|
</ActiveViewContext.Provider>
|
|
666
|
-
|
|
667
|
-
|
|
701
|
+
</AnnotationProvider>
|
|
702
|
+
</RevisionContext.Provider>
|
|
703
|
+
</RenderErrorContext.Provider>
|
|
668
704
|
</SessionContext.Provider>
|
|
669
705
|
);
|
|
670
706
|
}
|
|
@@ -7,6 +7,7 @@ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
|
7
7
|
import { generateAnnotationId } from "./utils";
|
|
8
8
|
import { useTextAnnotation } from "./useTextAnnotation";
|
|
9
9
|
import { loadCanvasModule } from "./clientApi";
|
|
10
|
+
import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
|
|
10
11
|
|
|
11
12
|
/** All navigable blocks (keyboard arrows) */
|
|
12
13
|
const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
|
|
@@ -18,8 +19,53 @@ interface PlanRendererProps {
|
|
|
18
19
|
filename: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
interface CanvasErrorBoundaryProps {
|
|
23
|
+
revision: number;
|
|
24
|
+
filename: string;
|
|
25
|
+
reportError: (error: CanvasRenderError) => void;
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CanvasErrorBoundaryState {
|
|
30
|
+
error: Error | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class CanvasErrorBoundary extends React.Component<CanvasErrorBoundaryProps, CanvasErrorBoundaryState> {
|
|
34
|
+
state: CanvasErrorBoundaryState = { error: null };
|
|
35
|
+
|
|
36
|
+
static getDerivedStateFromError(error: Error): CanvasErrorBoundaryState {
|
|
37
|
+
return { error };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
41
|
+
this.props.reportError({
|
|
42
|
+
revision: this.props.revision,
|
|
43
|
+
filename: this.props.filename,
|
|
44
|
+
message: error.message,
|
|
45
|
+
...(error.stack ? { stack: error.stack } : {}),
|
|
46
|
+
...(info.componentStack ? { componentStack: info.componentStack } : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
render() {
|
|
51
|
+
if (!this.state.error) return this.props.children;
|
|
52
|
+
return <CanvasErrorDisplay message={this.state.error.message} />;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function CanvasErrorDisplay({ message }: { message: string }) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="bg-accent-red-muted rounded-lg p-5">
|
|
59
|
+
<h3 className="font-body font-semibold text-accent-red mb-2">Canvas render failed</h3>
|
|
60
|
+
<pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{message}</pre>
|
|
61
|
+
<p className="text-meta font-body text-text-tertiary mt-3">The error was sent back to the canvas author.</p>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
21
66
|
export function PlanRenderer({ revision, filename }: PlanRendererProps) {
|
|
22
67
|
const sessionId = useContext(SessionContext);
|
|
68
|
+
const reportError = useContext(RenderErrorContext);
|
|
23
69
|
const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
|
|
24
70
|
const [error, setError] = useState<string | null>(null);
|
|
25
71
|
const [loading, setLoading] = useState(true);
|
|
@@ -44,8 +90,18 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
|
|
|
44
90
|
void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
|
|
45
91
|
loadCanvasModule(filename, revision)
|
|
46
92
|
.then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
|
|
47
|
-
.catch((
|
|
48
|
-
|
|
93
|
+
.catch((cause: unknown) => {
|
|
94
|
+
const moduleError = cause instanceof Error ? cause : new Error(String(cause));
|
|
95
|
+
setError(moduleError.message);
|
|
96
|
+
setLoading(false);
|
|
97
|
+
reportError({
|
|
98
|
+
revision,
|
|
99
|
+
filename,
|
|
100
|
+
message: moduleError.message,
|
|
101
|
+
...(moduleError.stack ? { stack: moduleError.stack } : {}),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}, [sessionId, revision, filename, reportError]);
|
|
49
105
|
|
|
50
106
|
const scrollContainer = document.getElementById("plan-scroll-container");
|
|
51
107
|
|
|
@@ -250,12 +306,7 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
|
|
|
250
306
|
}
|
|
251
307
|
|
|
252
308
|
if (error) {
|
|
253
|
-
return
|
|
254
|
-
<div className="bg-accent-red-muted rounded-lg p-5">
|
|
255
|
-
<h3 className="font-body font-semibold text-accent-red mb-2">Compilation Error</h3>
|
|
256
|
-
<pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
|
|
257
|
-
</div>
|
|
258
|
-
);
|
|
309
|
+
return <CanvasErrorDisplay message={error} />;
|
|
259
310
|
}
|
|
260
311
|
|
|
261
312
|
if (!PlanComponent) {
|
|
@@ -265,7 +316,14 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
|
|
|
265
316
|
return (
|
|
266
317
|
<CanvasFileCtx.Provider value={filename}>
|
|
267
318
|
<div ref={containerRef} className="plan-content plan-updated">
|
|
268
|
-
|
|
319
|
+
<CanvasErrorBoundary
|
|
320
|
+
key={`${revision}:${filename}`}
|
|
321
|
+
revision={revision}
|
|
322
|
+
filename={filename}
|
|
323
|
+
reportError={reportError}
|
|
324
|
+
>
|
|
325
|
+
{planElement}
|
|
326
|
+
</CanvasErrorBoundary>
|
|
269
327
|
</div>
|
|
270
328
|
{editingAnn && (
|
|
271
329
|
<EditAnnotationModal
|
|
@@ -554,4 +612,3 @@ function EditAnnotationModal({ note, onSave, onCancel }: { note: string; onSave:
|
|
|
554
612
|
</div>
|
|
555
613
|
);
|
|
556
614
|
}
|
|
557
|
-
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
|
|
3
|
+
export interface CanvasRenderError {
|
|
4
|
+
revision: number;
|
|
5
|
+
filename: string;
|
|
6
|
+
message: string;
|
|
7
|
+
stack?: string;
|
|
8
|
+
componentStack?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const RenderErrorContext = createContext<(error: CanvasRenderError) => void>(() => {});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { carryUnsubmittedDraft } from "./annotationDraft";
|
|
2
|
+
import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
|
|
3
3
|
|
|
4
4
|
class MemoryStorage {
|
|
5
5
|
private readonly values = new Map<string, string>();
|
|
@@ -11,12 +11,27 @@ class MemoryStorage {
|
|
|
11
11
|
setItem(key: string, value: string): void {
|
|
12
12
|
this.values.set(key, value);
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
removeItem(key: string): void {
|
|
16
|
+
this.values.delete(key);
|
|
17
|
+
}
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
const sessionId = "session";
|
|
17
21
|
const key = (revision: number) => `canvas:${sessionId}:rev:${revision}`;
|
|
18
22
|
|
|
19
23
|
describe("carryUnsubmittedDraft", () => {
|
|
24
|
+
test("clears a submitted draft and its carry marker", () => {
|
|
25
|
+
const storage = new MemoryStorage();
|
|
26
|
+
storage.setItem(key(2), JSON.stringify({ generalNote: "Submitted" }));
|
|
27
|
+
storage.setItem(`${key(2)}:draft-handled`, "1");
|
|
28
|
+
|
|
29
|
+
clearPersistedDraft(storage, sessionId, 2);
|
|
30
|
+
|
|
31
|
+
expect(storage.getItem(key(2))).toBeNull();
|
|
32
|
+
expect(storage.getItem(`${key(2)}:draft-handled`)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
20
35
|
test("carries the latest unsent draft into a new revision", () => {
|
|
21
36
|
const storage = new MemoryStorage();
|
|
22
37
|
storage.setItem(key(2), JSON.stringify({
|
|
@@ -8,6 +8,10 @@ interface AnnotationStorage {
|
|
|
8
8
|
setItem(key: string, value: string): void;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
interface RemovableAnnotationStorage {
|
|
12
|
+
removeItem(key: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
function storageKey(sessionId: string, revision: number): string {
|
|
12
16
|
return `canvas:${sessionId}:rev:${revision}`;
|
|
13
17
|
}
|
|
@@ -16,6 +20,17 @@ function handledKey(sessionId: string, revision: number): string {
|
|
|
16
20
|
return `${storageKey(sessionId, revision)}:draft-handled`;
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
export function clearPersistedDraft(
|
|
24
|
+
storage: RemovableAnnotationStorage,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
revision: number,
|
|
27
|
+
): void {
|
|
28
|
+
try {
|
|
29
|
+
storage.removeItem(storageKey(sessionId, revision));
|
|
30
|
+
storage.removeItem(handledKey(sessionId, revision));
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
function parseState(raw: string | null): object | null {
|
|
20
35
|
if (!raw) return null;
|
|
21
36
|
try {
|
|
@@ -118,6 +118,47 @@ export function uploadUrl(): string {
|
|
|
118
118
|
return `/api/session/${MODE.sessionId}/upload`;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
function localSecretUrl(fieldId: string, suffix: "status" | "value"): string {
|
|
122
|
+
if (MODE.isShared) throw new Error("Secret inputs are available only on the local canvas daemon");
|
|
123
|
+
return `/api/session/${encodeURIComponent(MODE.sessionId)}/secrets/${encodeURIComponent(fieldId)}/${suffix}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function secretApiError(response: Response): Promise<Error> {
|
|
127
|
+
const raw: unknown = await response.json().catch(() => null);
|
|
128
|
+
if (typeof raw === "object" && raw !== null && "error" in raw && typeof raw.error === "string") {
|
|
129
|
+
return new Error(raw.error);
|
|
130
|
+
}
|
|
131
|
+
return new Error(`Secret API request failed with status ${response.status}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Return only readiness metadata. The secret value is never sent back to the browser. */
|
|
135
|
+
export async function getLocalSecretStatus(fieldId: string): Promise<boolean> {
|
|
136
|
+
const response = await fetch(localSecretUrl(fieldId, "status"), { cache: "no-store" });
|
|
137
|
+
if (!response.ok) throw await secretApiError(response);
|
|
138
|
+
const raw: unknown = await response.json();
|
|
139
|
+
return typeof raw === "object" && raw !== null && "ready" in raw && raw.ready === true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Deposit a secret into the daemon's in-memory vault. */
|
|
143
|
+
export async function storeLocalSecret(fieldId: string, value: string): Promise<void> {
|
|
144
|
+
const response = await fetch(localSecretUrl(fieldId, "value"), {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
cache: "no-store",
|
|
148
|
+
body: JSON.stringify({ value }),
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) throw await secretApiError(response);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Remove a deposited secret without retrieving its value. */
|
|
154
|
+
export async function clearLocalSecret(fieldId: string): Promise<void> {
|
|
155
|
+
const response = await fetch(localSecretUrl(fieldId, "value"), {
|
|
156
|
+
method: "DELETE",
|
|
157
|
+
cache: "no-store",
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok) throw await secretApiError(response);
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
// --- Meta fetch (with optional decryption) ---------------------------------
|
|
122
163
|
|
|
123
164
|
interface RawMetaResponse {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { useFeedback } from "#canvas/runtime";
|
|
3
|
+
import { MODE, clearLocalSecret, getLocalSecretStatus, storeLocalSecret } from "../clientApi";
|
|
4
|
+
|
|
5
|
+
const FIELD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
|
|
8
|
+
interface SecretInputProps {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
env: string;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
required?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Local-only secret handoff that never enters annotation or feedback state. */
|
|
17
|
+
export function SecretInput({ id, label, env, placeholder, required }: SecretInputProps) {
|
|
18
|
+
const [value, setValue] = useState("");
|
|
19
|
+
const [ready, setReady] = useState(false);
|
|
20
|
+
const [busy, setBusy] = useState(!MODE.isShared);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const validFieldId = FIELD_ID_RE.test(id);
|
|
23
|
+
const validEnvName = ENV_NAME_RE.test(env);
|
|
24
|
+
const locallyAvailable = !MODE.isShared;
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!locallyAvailable || !validFieldId) {
|
|
28
|
+
setBusy(false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let active = true;
|
|
33
|
+
getLocalSecretStatus(id)
|
|
34
|
+
.then((isReady) => {
|
|
35
|
+
if (active) setReady(isReady);
|
|
36
|
+
})
|
|
37
|
+
.catch((cause: unknown) => {
|
|
38
|
+
if (active) setError(cause instanceof Error ? cause.message : "Could not read secret status");
|
|
39
|
+
})
|
|
40
|
+
.finally(() => {
|
|
41
|
+
if (active) setBusy(false);
|
|
42
|
+
});
|
|
43
|
+
return () => { active = false; };
|
|
44
|
+
}, [id, locallyAvailable, validFieldId]);
|
|
45
|
+
|
|
46
|
+
const feedback = ready
|
|
47
|
+
? `**${label}**\nSecret is ready. Field: \`${id}\`. Environment variable: \`${env}\`.\n`
|
|
48
|
+
: "";
|
|
49
|
+
useFeedback(`secret:${id}`, feedback, {
|
|
50
|
+
label,
|
|
51
|
+
required: required && locallyAvailable,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const store = async () => {
|
|
55
|
+
if (!value || !validFieldId || !validEnvName) return;
|
|
56
|
+
setBusy(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
await storeLocalSecret(id, value);
|
|
60
|
+
setValue("");
|
|
61
|
+
setReady(true);
|
|
62
|
+
} catch (cause: unknown) {
|
|
63
|
+
setError(cause instanceof Error ? cause.message : "Could not store secret");
|
|
64
|
+
} finally {
|
|
65
|
+
setBusy(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const clear = async () => {
|
|
70
|
+
setBusy(true);
|
|
71
|
+
setError(null);
|
|
72
|
+
try {
|
|
73
|
+
await clearLocalSecret(id);
|
|
74
|
+
setReady(false);
|
|
75
|
+
} catch (cause: unknown) {
|
|
76
|
+
setError(cause instanceof Error ? cause.message : "Could not clear secret");
|
|
77
|
+
} finally {
|
|
78
|
+
setBusy(false);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (!locallyAvailable) {
|
|
83
|
+
return (
|
|
84
|
+
<div data-md="secretinput" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg bg-accent-amber-muted">
|
|
85
|
+
<div className="text-[13px] font-body font-medium text-text-primary">{label}</div>
|
|
86
|
+
<p className="text-[12px] font-body text-text-secondary mt-1">
|
|
87
|
+
Secret input is unavailable in shared canvases. Open this runbook on the author's local canvas daemon.
|
|
88
|
+
</p>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const configurationError = !validFieldId
|
|
94
|
+
? "Secret field IDs may contain letters, numbers, dots, underscores, and hyphens."
|
|
95
|
+
: !validEnvName
|
|
96
|
+
? "Environment variable names must use letters, numbers, and underscores and may not start with a number."
|
|
97
|
+
: null;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div data-md="secretinput" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
|
|
101
|
+
<div className="flex items-baseline justify-between gap-3 mb-2">
|
|
102
|
+
<div className="flex items-baseline gap-2">
|
|
103
|
+
<span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
|
|
104
|
+
{required && <span className="text-[10px] text-accent-red font-body">*</span>}
|
|
105
|
+
</div>
|
|
106
|
+
<span className={`text-[11px] font-body ${ready ? "text-accent-green" : "text-text-tertiary"}`}>
|
|
107
|
+
{busy ? "Working…" : ready ? "Ready in memory" : "Not stored"}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div className="flex gap-2">
|
|
112
|
+
<input
|
|
113
|
+
type="password"
|
|
114
|
+
value={value}
|
|
115
|
+
onChange={(event) => setValue(event.target.value)}
|
|
116
|
+
onKeyDown={(event) => {
|
|
117
|
+
if (event.key === "Enter") {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
void store();
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
placeholder={placeholder || "Paste secret value"}
|
|
123
|
+
autoComplete="off"
|
|
124
|
+
spellCheck={false}
|
|
125
|
+
disabled={busy || !!configurationError}
|
|
126
|
+
className="min-w-0 flex-1 bg-bg-input text-[13px] font-mono text-text-primary rounded-lg px-3 py-2 focus:outline-none border border-border-subtle focus:border-border-hover placeholder:text-text-tertiary disabled:opacity-50 transition-colors"
|
|
127
|
+
/>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={() => void store()}
|
|
131
|
+
disabled={busy || !value || !!configurationError}
|
|
132
|
+
className="px-3 py-2 rounded-lg bg-btn-primary text-btn-primary-text text-[12px] font-body font-medium disabled:opacity-40 disabled:cursor-default"
|
|
133
|
+
>
|
|
134
|
+
{ready ? "Replace" : "Store"}
|
|
135
|
+
</button>
|
|
136
|
+
{ready && (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={() => void clear()}
|
|
140
|
+
disabled={busy}
|
|
141
|
+
className="px-3 py-2 rounded-lg border border-border-subtle text-text-secondary text-[12px] font-body font-medium hover:border-border-hover disabled:opacity-40"
|
|
142
|
+
>
|
|
143
|
+
Clear
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<p className="text-[11px] font-body text-text-tertiary mt-1.5">
|
|
149
|
+
Stored only in daemon memory. The agent receives field {id} for {env}, not the value.
|
|
150
|
+
</p>
|
|
151
|
+
{(configurationError || error) && (
|
|
152
|
+
<p className="text-[11px] text-accent-red font-body mt-1">{configurationError || error}</p>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -11,6 +11,7 @@ export { Mermaid } from "./Mermaid";
|
|
|
11
11
|
export { Diff } from "./Diff";
|
|
12
12
|
export { Choice, MultiChoice } from "./Choice";
|
|
13
13
|
export { UserInput, RangeInput } from "./UserInput";
|
|
14
|
+
export { SecretInput } from "./SecretInput";
|
|
14
15
|
export { ImageView } from "./Image";
|
|
15
16
|
export { Markdown } from "./Markdown";
|
|
16
17
|
export { useFeedback } from "#canvas/runtime";
|