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:
@@ -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 (`<tmp>/claude-<uid>/…`) 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"), "<h1>themes</h1>");
|
||||
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"), "<p>not mine</p>");
|
||||
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"), "<p>elsewhere</p>");
|
||||
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"), "<p>older</p>");
|
||||
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"), "<p>old</p>");
|
||||
const id = Number(
|
||||
|
||||
Reference in New Issue
Block a user