diff --git a/CLAUDE.md b/CLAUDE.md index 0305472c..0c4258ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.73 (2026-11-04) — Claude quota rows carry `accountUuid`; the Artifacts folder bar is restyled._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.74 (2026-11-05) — a page published from its session's scratchpad keeps a viewable copy._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -61,7 +61,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`; a member's single file goes to a hidden temp name and is `write-as move`d onto its name only once every byte arrived, never written in place) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `/trash//`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `/trash//`, items tagged `store`). **An upload survives a dashboard restart because its bytes are staged first** (`server/lib/upload-staging.js`): parts go to `/upload-staging///` on the HOST, keyed by the caller's user and a browser-made id (never a path), and `?staged=` on `upload`/`upload-batch` writes them through the SAME code and guards — a commit's answer is stored and replayed, never written twice. Do not add a staged write that skips `upload`/`uploadBatch`, nor stage inside a member's home. Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`. - **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo. - **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-` sessions. -- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `/artifacts//` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus those shared with everyone (`shared`, the Hub) or with them by name (`artifact_shares`, **Shared with you** — the name list only its owner or an admin sees); only the owner or an admin changes one, and Share again never widens a by-name share to everyone. Artifact folders are the viewer's own (`agenthub.artifacts.folders` preference), never an artifact column. A `page` keeps a COPY anyone it reaches can view without the publishing claude.ai account — its HTML file (and the files the tool named) snapshotted at publish, or the `artifacts-export/.*` Claude saves for a Doc / Slides / Design (`scripts/artifact-export-hint.js`, the one hook that answers Claude: synchronous, matched to those tools only, silent outside AgentHub panes) attached at Stop; it is viewed in `HtmlFrame`'s sandbox or drawn by pdf.js, never fetched from claude.ai — do not add a route that proxies or logs into claude.ai for it. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. +- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `/artifacts//` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus those shared with everyone (`shared`, the Hub) or with them by name (`artifact_shares`, **Shared with you** — the name list only its owner or an admin sees); only the owner or an admin changes one, and Share again never widens a by-name share to everyone. Artifact folders are the viewer's own (`agenthub.artifacts.folders` preference), never an artifact column. A `page` keeps a COPY anyone it reaches can view without the publishing claude.ai account — its HTML file (and the files the tool named) snapshotted at publish from the workspace folder or the publishing session's own Claude Code scratchpad (`/claude-///scratchpad`, its folders real dirs of the workspace's account, each file opened `O_NOFOLLOW` and owned by it — never any other path), or the `artifacts-export/.*` Claude saves for a Doc / Slides / Design (`scripts/artifact-export-hint.js`, the one hook that answers Claude: synchronous, matched to those tools only, silent outside AgentHub panes) attached at Stop; it is viewed in `HtmlFrame`'s sandbox or drawn by pdf.js, never fetched from claude.ai — do not add a route that proxies or logs into claude.ai for it. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. - **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `/workspace-context//` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `/workspace-prompts//` (a member's: `prompt-/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md). - **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id ` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `/.claude/worktrees/`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder. diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index 8ebc718b..0174c684 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -270,7 +270,7 @@ "folders.takeOut": "Take it out of its folder", "page.view": "View", "page.viewHint": "Its copy, viewable here without the claude.ai account that published it", - "page.noCopy": "No viewable copy: the file this page was published from is gone. Open it on claude.ai instead.", + "page.noCopy": "No viewable copy: the file this page was published from is gone, or lies outside this workspace and its session's scratchpad. Open it on claude.ai instead.", "pdf.page": "Page {{n}}", "pdf.failed": "This PDF could not be shown. Download it instead.", "pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.", diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index 0eb4ed8c..777c673f 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -268,7 +268,7 @@ "folders.takeOut": "Bỏ khỏi thư mục", "page.view": "Xem", "page.viewHint": "Bản sao xem được ngay tại đây, không cần tài khoản claude.ai đã tạo trang", - "page.noCopy": "Chưa có bản xem: file gốc của trang này không còn. Hãy mở trên claude.ai.", + "page.noCopy": "Chưa có bản xem: file gốc của trang này không còn, hoặc nằm ngoài workspace và scratchpad của phiên. Hãy mở trên claude.ai.", "pdf.page": "Trang {{n}}", "pdf.failed": "Không hiển thị được PDF này. Hãy tải về.", "pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.", diff --git a/docs/plans/artifacts/README.md b/docs/plans/artifacts/README.md index 9d2d08cf..e46c7f8e 100644 --- a/docs/plans/artifacts/README.md +++ b/docs/plans/artifacts/README.md @@ -14,7 +14,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it: | `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` | - **Local mode:** one personal library, no sharing UI. -- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file, or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account. +- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file (from the workspace folder, or from the publishing session's own Claude Code scratchpad, where the Artifact tool writes a page by default), or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account. ## Rules (binding, same spirit as CLAUDE.md) diff --git a/server/__tests__/artifacts.test.js b/server/__tests__/artifacts.test.js index 21e43a24..cf8bf91b 100644 --- a/server/__tests__/artifacts.test.js +++ b/server/__tests__/artifacts.test.js @@ -30,6 +30,8 @@ process.env.CLAUDE_HOME = path.join(ROOT, "claude"); process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data"); process.env.DASHBOARD_REMOTE_SYNC_MS = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0"; +// Claude Code's scratchpads (`/claude-/…`) go under this test's own folder. +process.env.CLAUDE_CODE_TMPDIR = ROOT; const { createApp } = require("../index"); const { db } = require("../db"); @@ -1223,6 +1225,92 @@ describe("a page's own copy, viewable without its claude.ai account", () => { } }); + // The Artifact tool writes a page into the session's own scratchpad by default. + const scratchpad = (sid, cwd = ws) => + path.join( + ROOT, + `claude-${process.getuid()}`, + cwd.replace(/[^A-Za-z0-9]/g, "-"), + sid, + "scratchpad" + ); + const SID = "5f0c1e2a-6b7d-4c8e-9f10-112233445566"; + + it("copies a page published from its session's scratchpad", async () => { + db.prepare( + "INSERT OR IGNORE INTO sessions (id, name, status, cwd, lane_id) VALUES (?, 'sp', 'active', ?, ?)" + ).run(SID, ws, lane.id); + const dir = scratchpad(SID); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "themes.html"), "

themes

"); + fs.writeFileSync(path.join(dir, "app.js"), "1"); + const row = artifacts.captureFromHook(SID, { + cwd: ws, + tool_input: { + action: "publish", + file_path: path.join(dir, "themes.html"), + files: { "app.js": path.join(dir, "app.js"), "x.html": path.join(ws, "pub", "deck.html") }, + }, + tool_response: "https://claude.ai/artifact/scratch-1", + }); + await Promise.all(artifacts.__pageCopies()); + const kept = db.prepare("SELECT files FROM artifacts WHERE id = ?").get(row.id); + assert.ok(kept.files, "the page has a copy"); + // A supporting file comes only from the page's own root, never the workspace. + assert.deepEqual( + JSON.parse(kept.files).map((f) => f.path), + ["themes.html", "app.js"] + ); + assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /themes/); + }); + + it("never copies from another session's scratchpad or through a linked one", async () => { + const other = scratchpad("other-session-1"); + fs.mkdirSync(other, { recursive: true }); + fs.writeFileSync(path.join(other, "x.html"), "

not mine

"); + const foreign = artifacts.captureFromHook(SID, { + cwd: ws, + tool_input: { action: "publish", file_path: path.join(other, "x.html") }, + tool_response: "https://claude.ai/artifact/scratch-foreign", + }); + // A scratchpad that is a link to somewhere else is refused too. + const sid2 = "linked-session-2"; + const real = path.join(ROOT, "elsewhere"); + fs.mkdirSync(real, { recursive: true }); + fs.writeFileSync(path.join(real, "y.html"), "

elsewhere

"); + fs.mkdirSync(path.dirname(scratchpad(sid2)), { recursive: true }); + fs.symlinkSync(real, scratchpad(sid2)); + const linked = artifacts.captureFromHook(sid2, { + cwd: ws, + tool_input: { action: "publish", file_path: path.join(scratchpad(sid2), "y.html") }, + tool_response: "https://claude.ai/artifact/scratch-linked", + }); + await Promise.all(artifacts.__pageCopies()); + for (const r of [foreign, linked]) { + assert.equal( + db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot, + null + ); + } + }); + + it("takes an older scratchpad page's copy on first view", async () => { + fs.writeFileSync(path.join(scratchpad(SID), "older.html"), "

older

"); + const id = Number( + db + .prepare( + "INSERT INTO artifacts (kind, title, lane_id, session_id, source, url) VALUES ('page', 'older', ?, ?, ?, 'https://claude.ai/artifact/older-sp')" + ) + .run(lane.id, SID, path.join(scratchpad(SID), "older.html")).lastInsertRowid + ); + const r = await request("POST", `/api/artifacts/${id}/copy`, {}); + assert.equal(r.status, 200, r.text); + assert.deepEqual( + r.body.item.files.map((f) => f.path), + ["older.html"] + ); + }); + it("takes an older page's copy on first view, once", async () => { fs.writeFileSync(path.join(ws, "pub", "old.html"), "

old

"); const id = Number( diff --git a/server/lib/artifacts.js b/server/lib/artifacts.js index 25aa3f05..4301f40f 100644 --- a/server/lib/artifacts.js +++ b/server/lib/artifacts.js @@ -494,7 +494,8 @@ function captureFromHook(sessionId, data, peer = null) { const file = typeof input.file_path === "string" ? input.file_path : ""; // A publish of a page from a file: keep a copy anyone it is shared with can // view here, without a claude.ai login (a republish takes the new version). - const copy = laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files) : null; + const copy = + laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files, sessionId) : null; if (same) { if (copy) schedulePageSnapshot(same.id, copy); return same; @@ -522,6 +523,9 @@ function captureFromHook(sessionId, data, peer = null) { // such file: Claude is asked (scripts/artifact-export-hint.js) to export a // copy to `artifacts-export/.(md|pdf)`, which the Stop capture // attaches to the row instead (`captureOutputsNow`). +// The file is taken from the workspace folder OR from the publishing +// session's own Claude Code scratchpad — where the Artifact tool writes a +// page by default — and from nowhere else. const PAGE_FILE_EXT = /\.html?$/i; const PAGE_FILES_MAX = 50; @@ -530,29 +534,118 @@ const PUBLISHED_RE = /** Where Claude is asked to export a typed artifact's copy. */ const EXPORT_DIR = "artifacts-export"; +const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; +/** Claude Code's name for a project folder in its temp tree: every non-alphanumeric is "-". */ +const projectKey = (cwd) => String(cwd).replace(/[^A-Za-z0-9]/g, "-"); + +/** The uid a workspace's sessions run as (a member's under S3, else this process's), or null. */ +function workspaceUid(lane) { + try { + const acct = require("./run-as").accountForPath(lane.cwd); + return acct ? acct.uid : typeof process.getuid === "function" ? process.getuid() : null; + } catch { + return null; // EMEMBERGONE: nobody to read as + } +} + /** - * What to copy for a page: `{laneId, main, extra: [{rel, as}]}` with every - * path workspace-relative, or null when the main file is not an HTML file - * inside the workspace. `cwd` is the session's folder (a relative path in - * the hook is relative to it); `files` is the Artifact tool's supporting - * files map (`{published: source | {from}}`) or list (`[{path}]`). + * The scratchpad Claude Code gives ONE session — + * `/claude-///scratchpad`, `` being + * `/tmp`, `os.tmpdir()` or `CLAUDE_CODE_TMPDIR` — as `{dir, uid}`, when `abs` + * lies inside it; else null. Only the text is judged here: `scratchpadSafe` + * checks the folders on disk before anything is read. */ -function pageCopy(laneId, cwd, file, files) { +function scratchpadOf(lane, sessionId, cwd, abs) { + if (!SESSION_ID_RE.test(String(sessionId ?? ""))) return null; + const uid = workspaceUid(lane); + if (uid == null) return null; + const tail = path.join(`claude-${uid}`, projectKey(cwd), String(sessionId), "scratchpad"); + const bases = new Set( + [require("node:os").tmpdir(), "/tmp", process.env.CLAUDE_CODE_TMPDIR] + .filter((b) => typeof b === "string" && path.isAbsolute(b)) + .map((b) => path.resolve(b)) + ); + for (const b of bases) { + const dir = path.join(b, tail); + const rel = path.relative(dir, abs); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return { dir, uid }; + } + return null; +} + +/** Each of the scratchpad's four own folders is a real directory of its account (never a link). */ +async function scratchpadSafe(sp) { + let d = sp.dir; + for (let i = 0; i < 4; i += 1) { + let st; + try { + st = await fs.promises.lstat(d); + } catch { + return false; + } + if (!st.isDirectory() || st.uid !== sp.uid) return false; + d = path.dirname(d); + } + return true; +} + +/** + * Open `rel` inside a scratchpad like a pinned file is opened in a + * workspace: confined by its REAL path, not a link, a regular file of the + * scratchpad's account. `{size, stream}`. + */ +async function openScratch(sp, rel) { + const refused = () => + Object.assign(new Error("not a file of this scratchpad"), { code: "ENOFILE" }); + const root = await fs.promises.realpath(sp.dir); + const real = await fs.promises.realpath(path.resolve(sp.dir, rel)); + const r = path.relative(root, real); + if (!r || r.startsWith("..") || path.isAbsolute(r)) throw refused(); + const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fs.constants; + const fh = await fs.promises.open(real, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + const st = await fh.stat(); + if (!st.isFile() || st.uid !== sp.uid) { + await fh.close(); + throw refused(); + } + return { + size: st.size, + stream: fh.createReadStream({ start: 0, end: Math.max(0, st.size - 1) }), + }; +} + +/** + * What to copy for a page: `{laneId, main, extra: [{rel, as}], scratch?}`, + * every path relative to its root — the workspace folder, or (`scratch`, + * `{dir, uid}`) the publishing session's own scratchpad — or null when the + * main file is not an HTML file inside either. `cwd` is the session's folder + * (a relative path in the hook is relative to it); `files` is the Artifact + * tool's supporting files map (`{published: source | {from}}`) or list + * (`[{path}]`), each taken only from the main file's root. + */ +function pageCopy(laneId, cwd, file, files, sessionId) { const lane = require("./lanes").getLane(Number(laneId)); if (!lane || !PAGE_FILE_EXT.test(file)) return null; const base = typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : lane.cwd; - const inLane = (p) => { - const rel = path.relative(lane.cwd, path.resolve(base, p)); + const under = (root) => (p) => { + const rel = path.relative(root, path.resolve(base, p)); return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : null; }; - const main = inLane(file); - if (!main) return null; + let inRoot = under(lane.cwd); + let main = inRoot(file); + let scratch = null; + if (!main) { + scratch = scratchpadOf(lane, sessionId, base, path.resolve(base, file)); + if (!scratch) return null; + inRoot = under(scratch.dir); + main = inRoot(file); + } const extra = []; const add = (as, src) => { if (typeof as !== "string" || !PUBLISHED_RE.test(as) || typeof src !== "string") return; - const rel = inLane(src); + const rel = inRoot(src); if (rel && extra.length < PAGE_FILES_MAX) extra.push({ rel, as }); }; if (Array.isArray(files)) { @@ -562,7 +655,7 @@ function pageCopy(laneId, cwd, file, files) { add(as, typeof v === "string" ? v : v && typeof v.from === "string" ? v.from : null); } } - return { laneId: lane.id, main, extra }; + return scratch ? { laneId: lane.id, main, extra, scratch } : { laneId: lane.id, main, extra }; } /** @@ -571,9 +664,11 @@ function pageCopy(laneId, cwd, file, files) { * written at its published path `as`. A file that is a secret, gone or * outside the workspace is skipped; the total stays under FILE_MAX_BYTES. * Returns `{snap, files: [{path, size}]}`, or null when nothing was copied. + * `open(rel)` → `{size, stream}` reads one entry (default: the workspace's + * guarded download). */ -async function snapshotLaneFiles(lane, entries) { - const { openDownload } = require("./lane-file-transfer"); +async function snapshotLaneFiles(lane, entries, open) { + const read = open || ((rel) => require("./lane-file-transfer").openDownload(lane.cwd, rel)); const snap = newSnapshotDir(); const files = []; let total = 0; @@ -587,7 +682,7 @@ async function snapshotLaneFiles(lane, entries) { continue; let file; try { - file = await openDownload(lane.cwd, rel); + file = await read(rel); } catch { continue; } @@ -633,11 +728,14 @@ function setSnapshot(rowId, shot) { async function snapshotPage(rowId, copy) { const lane = require("./lanes").getLane(Number(copy.laneId)); if (!lane) return null; + const sp = copy.scratch; + if (sp && !(await scratchpadSafe(sp))) return null; const mainAs = path.posix.basename(copy.main); - const shot = await snapshotLaneFiles(lane, [ - { rel: copy.main, as: mainAs }, - ...copy.extra.filter((e) => e.as !== mainAs), - ]); + const shot = await snapshotLaneFiles( + lane, + [{ rel: copy.main, as: mainAs }, ...copy.extra.filter((e) => e.as !== mainAs)], + sp ? (rel) => openScratch(sp, rel) : undefined + ); if (!shot || shot.files[0].path !== mainAs) { if (shot) dropSnapshot(shot.snap.id); return null; @@ -681,7 +779,10 @@ async function ensurePageCopy(req, id) { } if (backfillTried.has(row.id)) return view(row, req); backfillTried.add(row.id); - const copy = pageCopy(row.lane_id, null, row.source, null); + const cwd = row.session_id + ? sql("SELECT cwd FROM sessions WHERE id = ?").get(row.session_id)?.cwd + : null; + const copy = pageCopy(row.lane_id, cwd || null, row.source, null, row.session_id); const next = copy ? await schedulePageSnapshot(row.id, copy) : null; return view(next || row, req); }