From bb4840ccbb4a501998bb6bf3b1938d038210edab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Molecule=20AI=20=C2=B7=20core-fe?= Date: Fri, 15 May 2026 17:02:45 -0700 Subject: [PATCH] feat(canvas): /agent-home root option + secret-shape denial placeholder (internal#425 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Files API roots RFC. UI-side wiring for the new /agent-home root. Backend dispatch is the Phase 2b PR (#TBD) — until that lands, /agent-home returns the 501 stub from #1247, which the existing error banner already surfaces gracefully. Changes: 1. canvas/src/components/tabs/FilesTab/FilesToolbar.tsx — adds at the bottom of the root selector. Pre-Phase-2b the dropdown still works because the server-side 501 is just an error response — same error-banner path as a transient backend failure. 2. canvas/src/components/tabs/FilesTab.tsx — new defaultRootForRuntime() function pins the initial root per- runtime per Hongming Decisions §2 (internal#425): - openclaw → /agent-home (the user-facing interesting state) - everything else → /configs (legacy default) FilesTab now reads workspace runtime from props.data?.runtime and threads it through to PlatformOwnedFilesTab. Undefined- runtime callers (legacy tests, pre-load states) default to /configs — matches today's behaviour, no surprise. 3. canvas/src/components/tabs/FilesTab/FileEditor.tsx — new SECRET_SHAPE_DENIED_MARKER export + denial-placeholder render path. When fileContent === marker, the editor renders a role=region placeholder instead of the textarea, so the matched bytes never enter a controlled input (DOM value, clipboard, inspector). Marker constant matches the canonical '' string the Phase 2b backend will emit. Also: /agent-home is read-only via isReadOnlyRoot until Phase 2b decides write semantics. Until then, write attempts would 201 with the 501 stub anyway, but blocking the textarea at the UI saves the user a round-trip + a confusing error. Tests (canvas/src/components/tabs/FilesTab/__tests__/agentHome.test.tsx): - dropdown includes /agent-home option (pins Phase 1 contract) - dropdown reflects /agent-home as selected value when prop is set - denied-marker renders placeholder INSTEAD OF textarea (pins the bytes-don't-leak invariant) - regular content renders textarea, no placeholder (regression guard) - /agent-home renders textarea read-only (pins the gate) - /configs renders textarea writable (regression guard for the read-only-everywhere bug) - marker constant matches the canonical '' string (pins the contract value so a typo on either side breaks the test) vitest run on FilesTab + new tests: 47 tests passed, 3 files. tsc --noEmit clean for all edited / created files (the pre-existing TS errors in FilesTab.test.tsx are unchanged and unrelated). Refs internal#425. --- canvas/src/components/tabs/FilesTab.tsx | 49 ++++- .../components/tabs/FilesTab/FileEditor.tsx | 65 ++++++- .../components/tabs/FilesTab/FilesToolbar.tsx | 9 + .../FilesTab/__tests__/agentHome.test.tsx | 181 ++++++++++++++++++ 4 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 canvas/src/components/tabs/FilesTab/__tests__/agentHome.test.tsx 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. +

+
+
) : (