fix(artifacts): a page published from its session's scratchpad keeps a viewable copy

The Artifact tool writes a page into the session's own Claude Code
scratchpad by default (<tmp>/claude-<uid>/<project>/<session>/scratchpad),
but pageCopy took the file only from the workspace folder, so such a page
got no copy and Preview said "the file ... is gone" while it was still there.

pageCopy now also accepts that ONE scratchpad, named from the publishing
session's id, its cwd and the workspace account's uid; before reading,
each of its four folders must be a real directory of that account, and each
file is realpath-confined, opened O_NOFOLLOW and must be a regular file of
that uid. Supporting files come only from the page's own root. The older-
page backfill (POST /api/artifacts/:id/copy) uses the session's cwd too.
The noCopy message now says the file may also lie outside both.
This commit is contained in:
2026-11-05 11:20:34 +07:00
parent 2a31537b80
commit c356cf96a1
6 changed files with 215 additions and 26 deletions
+122 -21
View File
@@ -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/<artifact id>.(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 —
* `<tmp>/claude-<uid>/<project key>/<session id>/scratchpad`, `<tmp>` 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);
}