Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/guides/MODULE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand Down
5 changes: 2 additions & 3 deletions lib/project-file-path.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
14 changes: 13 additions & 1 deletion lib/project-request-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions lib/public/css/filebrowser.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 28 additions & 8 deletions lib/public/modules/filebrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {};
Expand Down Expand Up @@ -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(); },
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
19 changes: 19 additions & 0 deletions lib/public/modules/html-preview.js
Original file line number Diff line number Diff line change
@@ -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 = "<!doctype html>\n" + doc.documentElement.outerHTML;
body.replaceChildren(frame);
}
11 changes: 6 additions & 5 deletions test/filebrowser-download.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand Down
35 changes: 35 additions & 0 deletions test/fixtures/html-browser.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html>
<html><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clay HTML file preview</title>
<link rel="stylesheet" href="/lib/public/css/base.css">
<link rel="stylesheet" href="/lib/public/css/messages.css">
<link rel="stylesheet" href="/lib/public/css/filebrowser.css">
<link rel="stylesheet" href="/lib/public/css/wireframe.css">
<script src="https://cdn.jsdelivr.net/npm/marked@14/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/common.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.468.0/dist/umd/lucide.min.js"></script>
<script>window.fixtureErrors = []; window.addEventListener("error", function (event) { window.fixtureErrors.push(String(event.error || event.message)); }); window.addEventListener("unhandledrejection", function (event) { window.fixtureErrors.push(String(event.reason)); });</script>
</head><body>
<style>.hidden {display:none!important} #file-tree {display:none} .fixture-toolbar {display:flex;flex-wrap:wrap;gap:4px} body {padding:24px;font:14px sans-serif} #main-panels {height:70vh;display:flex} #file-viewer {position:relative;flex:1;max-width:100%;width:100%;height:100%} #file-viewer-body {flex:1;overflow:auto;min-height:0} button {padding:7px;margin:3px} #fixture-result {white-space:pre-wrap}</style><h1>HTML file preview</h1><p>Real file viewer; simulated HTML file responses.</p><nav id="plantuml-controls"></nav><div id="main-panels"><div id="file-tree"></div>

<section id="file-viewer" class="hidden">
<div id="file-viewer-tabs"></div><div id="file-viewer-path"></div>
<div class="fixture-toolbar">
<button id="file-viewer-close">Close</button><button id="file-viewer-fullscreen">Fullscreen</button>
<button id="file-viewer-slide-level"></button><button id="file-viewer-download">Download</button>
<button id="file-viewer-refresh">Refresh</button>
<button id="file-viewer-render" class="hidden">Source / Preview</button><button id="file-viewer-copy">Copy source</button>
<button id="file-viewer-pdf" class="hidden"></button><button id="file-viewer-copy-formatted" class="hidden"></button>
<button id="file-viewer-slides" class="hidden"></button><button id="file-viewer-history" class="hidden"></button>
</div>
<div id="file-viewer-body"></div>
</section>
</div>
<input id="input" value="draft must survive file navigation">
<main id="messages" hidden><div id="rendered" class="md-content"></div></main>
<div id="fixture-result" role="status"></div>
<script type="module" src="/test/fixtures/html-browser.js"></script>
</body></html>
Loading
Loading