diff --git a/canvas/src/components/ConsoleModal.tsx b/canvas/src/components/ConsoleModal.tsx new file mode 100644 index 00000000..6db48ee2 --- /dev/null +++ b/canvas/src/components/ConsoleModal.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { api } from "@/lib/api"; + +interface Props { + workspaceId: string; + workspaceName?: string; + open: boolean; + onClose: () => void; +} + +interface ConsoleResponse { + output: string; + instance_id?: string; +} + +// ConsoleModal renders the EC2 serial console output for a workspace. +// Used by the "View Logs" button on failed/stuck workspaces so operators +// can see the actual cloud-init + runtime startup trace without SSH or +// AWS console access. The tenant platform proxies to the control plane; +// this component just consumes GET /workspaces/:id/console. +export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Props) { + const [output, setOutput] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!open) return; + let ignore = false; + setLoading(true); + setError(null); + setOutput(null); + api + .get(`/workspaces/${workspaceId}/console`) + .then((data) => { + if (ignore) return; + setOutput(data.output || ""); + }) + .catch((e) => { + if (ignore) return; + // 501 = deployment without a control plane (local docker-compose); + // we render a friendlier message than "501 Not Implemented". + const msg = e instanceof Error ? e.message : "Failed to load console output"; + if (/501/.test(msg)) { + setError("Console output is only available on cloud (SaaS) deployments."); + } else if (/404/.test(msg)) { + setError("No EC2 instance found for this workspace — it may have been terminated."); + } else { + setError(msg); + } + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + return () => { + ignore = true; + }; + }, [open, workspaceId]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + if (!open || !mounted) return null; + + return createPortal( +
+
+
+
+
+

+ EC2 console output +

+ {workspaceName && ( +
+ {workspaceName} +
+ )} +
+ +
+ +
+ {loading && ( +
+ Loading console output… +
+ )} + {!loading && error && ( +
+ {error} +
+ )} + {!loading && !error && output !== null && ( +
+              {output || "(console output is empty — the instance may still be booting)"}
+            
+ )} +
+ +
+ {output && ( + + )} + +
+
+
, + document.body, + ); +} diff --git a/canvas/src/components/ProvisioningTimeout.tsx b/canvas/src/components/ProvisioningTimeout.tsx index 1945b125..fc4b4502 100644 --- a/canvas/src/components/ProvisioningTimeout.tsx +++ b/canvas/src/components/ProvisioningTimeout.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { api } from "@/lib/api"; import { showToast } from "./Toaster"; +import { ConsoleModal } from "./ConsoleModal"; /** Default provisioning timeout in milliseconds (2 minutes). */ export const DEFAULT_PROVISION_TIMEOUT_MS = 120_000; @@ -167,10 +168,16 @@ export function ProvisioningTimeout({ } }, [confirmingCancel]); + const [consoleFor, setConsoleFor] = useState(null); const handleViewLogs = useCallback((workspaceId: string) => { - // Open the terminal tab for this workspace so user can see logs - useCanvasStore.getState().selectNode(workspaceId); - useCanvasStore.getState().setPanelTab("terminal"); + // Open the EC2 console modal — this is the boot-trace log, which + // is what the user actually wants to see when provisioning is + // stuck (the terminal tab is post-boot, useless if the agent + // runtime never started). The modal closes over itself if the + // request returns 501 (self-hosted / docker-compose deploys) — + // the user gets a clear "console output unavailable" message + // instead of a broken button. + setConsoleFor(workspaceId); }, []); if (timedOut.length === 0) return null; @@ -270,6 +277,15 @@ export function ProvisioningTimeout({
)} + + {/* Console output modal — opens when the user clicks "View Logs" on + a stuck-provisioning banner. Fetches /workspaces/:id/console + which proxies to CP's ec2:GetConsoleOutput. */} + setConsoleFor(null)} + /> ); } diff --git a/canvas/src/components/__tests__/ConsoleModal.test.tsx b/canvas/src/components/__tests__/ConsoleModal.test.tsx new file mode 100644 index 00000000..fb44eadc --- /dev/null +++ b/canvas/src/components/__tests__/ConsoleModal.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; + +vi.mock("@/lib/api", () => ({ + api: { get: vi.fn() }, +})); + +import { api } from "@/lib/api"; +import { ConsoleModal } from "../ConsoleModal"; + +const mockGet = vi.mocked(api.get); + +beforeEach(() => vi.clearAllMocks()); +afterEach(cleanup); + +describe("ConsoleModal", () => { + it("returns null when closed — no fetch triggered", () => { + const { container } = render( + {}} />, + ); + expect(container.firstChild).toBeNull(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it("fetches console output when opened", async () => { + mockGet.mockResolvedValueOnce({ output: "boot line 1\nRuntime running (PID 42)\n", instance_id: "i-x" }); + render( {}} />); + await waitFor(() => + expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/console"), + ); + await waitFor(() => { + const out = screen.getByTestId("console-output"); + expect(out.textContent).toContain("Runtime running (PID 42)"); + }); + }); + + it("renders a friendly message on 501 (non-CP deploy)", async () => { + mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 501 Not Implemented")); + render( {}} />); + await waitFor(() => { + const err = screen.getByTestId("console-error"); + expect(err.textContent).toMatch(/only available on cloud/i); + }); + }); + + it("renders a specific message on 404 (instance terminated)", async () => { + mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found")); + render( {}} />); + await waitFor(() => { + const err = screen.getByTestId("console-error"); + expect(err.textContent).toMatch(/No EC2 instance found/i); + }); + }); + + it("Close button invokes onClose", async () => { + mockGet.mockResolvedValueOnce({ output: "" }); + const onClose = vi.fn(); + render(); + await waitFor(() => screen.getByText("Close")); + fireEvent.click(screen.getByText("Close")); + expect(onClose).toHaveBeenCalled(); + }); + + it("Escape key invokes onClose", async () => { + mockGet.mockResolvedValueOnce({ output: "" }); + const onClose = vi.fn(); + render(); + await waitFor(() => screen.getByText("Close")); + fireEvent.keyDown(window, { key: "Escape" }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/canvas/src/components/tabs/DetailsTab.tsx b/canvas/src/components/tabs/DetailsTab.tsx index 16a5ca01..0d52794d 100644 --- a/canvas/src/components/tabs/DetailsTab.tsx +++ b/canvas/src/components/tabs/DetailsTab.tsx @@ -6,6 +6,7 @@ import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { StatusDot } from "../StatusDot"; import { BudgetSection } from "./BudgetSection"; import { WorkspaceUsage } from "../WorkspaceUsage"; +import { ConsoleModal } from "../ConsoleModal"; interface Props { workspaceId: string; @@ -33,6 +34,7 @@ export function DetailsTab({ workspaceId, data }: Props) { const [deleteError, setDeleteError] = useState(null); const [restarting, setRestarting] = useState(false); const [restartError, setRestartError] = useState(null); + const [consoleOpen, setConsoleOpen] = useState(false); const updateNodeData = useCanvasStore((s) => s.updateNodeData); const removeNode = useCanvasStore((s) => s.removeNode); const selectNode = useCanvasStore((s) => s.selectNode); @@ -204,6 +206,31 @@ export function DetailsTab({ workspaceId, data }: Props) { )} + {/* Error details — shown when the workspace failed to boot. The + control plane's bootstrap watcher writes last_sample_error with + the real traceback from the EC2 serial console, so users see + "ModuleNotFoundError: ..." instead of a generic timeout. */} + {(data.status === "failed" || (data.status === "degraded" && data.lastSampleError)) && ( +
+ {data.lastSampleError ? ( +
+              {data.lastSampleError}
+            
+ ) : ( +

No error detail recorded.

+ )} + +
+ )} + {/* Budget — dedicated section with live usage stats (#541) */} @@ -296,6 +323,15 @@ export function DetailsTab({ workspaceId, data }: Props) { )} + + {/* Portal-rendered console modal — mounted at the root of this tab + but appears above everything via createPortal(document.body). */} + setConsoleOpen(false)} + /> ); } diff --git a/workspace-server/internal/handlers/admin_memories.go b/workspace-server/internal/handlers/admin_memories.go index 2742facd..169c2412 100644 --- a/workspace-server/internal/handlers/admin_memories.go +++ b/workspace-server/internal/handlers/admin_memories.go @@ -88,9 +88,9 @@ type memoryImportEntry struct { // + scope already exist. Returns counts of imported and skipped entries. // // SECURITY (F1085 / #1132): calls redactSecrets on each content field -// before inserting so that secrets embedded in imported memories cannot -// land unredacted in the agent_memories table (SAFE-T1201 / #838 parity -// with the commit_memory MCP bridge path). +// before both the deduplication check and the INSERT so that imported memories +// with embedded credentials cannot land unredacted in agent_memories (SAFE-T1201 +// parity with the commit_memory MCP bridge path). func (h *AdminMemoriesHandler) Import(c *gin.Context) { ctx := c.Request.Context() @@ -128,6 +128,12 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) { // the redacted content so that two backups with the same original // secret (same placeholder output) are treated as duplicates. var exists bool + // F1085 / #1132: scrub credential patterns before persistence. Must run + // BEFORE the dedup check so the redacted content is what gets stored — + // otherwise two backups with the same original secret would each get a + // different placeholder, producing duplicate rows with different content. + content, _ := redactSecrets(workspaceID, entry.Content) + err = db.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`, workspaceID, content, entry.Scope,