diff --git a/canvas/src/components/__tests__/TestConnectionButton.test.tsx b/canvas/src/components/__tests__/TestConnectionButton.test.tsx index 15f1dd9cf..b4dd0cda0 100644 --- a/canvas/src/components/__tests__/TestConnectionButton.test.tsx +++ b/canvas/src/components/__tests__/TestConnectionButton.test.tsx @@ -11,13 +11,21 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TestConnectionButton } from "../ui/TestConnectionButton"; import type { SecretGroup } from "@/types/secrets"; -import { validateSecret } from "@/lib/api/secrets"; +import { validateSecret, ApiError } from "@/lib/api/secrets"; // ─── Mock validateSecret ────────────────────────────────────────────────────── // vi.mock is hoisted, so validateSecret (imported above) refers to the mocked // namespace value once vi.mock runs. Use vi.mocked() to access it in tests. vi.mock("@/lib/api/secrets", () => ({ validateSecret: vi.fn(), + ApiError: class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } + }, })); // SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom' @@ -102,7 +110,7 @@ describe("TestConnectionButton — state machine", () => { expect(screen.getByText("Permission denied")).toBeTruthy(); }); - it("shows generic error message on unexpected exception", async () => { + it("shows a connectivity message on a genuine network exception", async () => { vi.mocked(validateSecret).mockRejectedValue(new Error("timeout")); render(); @@ -110,8 +118,23 @@ describe("TestConnectionButton — state machine", () => { await act(async () => { /* flush */ }); expect(screen.getByRole("alert")).toBeTruthy(); - // The error detail is hardcoded to "Connection timed out. Service may be down." - expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/timed out/i); + // A real thrown network error → honest connectivity message (not a + // fabricated "service down"); see internal#492. + expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch( + /could not reach the validation service/i, + ); + }); + + it("does not claim a timeout when the validate endpoint 404s (internal#492)", async () => { + vi.mocked(validateSecret).mockRejectedValue(new ApiError(404, "Not Found")); + render(); + + fireEvent.click(screen.getByRole("button")); + await act(async () => { /* flush */ }); + + const alert = document.body.querySelector('[role="alert"]')?.textContent ?? ""; + expect(alert).not.toMatch(/timed out/i); + expect(alert).toMatch(/not available/i); }); }); diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index 375bd37a8..b5940a0ea 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -2,8 +2,11 @@ // 04 · Chat — message thread + composer + sub-tabs. // Wired to the same /workspaces/:id/a2a (method message/send) endpoint -// that the desktop ChatTab uses, but with a slimmer surface: no -// attachments, no A2A topology overlay, no conversation tracing. +// that the desktop ChatTab uses. Render parity with desktop ChatTab is +// achieved by reusing its renderers rather than forking a reduced +// mobile path: the Agent Comms sub-tab mounts the same AgentCommsPanel, +// and message attachments route through the same AttachmentPreview +// dispatch the desktop My-Chat bubble uses (#231/#232). import { useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; @@ -16,6 +19,9 @@ import { useChatSend, useChatSocket, } from "@/components/tabs/chat/hooks"; +import { AgentCommsPanel } from "@/components/tabs/chat/AgentCommsPanel"; +import { AttachmentPreview } from "@/components/tabs/chat/AttachmentPreview"; +import { downloadChatFile } from "@/components/tabs/chat/uploads"; import { toMobileAgent } from "./components"; import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette"; @@ -304,6 +310,17 @@ export function MobileChat({ const removePendingFile = (index: number) => setPendingFiles((prev) => prev.filter((_, i) => i !== index)); + // Route attachment downloads through the same authenticated helper + // the desktop ChatTab uses (downloadChatFile) so platform-scheme + // URIs get a real Blob with auth headers instead of about:blank. + const downloadAttachment = (att: ChatAttachment) => { + downloadChatFile(agentId, att).catch(() => { + // AttachmentPreview's own error affordance covers the in-bubble + // failure state; matches ChatTab's behaviour of not double- + // reporting a download failure. + }); + }; + const send = async () => { const text = draft.trim(); if ((!text && pendingFiles.length === 0) || sending || !reachable) return; @@ -433,7 +450,19 @@ export function MobileChat({ + {/* Agent Comms — reuse the desktop AgentCommsPanel verbatim so + mobile renders the identical peer/A2A + delegation feed + (history GET + live socket events) instead of a placeholder + (#231). The panel owns its own scroll/load/error/empty + states, matching ChatTab's agent-comms tabpanel. */} + {tab === "a2a" && ( +
+ +
+ )} + {/* Messages */} + {tab === "my" && (
- {tab === "a2a" && ( -
- Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab. -
- )} {tab === "my" && historyLoading && (
Loading chat history… @@ -521,9 +538,31 @@ export function MobileChat({ overflowWrap: "anywhere", }} > - - {m.content} - + {m.content && ( + + {m.content} + + )} + {m.attachments && m.attachments.length > 0 && ( +
+ {m.attachments.map((att, i) => ( + + ))} +
+ )}
)}
+ )} + {/* Footer ID + composer belong to My Chat only. The Agent Comms + tab is a read-only peer/A2A feed (parity with desktop + ChatTab, where the agent-comms tabpanel has no composer). */} + {tab === "my" && ( + <> {/* Footer ID */}
+ + )}
); } diff --git a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx index 7a0a8ca12..0c8e24595 100644 --- a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx +++ b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx @@ -21,6 +21,14 @@ import { MobileChat } from "../MobileChat"; vi.mock("@/lib/api"); import { api } from "@/lib/api"; +// AgentCommsPanel (mounted by the Agent Comms sub-tab, #231) subscribes +// to the global socket via useSocketEvent. Stub it to a no-op so the +// panel mounts without the real ReconnectingSocket — the parity tests +// only assert the panel renders (vs the old static placeholder). +vi.mock("@/hooks/useSocketEvent", () => ({ + useSocketEvent: vi.fn(), +})); + // ─── Mock store ─────────────────────────────────────────────────────────────── const mockAgentId = "ws-chat-test"; @@ -155,6 +163,12 @@ beforeEach(() => { mockOnBack.mockClear(); mockStoreState.nodes = []; mockStoreState.agentMessages = {}; + // jsdom doesn't implement scrollIntoView. The Agent Comms tab now + // mounts AgentCommsPanel (#231), which scrolls its feed to bottom on + // arrival; a no-op stub keeps the panel from throwing under jsdom + // (same stub AgentCommsPanel's own render test installs). + Element.prototype.scrollIntoView = + vi.fn() as unknown as Element["scrollIntoView"]; // Set up spies on the real api methods. Tests override these per-call. const getSpy = vi.spyOn(api, "get"); const postSpy = vi.spyOn(api, "post"); @@ -474,3 +488,146 @@ describe("MobileChat — chat history", () => { expect(getSpy).toHaveBeenCalledTimes(2); }); }); + +// ─── #232 · Attachment render parity with desktop ChatTab ──────────────────── +// +// Regression for the CTO-reported mobile bug: MobileChat used to render +// only m.content (no attachment surface), so files sent/received in a +// conversation were invisible on mobile while desktop showed them. The +// fix routes m.attachments through the same AttachmentPreview the +// desktop ChatTab bubble uses. + +describe("MobileChat — attachment render parity (#232)", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders an attachment from a history message via AttachmentPreview", async () => { + const getSpy = vi.spyOn(api, "get"); + // useChatHistory reads { messages, reached_end }. + getSpy.mockResolvedValueOnce({ + messages: [ + { + id: "m-att-1", + role: "agent", + content: "Here is the report", + attachments: [ + { + name: "report.csv", + uri: "workspace://out/report.csv", + mimeType: "text/csv", + size: 2048, + }, + ], + timestamp: new Date().toISOString(), + }, + ], + reached_end: true, + }); + + let rr: ReturnType; + await act(async () => { + rr = renderChat(mockAgentId); + }); + const { container } = rr!; + + // A non-image attachment renders the AttachmentChip download button + // with title="Download " — same component the desktop bubble + // dispatches through AttachmentPreview. + await waitFor(() => { + const chip = container.querySelector('[title="Download report.csv"]'); + expect(chip).toBeTruthy(); + }); + expect(container.textContent ?? "").toContain("report.csv"); + }); +}); + +// ─── #231 · Agent Comms (A2A/peer) render parity with desktop ChatTab ──────── +// +// Regression for the CTO-reported mobile bug: the Agent Comms sub-tab +// rendered a static placeholder string ("peer-to-peer A2A traffic +// surfaces in the Comms tab") instead of the real feed. The fix mounts +// the same AgentCommsPanel the desktop ChatTab agent-comms tabpanel +// uses, so peer/A2A + delegation activity is visible on mobile. + +describe("MobileChat — Agent Comms render parity (#231)", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("mounts AgentCommsPanel on the Agent Comms tab (not the old placeholder)", async () => { + const getSpy = vi.spyOn(api, "get"); + // 1st GET: useChatHistory (My Chat) on mount. + getSpy.mockResolvedValueOnce({ messages: [], reached_end: true }); + // 2nd GET: AgentCommsPanel's activity load when the tab is shown. + // Empty list → panel renders its own empty state, which still + // proves AgentCommsPanel mounted (vs. the removed placeholder). + getSpy.mockResolvedValueOnce([]); + + let rr: ReturnType; + await act(async () => { + rr = renderChat(mockAgentId); + }); + const { container } = rr!; + + const commsTab = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Agent Comms", + ); + expect(commsTab).toBeTruthy(); + await act(async () => { + commsTab!.click(); + }); + + await waitFor(() => { + const text = container.textContent ?? ""; + // The panel's empty state — proves AgentCommsPanel mounted. + expect(text).toContain("No agent-to-agent communications yet."); + }); + // The old hard-coded placeholder must be gone. + expect(container.textContent ?? "").not.toContain( + "peer-to-peer A2A traffic surfaces in the Comms tab", + ); + // The panel hit its activity endpoint. + expect(getSpy).toHaveBeenCalledWith( + expect.stringContaining(`/workspaces/${mockAgentId}/activity`), + ); + }); + + it("renders a peer message on the Agent Comms tab", async () => { + const getSpy = vi.spyOn(api, "get"); + getSpy.mockResolvedValueOnce({ messages: [], reached_end: true }); + // a2a_receive from a peer → AgentCommsPanel.toCommMessage maps it + // to an inbound bubble with the request text. + getSpy.mockResolvedValueOnce([ + { + id: "act-1", + activity_type: "a2a_receive", + source_id: "peer-ws-uuid", + target_id: mockAgentId, + method: "message/send", + summary: "peer asked something", + request_body: { task: "Please review PR 42" }, + response_body: null, + status: "ok", + created_at: new Date().toISOString(), + }, + ]); + + let rr: ReturnType; + await act(async () => { + rr = renderChat(mockAgentId); + }); + const { container } = rr!; + + const commsTab = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Agent Comms", + ); + await act(async () => { + commsTab!.click(); + }); + + await waitFor(() => { + expect(container.textContent ?? "").toContain("Please review PR 42"); + }); + }); +}); diff --git a/canvas/src/components/settings/SecretRow.tsx b/canvas/src/components/settings/SecretRow.tsx index 92bb633ea..10339e869 100644 --- a/canvas/src/components/settings/SecretRow.tsx +++ b/canvas/src/components/settings/SecretRow.tsx @@ -3,16 +3,24 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import type { Secret, SecretGroup } from '@/types/secrets'; import { useSecretsStore } from '@/stores/secrets-store'; import { StatusBadge } from '@/components/ui/StatusBadge'; -import { RevealToggle } from '@/components/ui/RevealToggle'; import { KeyValueField } from '@/components/ui/KeyValueField'; import { ValidationHint } from '@/components/ui/ValidationHint'; import { TestConnectionButton } from '@/components/ui/TestConnectionButton'; import { validateSecretValue } from '@/lib/validation/secret-formats'; import { SERVICES } from '@/lib/services'; -const AUTO_HIDE_MS = 30_000; const VALIDATION_DEBOUNCE_MS = 400; +// Secret values are write-only from the browser: the server List endpoint +// "Never exposes values", there is no per-secret decrypt route, and the +// only decrypted path (GET /secrets/values) is bulk + token-gated for +// remote agents. The old eye/RevealToggle was a dead affordance — it +// flipped its own icon but could never reveal anything, which read as +// "this doesn't work" (esp. once clicked → eye-with-slash). We show an +// honest static indicator instead; rotation is via Edit. +const WRITE_ONLY_TITLE = + 'Value is write-only and cannot be revealed — use Edit to replace/rotate it'; + interface SecretRowProps { secret: Secret; workspaceId: string; @@ -31,28 +39,12 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) { const setSecretStatus = useSecretsStore((s) => s.setSecretStatus); const isEditing = editingKey === secret.name; - const [revealed, setRevealed] = useState(false); const [editValue, setEditValue] = useState(''); const [validationError, setValidationError] = useState(null); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const debounceRef = useRef>(undefined); const editBtnRef = useRef(null); - const revealTimerRef = useRef>(undefined); - - // Auto-hide revealed value after 30s - useEffect(() => { - if (revealed) { - clearTimeout(revealTimerRef.current); - revealTimerRef.current = setTimeout(() => setRevealed(false), AUTO_HIDE_MS); - return () => clearTimeout(revealTimerRef.current); - } - }, [revealed]); - - // Reset revealed state when panel closes (session-only) - useEffect(() => { - return () => setRevealed(false); - }, []); // Debounced validation useEffect(() => { @@ -133,11 +125,15 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) { {secret.masked_value}
- setRevealed((r) => !r)} - label={`Toggle reveal ${secret.name}`} - /> + + 🔒 +
+ ); + } + return ; +} + +function WorkspaceTokensTab({ workspaceId }: TokensTabProps) { const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); diff --git a/canvas/src/components/settings/__tests__/SecretRow.test.tsx b/canvas/src/components/settings/__tests__/SecretRow.test.tsx index 1db10a82b..6763355be 100644 --- a/canvas/src/components/settings/__tests__/SecretRow.test.tsx +++ b/canvas/src/components/settings/__tests__/SecretRow.test.tsx @@ -138,14 +138,54 @@ describe("SecretRow — display mode", () => { expect(document.querySelector('[role="row"]')).toBeTruthy(); }); - it("has Reveal, Copy, Edit, Delete buttons", () => { + it("has Copy, Edit, Delete buttons", () => { render(); - expect(screen.getByTestId("reveal-toggle")).toBeTruthy(); expect(screen.getByRole("button", { name: /copy/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy(); }); + // Regression: the reveal/eye control was a dead affordance. Clicking it + // flipped its own icon (eye → eye-with-slash) but never revealed the value, + // because secret values are write-only from the browser (server List + // "Never exposes values"; there is no per-secret decrypt endpoint and the + // client has no plaintext-fetch function). The honest fix removes the + // toggle and shows a static "write-only / cannot be revealed" indicator. + // See internal tracking issue + internal#210/#211. + it("does NOT render a reveal/eye toggle (values are write-only)", () => { + render(); + expect(screen.queryByTestId("reveal-toggle")).toBeNull(); + expect( + screen.queryByRole("button", { name: /toggle reveal/i }), + ).toBeNull(); + }); + + it("shows a write-only indicator explaining the value cannot be revealed", () => { + render(); + const indicator = screen.getByTestId("write-only-indicator"); + expect(indicator).toBeTruthy(); + // Affordance must be honest: explain it cannot be revealed and that + // Edit is the rotate path. It must not be a clickable button. + const title = indicator.getAttribute("title") ?? ""; + expect(title.toLowerCase()).toMatch(/write-only|cannot be revealed/); + expect(indicator.tagName).not.toBe("BUTTON"); + }); + + it("write-only indicator is present for the Anthropic/OAuth-token row too", () => { + // The reported bug singled out CLAUDE_CODE_OAUTH_TOKEN (anthropic group); + // the fix is group-agnostic — every row gets the same honest affordance. + const OAUTH_SECRET = { + name: "CLAUDE_CODE_OAUTH_TOKEN", + masked_value: "••••••••••••••••9d2a", + group: "anthropic" as const, + status: "unverified" as const, + updated_at: "2024-01-04", + }; + render(); + expect(screen.queryByTestId("reveal-toggle")).toBeNull(); + expect(screen.getByTestId("write-only-indicator")).toBeTruthy(); + }); + it("shows invalid status correctly", () => { render(); expect(screen.getByTestId("status-badge").getAttribute("data-status")).toBe("invalid"); diff --git a/canvas/src/components/settings/__tests__/TokensTab.test.tsx b/canvas/src/components/settings/__tests__/TokensTab.test.tsx index cb923de55..f9409583b 100644 --- a/canvas/src/components/settings/__tests__/TokensTab.test.tsx +++ b/canvas/src/components/settings/__tests__/TokensTab.test.tsx @@ -302,3 +302,35 @@ describe("TokensTab — error", () => { expect(document.querySelector('[role="status"]')).toBeNull(); }); }); + +// ─── "global" sentinel (no node selected) ──────────────────────────────────── +// +// Regression: SettingsPanel passes the literal "global" when no canvas +// node is selected. workspace tokens are per-workspace and there is no +// /workspaces/global/tokens endpoint — calling it 500'd +// ("invalid input syntax for type uuid: global"). The tab must NOT call +// the API in that state and must point the user at the Org API Keys tab. +describe("TokensTab — global sentinel (no node selected)", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockApiPost.mockReset(); + mockApiGet.mockRejectedValue(new Error("should not be called")); + }); + + it("does not call the API and shows a pointer to Org API Keys", async () => { + render(); + await flush(); + expect(mockApiGet).not.toHaveBeenCalled(); + expect(mockApiPost).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Select a workspace node"); + expect(document.body.textContent).toContain("Org API Keys"); + // No error banner, no scary 500 surfacing. + expect(document.querySelector(".text-bad")).toBeNull(); + }); + + it("has no create button in the global state", async () => { + render(); + await flush(); + expect(document.body.textContent).not.toContain("New Token"); + }); +}); diff --git a/canvas/src/components/tabs/FilesTab.tsx b/canvas/src/components/tabs/FilesTab.tsx index caf222795..196551da4 100644 --- a/canvas/src/components/tabs/FilesTab.tsx +++ b/canvas/src/components/tabs/FilesTab.tsx @@ -45,11 +45,54 @@ export function FilesTab({ workspaceId, data }: Props) { if (data && isExternalLikeRuntime(data.runtime)) { return ; } - return ; + return ; } -function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) { - const [root, setRoot] = useState("/configs"); +/** Picks the initial root for the FilesTab dropdown based on the + * workspace's runtime. Decision: per-runtime default (Hongming + * 2026-05-15, internal#425 Decisions §2). + * + * - openclaw → `/agent-home` (the agent's identity/state — the + * user-facing interesting files for that runtime live in + * `~/.openclaw/` inside the container, which `/agent-home` maps to + * via the Phase 2b docker-exec backend). + * - everything else (claude-code, hermes, external-like, undefined) + * → `/configs` (the legacy default — managed config that flows + * through the per-runtime indirection in + * workspace-server/internal/handlers/template_files_eic.go). + * + * When the runtime is undefined (legacy callers that don't thread + * `data` through, or a workspace whose runtime field hasn't loaded + * yet) the default is `/configs` — matches today's behaviour, no + * surprise. + * + * Note on `/agent-home` pre-Phase-2b: the backend short-circuits + * with HTTP 501 and the canonical "implementation pending" body. + * The tab renders empty + the error banner explains. This is by + * design — lets us land the canvas UX before the backend ships, + * per the RFC's phased rollout. The 501 is graceful: it doesn't + * poison error toasts or generate "workspace not found" noise. + * + * Adding a new runtime that should default to `/agent-home`: add it + * to the agentHomeDefaultRuntimes set below. Adding a runtime that + * should default to a different root: extend this function. */ +const agentHomeDefaultRuntimes = new Set(["openclaw"]); + +function defaultRootForRuntime(runtime: string | undefined): string { + if (runtime && agentHomeDefaultRuntimes.has(runtime)) { + return "/agent-home"; + } + return "/configs"; +} + +function PlatformOwnedFilesTab({ + workspaceId, + runtime, +}: { + workspaceId: string; + runtime?: string; +}) { + const [root, setRoot] = useState(() => defaultRootForRuntime(runtime)); const [selectedFile, setSelectedFile] = useState(null); const [fileContent, setFileContent] = useState(""); const [editContent, setEditContent] = useState(""); diff --git a/canvas/src/components/tabs/FilesTab/FileEditor.tsx b/canvas/src/components/tabs/FilesTab/FileEditor.tsx index db5301c5d..3e51356e6 100644 --- a/canvas/src/components/tabs/FilesTab/FileEditor.tsx +++ b/canvas/src/components/tabs/FilesTab/FileEditor.tsx @@ -3,6 +3,22 @@ import { useRef } from "react"; import { getIcon } from "./tree"; +// secretShapeMarker is the canonical body the workspace-server Files +// API returns when a file's path OR content matched a credential +// regex (internal#425 RFC, Phase 2b — backed by +// workspace-server/internal/secrets.ScanBytes). The marker is a +// fixed prefix so the canvas can detect it without parsing JSON and +// without round-tripping the matched bytes through the editor (which +// would defeat the purpose — clipboard, browser history, log +// surfaces would all see them). +// +// Today (Phase 1 / before 2b ships) the backend returns 501 for the +// only root that uses this path, so the marker is dead code until +// 2b lands. Wiring it in now keeps the canvas + backend contracts +// aligned in one PR rather than a follow-up. The constant is +// importable so a future test can pin the exact string. +export const SECRET_SHAPE_DENIED_MARKER = ""; + interface Props { selectedFile: string | null; fileContent: string; @@ -31,6 +47,22 @@ export function FileEditor({ const editorRef = useRef(null); const isDirty = editContent !== fileContent; + // internal#425 Phase 3: detect the secret-shape denial marker and + // render a placeholder instead of the editor. The marker comes + // from workspace-server Phase 2b (secrets.ScanBytes) which refuses + // to surface the file's bytes. We deliberately don't expose + // the matched pattern's Name here — the canvas just shows the + // generic denial. The Files API log surface has the Pattern.Name + // for operators who need to debug a false positive. + const isSecretShapeDenied = fileContent === SECRET_SHAPE_DENIED_MARKER; + + // /agent-home is read-only from the canvas (Phase 2b ships read + + // delete; Phase-2b-followup may add write). Edits to /configs are + // unchanged. Until 2b ships, /agent-home returns 501 so this + // read-only gate is also dead code, but wiring it in now keeps + // the UI honest the moment 2b lands without a follow-up canvas PR. + const isReadOnlyRoot = root !== "/configs"; + if (!selectedFile) { return (
@@ -75,11 +107,42 @@ export function FileEditor({ {/* Editor area */} {loadingFile ? (
Loading...
+ ) : isSecretShapeDenied ? ( + // Files API refused to surface this file's bytes because its + // path or content matched a credential regex + // (workspace-server/internal/secrets, internal#425 Phase 2b). + // We render a placeholder INSTEAD OF the textarea so the + // matched bytes never enter the DOM. Clipboard / view-source + // / element-inspector all see the placeholder, not the + // credential. +
+
+
🛡️
+

+ {SECRET_SHAPE_DENIED_MARKER} +

+

+ The platform refused to surface this file because its + path or content matched a credential-shape pattern. + The bytes never left the workspace container. +

+

+ If this is a false positive (test fixture, docs example, + or content that happens to share a credential's shape), + rename the file or adjust the content via the workspace + terminal so the regex no longer matches, then refresh. +

+
+
) : (