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 @@ + + + + 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'); diff --git a/test/project-file-authority.test.js b/test/project-file-authority.test.js index add1e3a06..af63c6bb2 100644 --- a/test/project-file-authority.test.js +++ b/test/project-file-authority.test.js @@ -18,14 +18,17 @@ 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 = []; 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; }, + getAllUsers: function () { return accounts; }, findUserById: function () { return activeUser; }, getEffectivePermissions: function () { return { fileBrowser: allowed }; }, }, @@ -53,11 +56,14 @@ 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; } }; + addUser: function () { accounts.push({ id: "second", role: "member" }); }, + 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 +81,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("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) { 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,9 +120,35 @@ 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); + f.addUser(); f.ctx.osUsers = true; var calls = []; f.ctx.fsAsUser = function (operation, args, identity) { @@ -135,6 +170,43 @@ 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.addUser(); + 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"); @@ -142,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); + }); +});