diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index 57f98df74..e9dbfb65c 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -30,6 +30,8 @@ Wires all modules, sets up session manager and SDK bridge, dispatches messages. ### Message Handler Modules +HTML file previews: `public/modules/html-preview.js` renders `.html`/`.htm` in an opaque-origin, script-enabled sandbox with a restrictive content policy. `filebrowser.js` owns source switching, line navigation, live refresh and teardown; files above the existing rich-preview limit stay as source. Self-contained documents and absolute external resources are supported; relative local asset paths are not resolved. Browser regression fixture: `test/fixtures/html-browser.html`, served by `test/fixtures/plantuml-browser-server.js`. + clay-sketch chat and temporary workbench: `public/modules/wireframe-chat.js` renders fenced blocks; `wireframe-render.js` queues local renders; `wireframe-workbench.js` owns the read-only viewer and Save/Download. `wireframe-style.js` provides the shared clay-sketch identity and PlantUML Salt attribution. `wireframe-viewport.js` manages natural-size/whole-design Fit, zoom controls and resize/cleanup for chat, workbench and file previews, with transient state in the store. `project-wireframe-http.js` handles authenticated source rendering and exclusive project saves. `project-session-document.js` and `session-document-mcp-server.js` provide the session-bound `present_wireframe` tool and agent guidance. `bundled-skills/clay-sketch/SKILL.md` teaches agents to draw whole-page UI plans in `clay-sketch` fences and revise them through `present_wireframe`. Shared skill discovery exposes it automatically across vendors; no separate installation is required. @@ -50,7 +52,7 @@ Favorites-as-tag behavior supersedes the older exclusive-folder wording above: F | `project-session-github.js` + `session-github.js` | Session-bound GitHub work links (`clay-github` native/MCP/stdio tools) and `session_github_refresh`. Exact issue/PR URLs are explicitly associated by the Driver, verified with read-only authenticated `gh`, and persisted in session metadata. Uses per-OS-user credentials, live access checks, bounded refresh, and stale status on failures; never infers associations from a shared checkout or mutates GitHub. | | `project-sessions.js` | `new_session`, `switch_session`, `delete_session`, `rename_session`, `resume_session`, `fork_session`, `rewind_*`, `permission_response`, `elicitation_response`, `set_model`, `set_effort`, `set_thinking`, `set_betas`, `set_*_mode`, `browse_dir`, `add_project`, `create_project`, `clone_project`, `create_worktree`, `remove_project*`, `schedule_move`, `reorder_projects`, `set_project_title`, `set_project_icon`, `get_daemon_config`, `set_pin`, `set_keep_awake`, `set_auto_continue`, `set_image_retention`, `shutdown_server`, `restart_server`, `process_stats`, `stop`, `stop_task`, `kill_process`, `set_update_channel`, `check_update`, `update_now`, `ask_user_response`, `input_sync`, `cursor_*`, `text_select`, `push_subscribe`, `load_more_history`, `search_sessions`, `search_session_content`, `list_cli_sessions`, `set_session_visibility`, `transfer_project_owner`, `set_mate_dm` | Session lifecycle, config, project management, daemon settings, permissions, updates | | `project-session-delete.js` | `delete_session` and `bulk_delete_sessions` scope and authorization. Explicit human deletion of a Driver also deletes every Split Worker generation (current and replaced) that the provenance hierarchy links to it: a unique parent origin, the same owner, and a parent that is not itself a Worker. Ambiguous and cross-owner links are never expanded. The caller's delete permission and access are checked for the whole scope before anything happens. If any cascaded Worker is inaccessible, the request is refused. PTYs and title watchers are released, then the existing session-manager deletion cleanup runs. Deleting a Worker affects only that Worker; close, dissolve, and replacement never come through here and keep history | -| `project-filesystem.js` + `project-file-history.js` + `project-file-path.js` + `project-file-http.js` | `fs_list`, `fs_read`, `fs_write`, `fs_watch`, `fs_unwatch`, `fs_file_history`, `fs_git_diff`, `fs_file_at`, `get_project_env`, `set_project_env`, `read_global_claude_md`, `write_global_claude_md`, `get_shared_env`, `set_shared_env` | File browser, file history, project env/settings. `project-request-access.js` supplies fresh user-bound file scope across WS, HTTP download/image routes and watches: personal and mapped OS identities use OS permissions; unmapped multi-user access stays project-bound for every role. `project-file-path.js` resolves scope and distinguishes missing paths, scope denial and OS denial; Git history remains repository-scoped. | +| `project-filesystem.js` + `project-file-history.js` + `project-file-path.js` + `project-file-http.js` | `fs_list`, `fs_read`, `fs_write`, `fs_watch`, `fs_unwatch`, `fs_file_history`, `fs_git_diff`, `fs_file_at`, `get_project_env`, `set_project_env`, `read_global_claude_md`, `write_global_claude_md`, `get_shared_env`, `set_shared_env` | File browser, file history, project env/settings. `project-request-access.js` supplies fresh user-bound file scope across WS, HTTP download/image routes and watches: without Linux OS isolation, personal mode and the sole authenticated account use server process permissions; shared account installations retain the project boundary. The live account count, not login mode alone, determines the sole-account exception. With isolation, operations use the authenticated Linux identity and fail closed if it is missing. Project and file-browser permissions are revalidated for every request. `project-file-path.js` resolves scope and distinguishes missing paths, scope denial and OS denial; Git history remains repository-scoped. | | `project-user-message.js` | `message`, `note_*`, `term_*`, `context_sources_save`, `browser_tab_list`, `extension_result`, `loop_*` (delegation), `schedule_*`, `send_scheduled_now`, `cancel_scheduled_message` | User message dispatch, sticky notes, terminals, context sources, browser extension | | `project-message-delivery.js` | (called from `project-user-message.js`) | Idempotent client message identifiers, persisted replay deduplication, and delivery acknowledgements | | `project-scheduled-messages.js` | (called from `project-user-message.js`, SDK rate-limit callbacks, and session lifecycle) | Durable one-shot project chat messages keyed by stable provider session identity; owns authorization, rehydration, busy-session eligibility, cancellation/replacement/Send now, and SDK dispatch receipts | diff --git a/lib/project-file-path.js b/lib/project-file-path.js index c8a7e60fc..9a73ad77c 100644 --- a/lib/project-file-path.js +++ b/lib/project-file-path.js @@ -12,9 +12,8 @@ function inside(base, target) { return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); } -// Scope is server-derived for each request. In personal and mapped OS-user -// modes, the actual filesystem operation enforces read/write permissions. -// Unmapped multi-user deployments retain the project and symlink boundary. +// Scope is server-derived for each request. File-viewer operations follow the +// executing OS identity within the account-derived project boundary, if any. function resolveFilePath(cwd, requested, scope) { if (typeof requested !== "string" || !requested || requested.indexOf("\0") !== -1) { throw failure("INVALID_PATH", "Invalid file path"); diff --git a/lib/project-request-access.js b/lib/project-request-access.js index 07dfec4d4..2bf0e2ad4 100644 --- a/lib/project-request-access.js +++ b/lib/project-request-access.js @@ -60,10 +60,22 @@ function attachRequestAccess(ctx) { return info; } + function isSoleAccount(ws) { + if (!users || typeof users.getAllUsers !== "function") return false; + try { + var accounts = users.getAllUsers(); + var user = userFor(ws); + return !!(user && user.id && Array.isArray(accounts) && accounts.length === 1 && + accounts[0] && accounts[0].id === user.id); + } catch (e) { return false; } + } + function fileScope(ws) { if (!canUseFiles(ws)) throw fileFailure("FILE_FORBIDDEN", "File browser access is not permitted"); var identity = osIdentity(ws); - return { identity: identity, projectBound: isMultiUser() && !identity }; + // Recheck the account roster so adding another user restores the boundary + // for shared installations without OS isolation, including active watches. + return { identity: identity, projectBound: !identity && isMultiUser() && !isSoleAccount(ws) }; } return { permitMessage: permitMessage, canAccessProject: canAccessProject, hasPermission: hasPermission, diff --git a/lib/public/css/filebrowser.css b/lib/public/css/filebrowser.css index 1ab172c78..f1d5cb922 100644 --- a/lib/public/css/filebrowser.css +++ b/lib/public/css/filebrowser.css @@ -1696,6 +1696,16 @@ padding: 0; } +.file-viewer-html-preview { + display: block; + width: 100%; + height: 100%; + min-height: 320px; + border: 0; + background: #fff; + color-scheme: light; +} + .file-viewer-body pre { margin: 0; padding: 16px 20px; diff --git a/lib/public/modules/filebrowser.js b/lib/public/modules/filebrowser.js index bd79c0af0..bc5e93b0f 100644 --- a/lib/public/modules/filebrowser.js +++ b/lib/public/modules/filebrowser.js @@ -13,6 +13,7 @@ import { initFileViewerTabs, openFileViewerTab, previewFileViewerTab, updateFile import { initFileBrowserContextMenu, downloadProjectFile } from './filebrowser-context-menu.js'; import { registerClayFileLinkOpener } from './clay-file-links.js'; import { isPlantUmlFile, renderPlantUmlPreview, cancelPlantUmlPreview } from './plantuml-preview.js'; +import { isHtmlFile, renderHtmlPreview } from './html-preview.js'; var ctx; var showDropHint = function () {}; @@ -43,7 +44,7 @@ export function initFileBrowser(_ctx) { onFocus: function(path) { claimRightWorkbench("file-viewer"); ctx.fileViewerEl.classList.remove("hidden"); - if (path !== currentFilePath) requestFileContent(path); + if (path !== currentFilePath || (store.get("fileViewerIsHtml") && isRendered && !document.querySelector(".file-viewer-html-preview"))) requestFileContent(path); }, onEmpty: function() { teardownFileViewer(); }, }); @@ -194,10 +195,14 @@ export function initFileBrowser(_ctx) { downloadProjectFile(currentFilePath); }); - // Markdown render toggle + // File preview/source toggle document.getElementById("file-viewer-render").addEventListener("click", function () { - if (currentContent == null || (!currentIsMarkdown && !currentIsSvg && !store.get("fileViewerIsPlantUml"))) return; + if (currentContent == null || (!currentIsMarkdown && !currentIsSvg && !store.get("fileViewerIsPlantUml") && !store.get("fileViewerIsHtml"))) return; isRendered = !isRendered; + if (store.get("fileViewerIsHtml")) { + renderHtmlBody(); + return; + } if (store.get("fileViewerIsPlantUml")) { renderPlantUmlBody(); return; @@ -491,6 +496,8 @@ export function closeFileViewer() { function teardownFileViewer() { cancelPlantUmlPreview(); + var htmlFrame = document.querySelector(".file-viewer-html-preview"); + if (htmlFrame) htmlFrame.remove(); if (currentFilePath && isFollowingMarkdown(currentFilePath)) cancelMarkdownFollow(); sendUnwatch(); inlineDiffActive = false; @@ -528,7 +535,7 @@ export function resetFileBrowser() { isRendered = false; currentIsMarkdown = false; currentIsSvg = false; - store.set({ fileViewerIsPlantUml: false }); + store.set({ fileViewerIsPlantUml: false, fileViewerIsHtml: false }); cancelPlantUmlPreview(); historyVisible = false; currentHistoryEntries = []; @@ -1165,7 +1172,7 @@ function showFileContent(msg, expectedRevision) { currentFilePath = msg.path; currentIsMarkdown = false; currentIsSvg = false; - store.set({ fileViewerIsPlantUml: false }); + store.set({ fileViewerIsPlantUml: false, fileViewerIsHtml: false }); cancelPlantUmlPreview(); if (!keepRenderState) isRendered = false; var requestedExt = msg.path.split(".").pop().toLowerCase(); @@ -1193,19 +1200,20 @@ function showFileContent(msg, expectedRevision) { lightweightPreview = (msg.size || 0) > FILE_RICH_PREVIEW_MAX_BYTES; currentIsMarkdown = !lightweightPreview && (ext === "md" || ext === "mdx"); currentIsSvg = !lightweightPreview && ext === "svg"; + store.set({ fileViewerIsHtml: !lightweightPreview && isHtmlFile(msg.path) }); store.set({ fileViewerIsPlantUml: !lightweightPreview && isPlantUmlFile(msg.path) }); if (lightweightPreview) isRendered = false; if (pendingRenderedOpen && currentIsMarkdown) isRendered = true; - if (currentIsMarkdown || currentIsSvg || store.get("fileViewerIsPlantUml")) { + if (currentIsMarkdown || currentIsSvg || store.get("fileViewerIsPlantUml") || store.get("fileViewerIsHtml")) { renderBtn.classList.remove("hidden"); renderBtn.title = currentIsSvg ? "Show SVG source" : "Render markdown"; - copyBtn.title = store.get("fileViewerIsPlantUml") ? "Copy PlantUML source" : currentIsSvg ? "Copy SVG source" : "Copy Markdown source"; + copyBtn.title = store.get("fileViewerIsHtml") ? "Copy HTML source" : store.get("fileViewerIsPlantUml") ? "Copy PlantUML source" : currentIsSvg ? "Copy SVG source" : "Copy Markdown source"; } else { copyBtn.title = "Copy contents"; } - // Markdown starts as source; SVG starts as a safe image preview. + // Markdown starts as source; HTML, SVG and diagrams start as previews. if (lightweightPreview) { renderLargeTextFile(bodyEl, msg.content, msg.size); } else if (currentIsMarkdown) { @@ -1214,6 +1222,9 @@ function showFileContent(msg, expectedRevision) { ? (previousContent == null ? "" : previousContent) : null; renderBody(transitionFrom, refreshSlideIndex); + } else if (store.get("fileViewerIsHtml")) { + if (!keepRenderState) isRendered = !store.get("pendingFileNavigation"); + renderHtmlBody(); } else if (store.get("fileViewerIsPlantUml")) { if (!keepRenderState) isRendered = !store.get("pendingFileNavigation"); renderPlantUmlBody(); @@ -1283,6 +1294,15 @@ function showFileContent(msg, expectedRevision) { } } +function renderHtmlBody() { + var bodyEl = document.getElementById("file-viewer-body"); + var renderBtn = document.getElementById("file-viewer-render"); + renderBtn.classList.toggle("active", isRendered); + renderBtn.title = isRendered ? "Show HTML source" : "Show HTML preview"; + if (isRendered) renderHtmlPreview(bodyEl, currentContent, currentFilePath); + else renderCodeWithLineNumbers(bodyEl, currentContent, "html"); +} + function renderPlantUmlBody() { var bodyEl = document.getElementById("file-viewer-body"); var renderBtn = document.getElementById("file-viewer-render"); diff --git a/lib/public/modules/html-preview.js b/lib/public/modules/html-preview.js new file mode 100644 index 000000000..2aa305ce4 --- /dev/null +++ b/lib/public/modules/html-preview.js @@ -0,0 +1,19 @@ +export function isHtmlFile(path) { + return /\.html?$/i.test(String(path || "")); +} + +export function renderHtmlPreview(body, source, path) { + var frame = document.createElement("iframe"); + frame.className = "file-viewer-html-preview"; + frame.title = "HTML preview of " + path; + // Scripts can drive the preview, but cannot access Clay's document or cookies. + frame.setAttribute("sandbox", "allow-scripts"); + frame.setAttribute("referrerpolicy", "no-referrer"); + var doc = new DOMParser().parseFromString(source, "text/html"); + var policy = doc.createElement("meta"); + policy.httpEquiv = "Content-Security-Policy"; + policy.content = "default-src 'none'; script-src 'unsafe-inline' https: http:; style-src 'unsafe-inline' https: http:; img-src data: blob: https: http:; font-src data: https: http:; media-src data: blob: https: http:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"; + doc.head.prepend(policy); + frame.srcdoc = "\n" + doc.documentElement.outerHTML; + body.replaceChildren(frame); +} diff --git a/test/filebrowser-download.test.js b/test/filebrowser-download.test.js index 9f624dc27..e52098013 100644 --- a/test/filebrowser-download.test.js +++ b/test/filebrowser-download.test.js @@ -25,6 +25,7 @@ function createHandler(filePath, permitted) { project: "Test", slug: "test", opts: { canAccessProjectSlug: function () { return true; } }, usersModule: { isMultiUser: function () { return true; }, + getAllUsers: function () { return [{ id: "sole" }]; }, getEffectivePermissions: function () { return { fileBrowser: permitted !== false }; } }, sm: { sessions: new Map() }, osUsers: null, @@ -34,7 +35,7 @@ function createHandler(filePath, permitted) { } function createRequest() { - return { method: "GET", _clayUser: { role: "admin" } }; + return { method: "GET", _clayUser: { id: "sole", role: "admin" } }; } test("file browser download returns the selected file as an attachment", function () { @@ -55,14 +56,14 @@ test("file browser download returns the selected file as an attachment", functio assert.deepEqual(response.body, fs.readFileSync(__filename)); }); -test("file browser download rejects paths outside the project", function () { +test("file browser download follows server OS access outside the project", function () { var handler = createHandler(__filename); var response = createResponse(); - handler(createRequest(), response, "/api/file/download?path=..%2Fsecret.txt"); + handler(createRequest(), response, "/api/file/download?path=..%2Fpackage.json"); - assert.equal(response.status, 403); - assert.match(response.body, /outside.*allowed file scope/); + assert.equal(response.status, 200); + assert.deepEqual(response.body, fs.readFileSync(path.join(__dirname, "../package.json"))); }); test("file browser download enforces the file browser permission", function () { diff --git a/test/fixtures/html-browser.html b/test/fixtures/html-browser.html new file mode 100644 index 000000000..771e0fb68 --- /dev/null +++ b/test/fixtures/html-browser.html @@ -0,0 +1,35 @@ + +
+ +Real file viewer; simulated HTML file responses.