feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -504,7 +504,8 @@ router.get("/:id/outline", (req, res) => {
|
||||
|
||||
// An image the session's own Read tool returned (`?path=`, exact), for the Chat
|
||||
// pane's Files read preview when it lies outside the workspace (a screenshot
|
||||
// under ~/.cache, /tmp). The path is authorized only by that event, so: raster
|
||||
// under ~/.cache, /tmp) — or one its Bash or a reply named (a chart a script
|
||||
// drew, `sessionNamedFile`). The path is authorized only by that event, so: raster
|
||||
// types only, under a sandbox CSP with nosniff; never for a pushed session
|
||||
// (another machine's paths). In server mode only with S3, where a member's hook
|
||||
// reports sessions in its own home only (hook-auth.js): a member's session gets
|
||||
@@ -524,6 +525,56 @@ const sessionReadFile = db.prepare(
|
||||
AND json_extract(data, '$.tool_input.file_path') = ?
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
// The other way a session can point at a file (issue #76): a report its own
|
||||
// script generated (`python audit.py -o ~/Documents/r.md`), which no
|
||||
// Read/Write names, but its Bash command or output, or a reply's text, does.
|
||||
const sessionNamedText = db.prepare(
|
||||
`SELECT t FROM (
|
||||
SELECT coalesce(json_extract(data, '$.tool_input.command'), '') || char(10) ||
|
||||
coalesce(json_extract(data, '$.tool_response.stdout'), '') || char(10) ||
|
||||
coalesce(json_extract(data, '$.last_assistant_message'), '') AS t
|
||||
FROM events
|
||||
WHERE session_id = ?
|
||||
AND ((event_type = 'PostToolUse' AND tool_name = 'Bash')
|
||||
OR event_type IN ('Stop', 'SubagentStop')))
|
||||
WHERE instr(t, ?) > 0
|
||||
LIMIT 50`
|
||||
);
|
||||
const PATH_CHAR = /[\w./\\~-]/;
|
||||
/** Whether `text` names `file` as a whole path — `/a/b` is not named by
|
||||
* `/a/b.md` or `/x/a/b`; a `file://` link and a sentence's full stop are. */
|
||||
function namesPath(text, file) {
|
||||
for (let i = text.indexOf(file); i !== -1; i = text.indexOf(file, i + 1)) {
|
||||
const before = text[i - 1];
|
||||
const after = text[i + file.length];
|
||||
const okBefore =
|
||||
before === undefined ||
|
||||
!PATH_CHAR.test(before) ||
|
||||
text.slice(Math.max(0, i - 7), i).toLowerCase() === "file://";
|
||||
const okAfter =
|
||||
after === undefined ||
|
||||
(after === "." ? !/\w/.test(text[i + file.length + 1] || "") : !PATH_CHAR.test(after));
|
||||
if (okBefore && okAfter) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Whether the session's own Bash calls or replies named `file`. */
|
||||
function sessionNamedFile(sessionId, file) {
|
||||
return sessionNamedText.all(sessionId, file).some((r) => namesPath(r.t, file));
|
||||
}
|
||||
/** A file only NAMED (not touched) is served only when the session's account
|
||||
* could read it itself: access(2) checks without the unit's
|
||||
* CAP_DAC_READ_SEARCH, so a reply quoting `/etc/shadow` serves nothing. */
|
||||
async function readableByAccount(file, member) {
|
||||
if (member) return true; // inside their home and owned by them, checked below
|
||||
try {
|
||||
await fs.promises.access(file, fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
router.get("/:id/image", async (req, res) => {
|
||||
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
@@ -540,7 +591,10 @@ router.get("/:id/image", async (req, res) => {
|
||||
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||
}
|
||||
if (!sessionReadFile.get(session.id, file)) {
|
||||
if (
|
||||
!sessionReadFile.get(session.id, file) &&
|
||||
!(sessionNamedFile(session.id, file) && (await readableByAccount(file, member)))
|
||||
) {
|
||||
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||
}
|
||||
let fh;
|
||||
@@ -571,8 +625,10 @@ router.get("/:id/image", async (req, res) => {
|
||||
|
||||
// A text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit
|
||||
// named (`?path=`, exact), for the Chat pane's Outputs/Files preview when it
|
||||
// lies outside the workspace (a report written to ~/Documents, issue #76).
|
||||
// The same authorization as `/image` — that event, never a pushed session,
|
||||
// lies outside the workspace (a report written to ~/Documents, issue #76) —
|
||||
// or one its Bash command, its output or a reply named as a whole path (a
|
||||
// report a script generated, `sessionNamedFile`), when the session's account
|
||||
// could read it itself. The same authorization as `/image` — that event, never a pushed session,
|
||||
// server mode only with S3 and a member's session only inside their own home
|
||||
// on a file they own — plus: never a credential by name
|
||||
// (`isSecretConfigPath`, a `settings.local.json`), never a binary or a
|
||||
@@ -605,7 +661,10 @@ router.get("/:id/file", async (req, res) => {
|
||||
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||
}
|
||||
if (!sessionTouchedFile.get(session.id, file, file)) {
|
||||
if (
|
||||
!sessionTouchedFile.get(session.id, file, file) &&
|
||||
!(sessionNamedFile(session.id, file) && (await readableByAccount(file, member)))
|
||||
) {
|
||||
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||
}
|
||||
let fh;
|
||||
|
||||
Reference in New Issue
Block a user