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:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+64 -5
View File
@@ -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;