diff --git a/canvas/src/components/settings/TokensTab.tsx b/canvas/src/components/settings/TokensTab.tsx index 092e4df56..5210b8a17 100644 --- a/canvas/src/components/settings/TokensTab.tsx +++ b/canvas/src/components/settings/TokensTab.tsx @@ -16,7 +16,40 @@ interface TokensTabProps { workspaceId: string; } +// The settings panel passes the literal sentinel "global" when no canvas +// node is selected. Workspace tokens are inherently per-workspace — there +// is no /workspaces/global/tokens endpoint (querying the uuid column with +// "global" 500s on Postgres). The org-wide equivalent lives in the +// separate "Org API Keys" tab. Mirrors the sentinel-awareness that +// api/secrets.ts already has (workspaceId === 'global' → /settings/secrets). +const GLOBAL_WORKSPACE_ID = 'global'; + export function TokensTab({ workspaceId }: TokensTabProps) { + if (workspaceId === GLOBAL_WORKSPACE_ID) { + return ( +
+
+

API Tokens

+

+ Bearer tokens for authenticating API calls to this workspace. +

+
+
+

Select a workspace node first

+

+ Workspace tokens are scoped to a single workspace. Select a node + on the canvas to manage its tokens, or use the{' '} + Org API Keys tab + for org-wide API keys. +

+
+
+ ); + } + 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__/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/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 6563a621b..645edc25e 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -176,7 +176,7 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] { // exactly the point of the platform adaptor. The deep `~/.hermes/ // config.yaml` on the container is a separate runtime-internal file, // not this one. -const RUNTIMES_WITH_OWN_CONFIG = new Set(["external", "kimi", "kimi-cli"]); +const RUNTIMES_WITH_OWN_CONFIG = new Set(["external", "kimi", "kimi-cli", "openclaw"]); const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [ { value: "", label: "LangGraph (default)", models: [], providers: [] }, 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. +

+
+
) : (