From 7c8c427ba85bc3d31431d1b97f292fce232d4573 Mon Sep 17 00:00:00 2001 From: chadbyte Date: Fri, 9 Oct 2026 11:26:32 +1300 Subject: [PATCH 1/3] feat(files): add sandboxed HTML previews to the file viewer Render HTML and HTM documents with source switching and live updates. Keep preview scripts isolated from Clay and dispose of them on close. Preserve source navigation and the existing large-file fallback. Verify browser interactions and 38 focused file and watcher tests. --- docs/guides/MODULE_MAP.md | 2 + lib/public/css/filebrowser.css | 10 ++++ lib/public/modules/filebrowser.js | 36 ++++++++++--- lib/public/modules/html-preview.js | 19 +++++++ test/fixtures/html-browser.html | 35 +++++++++++++ test/fixtures/html-browser.js | 83 ++++++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 lib/public/modules/html-preview.js create mode 100644 test/fixtures/html-browser.html create mode 100644 test/fixtures/html-browser.js diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index 57f98df74..aa8f25954 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. 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/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 @@ + + + + Clay HTML file preview + + + + + + + + + + + +

HTML file preview

Real file viewer; simulated HTML file responses.

+ + +
+ +
+
+ + diff --git a/test/fixtures/html-browser.js b/test/fixtures/html-browser.js new file mode 100644 index 000000000..b9afd211c --- /dev/null +++ b/test/fixtures/html-browser.js @@ -0,0 +1,83 @@ +import { createStore, store } from '/lib/public/modules/store.js'; +import { initFileBrowser, handleFsRead, handleFileChanged, openFile, closeFileViewer } from '/lib/public/modules/filebrowser.js'; + +var source = '

HTML preview

'; +var sources = { 'demo.html': source, 'PAGE.HTM': source, 'plain.txt': 'Plain text', 'large.html': source }; +var reports = []; +window.addEventListener('message', function (event) { + var frame = document.querySelector('.file-viewer-html-preview'); + if (frame && event.source === frame.contentWindow && event.data.fixtureHtml) reports.push(event.data); +}); +createStore({ connected: true, cwd: '/workspace/project', currentSlug: 'fixture', activeSessionId: 42, myUserId: 'fixture-user' }); +var ws = { send: function (raw) { + var msg = JSON.parse(raw); + if (msg.type !== 'fs_read') return; + setTimeout(function () { + handleFsRead(Object.assign({}, msg, { type: 'fs_read_result', content: sources[msg.path], size: msg.path === 'large.html' ? 2 * 1024 * 1024 : sources[msg.path].length })); + }, 0); +} }; +initFileBrowser({ ws: ws, connected: true, activeSessionId: 42, cwd: '/workspace/project', messagesEl: document.getElementById('messages'), fileTreeEl: document.getElementById('file-tree'), fileViewerEl: document.getElementById('file-viewer') }); +Object.keys(sources).forEach(function (path) { + var button = document.createElement('button'); + button.textContent = path; + button.onclick = function () { openFile(path); }; + document.getElementById('plantuml-controls').appendChild(button); +}); +function waitFor(check) { + var start = Date.now(); + return new Promise(function (resolve, reject) { + function poll() { + if (check()) return resolve(); + if (Date.now() - start > 5000) return reject(new Error('Timed out')); + setTimeout(poll, 30); + } + poll(); + }); +} +var report = document.getElementById('fixture-result'); +function check(value, message) { + if (!value) throw new Error(message); + report.textContent += 'PASS: ' + message + '\n'; +} +var run = document.createElement('button'); +run.textContent = 'Run HTML checks'; +document.getElementById('plantuml-controls').appendChild(run); +run.onclick = async function () { + report.textContent = ''; + try { + for (var path of ['demo.html', 'PAGE.HTM']) { + reports = []; + openFile(path); + await waitFor(function () { return reports.length; }); + check(reports[0].title === 'HTML preview', path + ' renders and runs scripts'); + check(reports[0].isolated, 'Preview cannot access Clay DOM'); + } + document.getElementById('file-viewer-render').click(); + check(!document.querySelector('iframe') && document.getElementById('file-viewer-body').textContent.includes(''), 'Source toggle displays HTML'); + var updated = source.replace('HTML preview', 'Updated preview'); + handleFileChanged({ path: 'PAGE.HTM', content: updated, size: updated.length }); + check(!document.querySelector('iframe'), 'Refresh preserves source mode'); + reports = []; + document.getElementById('file-viewer-render').click(); + await waitFor(function () { return reports.length; }); + check(reports[0].title === 'Updated preview', 'Returning to preview uses updated source'); + reports = []; + handleFileChanged({ path: 'PAGE.HTM', content: source, size: source.length }); + await waitFor(function () { return reports.length; }); + check(reports[0].title === 'HTML preview', 'Live changes refresh the preview'); + closeFileViewer(); + check(!document.querySelector('iframe'), 'Closing destroys the running preview'); + openFile('demo.html', { line: 1 }); + await waitFor(function () { return document.getElementById('file-viewer-body').textContent.includes(''); }); + check(!document.querySelector('iframe'), 'Line links open source'); + openFile('large.html'); + await waitFor(function () { return document.getElementById('file-viewer-body').textContent.includes('showing plain text'); }); + check(document.getElementById('file-viewer-render').classList.contains('hidden'), 'Large HTML uses lightweight source'); + openFile('plain.txt'); + await waitFor(function () { return document.getElementById('file-viewer-body').textContent.includes('Plain text'); }); + check(!store.get('fileViewerIsHtml'), 'Switching files clears HTML state'); + report.textContent += 'ALL CHECKS PASSED'; + openFile('demo.html'); + } catch (error) { report.textContent += 'FAIL: ' + error.message; } +}; +openFile('demo.html'); From 5432f1a8975a528152c54ddbf7d24809ead6c9e7 Mon Sep 17 00:00:00 2001 From: chadbyte Date: Fri, 9 Oct 2026 12:58:26 +1300 Subject: [PATCH 2/3] fix(files): follow OS permissions outside the project directory Use the server process filesystem authority when OS-user isolation is disabled, regardless of Clay account login mode. Keep mapped Linux operations bound to the authenticated OS identity without fallback. Preserve project and file-browser authorization on requests and watches. Cover external paths, symlinks, revocation, and real OS read denial. --- docs/guides/MODULE_MAP.md | 2 +- lib/project-file-path.js | 5 +- lib/project-request-access.js | 4 +- test/filebrowser-download.test.js | 8 +-- test/project-file-authority.test.js | 89 +++++++++++++++++++++++++---- 5 files changed, 88 insertions(+), 20 deletions(-) diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index aa8f25954..7cc823aec 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -52,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, filesystem operations use the server process permissions regardless of account login mode; with isolation, they 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..36747f5ae 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; callers requiring a project boundary can still opt in. 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..3073761df 100644 --- a/lib/project-request-access.js +++ b/lib/project-request-access.js @@ -63,7 +63,9 @@ function attachRequestAccess(ctx) { 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 }; + // Account login mode does not define a filesystem boundary. Without OS + // isolation, reads/writes run as the server; with it, they use this identity. + return { identity: identity, projectBound: false }; } return { permitMessage: permitMessage, canAccessProject: canAccessProject, hasPermission: hasPermission, diff --git a/test/filebrowser-download.test.js b/test/filebrowser-download.test.js index 9f624dc27..d474aebe2 100644 --- a/test/filebrowser-download.test.js +++ b/test/filebrowser-download.test.js @@ -55,14 +55,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/project-file-authority.test.js b/test/project-file-authority.test.js index add1e3a06..fdde0c774 100644 --- a/test/project-file-authority.test.js +++ b/test/project-file-authority.test.js @@ -19,11 +19,12 @@ function fixture(t, multiUser) { var user = { id: "user", role: "admin", linuxUser: "mapped" }; var activeUser = user; var allowed = true; + var projectAllowed = true; var sent = []; var client = { _clayUser: { id: "user", role: "admin" }, method: "GET" }; var ctx = { cwd: cwd, slug: "project", osUsers: false, - opts: { canAccessProjectSlug: function () { return allowed; } }, + opts: { canAccessProjectSlug: function () { return projectAllowed; } }, usersModule: { isMultiUser: function () { return multiUser; }, findUserById: function () { return activeUser; }, @@ -53,11 +54,13 @@ function fixture(t, multiUser) { return response; } return { ctx: ctx, client: client, user: user, external: external, picture: picture, sent: sent, request: request, get: get, - watcher: watcher, revoke: function () { allowed = false; }, removeUser: function () { activeUser = null; } }; + watcher: watcher, revoke: function () { allowed = false; }, + revokeProject: function () { projectAllowed = false; }, removeUser: function () { activeUser = null; } }; } -test("single-user external files open, save, download, preview and update under local OS authority", async function (t) { - var f = fixture(t, false); +[false, true].forEach(function (multiUser) { +test((multiUser ? "account login" : "single-user") + " external files open, save, download, preview and update under local OS authority", async function (t) { + var f = fixture(t, multiUser); assert.equal(f.request("fs_read", f.external).content, "personal document"); assert.equal(f.request("fs_write", f.external, { content: "edited" }).ok, true); assert.equal(fs.readFileSync(f.external, "utf8"), "edited"); @@ -75,26 +78,29 @@ test("single-user external files open, save, download, preview and update under await new Promise(function (resolve) { setTimeout(resolve, 300); }); assert.equal(f.sent.some(function (msg) { return msg.type === "fs_file_changed" && msg.content === "updated"; }), true); }); +}); -test("unmapped multi-user admins and members cannot access external files or escaping symlinks", function (t) { +test("without OS isolation admins and members follow process access for absolute, relative and symlink paths", function (t) { var f = fixture(t, true); fs.symlinkSync(f.external, path.join(f.ctx.cwd, "escape.md")); ["admin", "member"].forEach(function (role) { f.user.role = role; + f.user.linuxUser = null; [f.external, "../personal.md", "escape.md"].forEach(function (target) { - assert.equal(f.request("fs_read", target).errorCode, "FILE_SCOPE"); - assert.equal(f.request("fs_write", target, { content: "forbidden" }).errorCode, "FILE_SCOPE"); - assert.equal(f.get(target, true).status, 403); + assert.equal(f.request("fs_read", target).content, "personal document"); + assert.equal(f.request("fs_write", target, { content: "personal document" }).ok, true); + assert.equal(f.get(target, true).body.toString(), "personal document"); }); - assert.equal(f.get(f.picture, false).status, 403); + assert.equal(f.get(f.picture, false).body.toString(), "image bytes"); }); assert.equal(fs.readFileSync(f.external, "utf8"), "personal document"); assert.equal(f.request("fs_read", "missing.md").errorCode, "ENOENT"); assert.equal(f.get("missing.md", true).status, 404); }); -test("OS denial is distinct from missing files and read access does not imply write access", function (t) { - var f = fixture(t, false); +[false, true].forEach(function (multiUser) { +test((multiUser ? "account login" : "single-user") + " OS denial is distinct from missing files and read access does not imply write access", function (t) { + var f = fixture(t, multiUser); var write = fs.writeFileSync; t.mock.method(fs, "writeFileSync", function (file, content, options) { if (file === f.external) { var denied = new Error("denied"); denied.code = "EACCES"; throw denied; } @@ -111,6 +117,31 @@ test("OS denial is distinct from missing files and read access does not imply wr assert.equal(f.get(f.external, true).status, 403); assert.match(f.get(f.external, true).body, /operating system denied/); }); +}); + +["revoke", "revokeProject", "removeUser"].forEach(function (revoke) { + test("external WS, HTTP and watches stop after " + revoke, async function (t) { + var f = fixture(t, true); + var changed; + t.mock.method(fs, "watch", function (dir, callback) { + changed = callback; + return { on: function () {}, close: function () {} }; + }); + assert.equal(f.request("fs_read", f.external).content, "personal document"); + assert.equal(await f.watcher.startFileWatch(f.client, f.external), true); + f[revoke](); + assert.equal(f.request("fs_read", f.external).errorCode, "FILE_FORBIDDEN"); + assert.equal(f.request("fs_write", f.external, { content: "denied" }).errorCode, "FILE_FORBIDDEN"); + assert.equal(f.request("fs_list", "..").errorCode, "FILE_FORBIDDEN"); + assert.equal(f.get(f.external, true).status, 403); + assert.equal(f.get(f.picture, false).status, 403); + var count = f.sent.length; + fs.writeFileSync(f.external, "must not publish"); + changed("change", path.basename(f.external)); + await new Promise(function (resolve) { setTimeout(resolve, 300); }); + assert.equal(f.sent.length, count); + }); +}); test("mapped OS reads use fresh identity across WS and HTTP and never fall back after identity loss", function (t) { var f = fixture(t, true); @@ -135,6 +166,42 @@ test("mapped OS reads use fresh identity across WS and HTTP and never fall back assert.equal(calls.length, count); }); +test("external reads respect actual process OS permissions", { skip: process.platform === "win32" || (process.getuid && process.getuid() === 0) }, function (t) { + [false, true].forEach(function (multiUser) { + var f = fixture(t, multiUser); + fs.chmodSync(f.external, 0); + try { + assert.equal(f.request("fs_read", f.external).errorCode, "EACCES"); + assert.equal(f.get(f.external, true).status, 403); + assert.match(f.get(f.external, true).body, /operating system denied/); + } finally { fs.chmodSync(f.external, 0o600); } + assert.equal(f.request("fs_read", f.external).content, "personal document"); + }); +}); + +test("mapped Linux denial never falls back to a readable server file", function (t) { + var f = fixture(t, true); + f.ctx.osUsers = true; + var calls = 0; + f.ctx.fsAsUser = function (operation, args, identity) { + assert.equal(identity.uid, 4321); + calls++; + var denied = new Error("mapped read denied"); + denied.stderr = "code: 'EACCES'"; + throw denied; + }; + var handler = attachFilesystem(f.ctx).handleFilesystemMessage; + handler(f.client, { type: "fs_read", path: f.external }); + assert.equal(f.sent[f.sent.length - 1].errorCode, "EACCES"); + assert.equal(f.get(f.external, true).status, 403); + assert.equal(calls, 2); + assert.equal(fs.readFileSync(f.external, "utf8"), "personal document"); + f.ctx.getOsUserInfoForWs = function () { return null; }; + handler(f.client, { type: "fs_read", path: f.external }); + assert.equal(f.sent[f.sent.length - 1].errorCode, "OS_IDENTITY"); + assert.equal(calls, 2); +}); + test("HTTP revalidates file permission and deleted users rather than cached request roles", function (t) { var f = fixture(t, true); fs.writeFileSync(path.join(f.ctx.cwd, "file"), "shared"); From 86b8ba3fdc6a0388d41b5f4ef5336d3879a54da4 Mon Sep 17 00:00:00 2001 From: chadbyte Date: Fri, 9 Oct 2026 15:04:18 +1300 Subject: [PATCH 3/3] fix(files): derive external access from the actual account count Allow the sole authenticated account to use process file permissions. Restore project boundaries for shared accounts without Linux isolation, including existing file and directory subscriptions. --- docs/guides/MODULE_MAP.md | 2 +- lib/project-file-path.js | 2 +- lib/project-request-access.js | 16 +++++++-- test/filebrowser-download.test.js | 3 +- test/project-file-authority.test.js | 51 ++++++++++++++++++++++++++++- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index 7cc823aec..e9dbfb65c 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -52,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: without Linux OS isolation, filesystem operations use the server process permissions regardless of account login mode; with isolation, they 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-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 36747f5ae..9a73ad77c 100644 --- a/lib/project-file-path.js +++ b/lib/project-file-path.js @@ -13,7 +13,7 @@ function inside(base, target) { } // Scope is server-derived for each request. File-viewer operations follow the -// executing OS identity; callers requiring a project boundary can still opt in. +// 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 3073761df..2bf0e2ad4 100644 --- a/lib/project-request-access.js +++ b/lib/project-request-access.js @@ -60,12 +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); - // Account login mode does not define a filesystem boundary. Without OS - // isolation, reads/writes run as the server; with it, they use this identity. - return { identity: identity, projectBound: false }; + // 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/test/filebrowser-download.test.js b/test/filebrowser-download.test.js index d474aebe2..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 () { diff --git a/test/project-file-authority.test.js b/test/project-file-authority.test.js index fdde0c774..af63c6bb2 100644 --- a/test/project-file-authority.test.js +++ b/test/project-file-authority.test.js @@ -18,6 +18,7 @@ function fixture(t, multiUser) { fs.writeFileSync(picture, "image bytes"); var user = { id: "user", role: "admin", linuxUser: "mapped" }; var activeUser = user; + var accounts = [user]; var allowed = true; var projectAllowed = true; var sent = []; @@ -27,6 +28,7 @@ function fixture(t, multiUser) { opts: { canAccessProjectSlug: function () { return projectAllowed; } }, usersModule: { isMultiUser: function () { return multiUser; }, + getAllUsers: function () { return accounts; }, findUserById: function () { return activeUser; }, getEffectivePermissions: function () { return { fileBrowser: allowed }; }, }, @@ -54,6 +56,7 @@ function fixture(t, multiUser) { return response; } return { ctx: ctx, client: client, user: user, external: external, picture: picture, sent: sent, request: request, get: get, + addUser: function () { accounts.push({ id: "second", role: "member" }); }, watcher: watcher, revoke: function () { allowed = false; }, revokeProject: function () { projectAllowed = false; }, removeUser: function () { activeUser = null; } }; } @@ -80,7 +83,7 @@ test((multiUser ? "account login" : "single-user") + " external files open, save }); }); -test("without OS isolation admins and members follow process access for absolute, relative and symlink paths", function (t) { +test("a sole account without OS isolation follows process access for absolute, relative and symlink paths", function (t) { var f = fixture(t, true); fs.symlinkSync(f.external, path.join(f.ctx.cwd, "escape.md")); ["admin", "member"].forEach(function (role) { @@ -145,6 +148,7 @@ test((multiUser ? "account login" : "single-user") + " OS denial is distinct fro test("mapped OS reads use fresh identity across WS and HTTP and never fall back after identity loss", function (t) { var f = fixture(t, true); + f.addUser(); f.ctx.osUsers = true; var calls = []; f.ctx.fsAsUser = function (operation, args, identity) { @@ -181,6 +185,7 @@ test("external reads respect actual process OS permissions", { skip: process.pla test("mapped Linux denial never falls back to a readable server file", function (t) { var f = fixture(t, true); + f.addUser(); f.ctx.osUsers = true; var calls = 0; f.ctx.fsAsUser = function (operation, args, identity) { @@ -209,3 +214,47 @@ test("HTTP revalidates file permission and deleted users rather than cached requ f.removeUser(); assert.equal(f.get("file", true).status, 403); }); + +test("adding a second account restores boundaries for requests and existing watches", async function (t) { + var f = fixture(t, true); + var callbacks = []; + var closed = 0; + t.mock.method(fs, "watch", function (dir, callback) { + callbacks.push(callback); + return { on: function () {}, close: function () { closed++; } }; + }); + assert.equal(await f.watcher.startFileWatch(f.client, f.external), true); + f.watcher.startDirWatch(f.client, ".."); + f.addUser(); + fs.symlinkSync(f.external, path.join(f.ctx.cwd, "escape.md")); + fs.writeFileSync(path.join(f.ctx.cwd, "inside.md"), "inside"); + ["admin", "member"].forEach(function (role) { + f.user.role = role; + [f.external, "../personal.md", "escape.md"].forEach(function (target) { + assert.equal(f.request("fs_read", target).errorCode, "FILE_SCOPE"); + assert.equal(f.request("fs_write", target, { content: "denied" }).errorCode, "FILE_SCOPE"); + assert.equal(f.get(target, true).status, 403); + }); + assert.equal(f.request("fs_list", "..").errorCode, "FILE_SCOPE"); + assert.equal(f.get(f.picture, false).status, 403); + assert.equal(f.request("fs_read", "inside.md").content, "inside"); + }); + var count = f.sent.length; + fs.writeFileSync(f.external, "must not publish"); + callbacks.forEach(function (callback) { callback("change", path.basename(f.external)); }); + await new Promise(function (resolve) { setTimeout(resolve, 400); }); + assert.equal(f.sent.length, count); + assert.equal(closed, 2); + assert.equal(await f.watcher.startFileWatch(f.client, f.external), false); +}); + +test("unknown, empty or mismatched account rosters retain the project boundary", function (t) { + var f = fixture(t, true); + [undefined, function () { throw new Error("unavailable"); }, + function () { return []; }, function () { return null; }, + function () { return [{ id: "other" }]; }].forEach(function (lookup) { + f.ctx.usersModule.getAllUsers = lookup; + assert.equal(f.request("fs_read", f.external).errorCode, "FILE_SCOPE"); + assert.equal(f.get(f.external, true).status, 403); + }); +});