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
+106 -21
View File
@@ -735,8 +735,14 @@ router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
* login, 409 `ESSHAUTH` means the key was refused — 409 like the git bar's
* `EGITAUTH`, never 401, which the client reads as "signed out".
*/
router.post("/clone", sameOriginGuard, async (req, res) => {
const body = req.body || {};
/**
* The clone itself, shared by Add workspace → Clone and a workspace's Clone
* extra folder: every check above, then `git clone` as the folder's account.
* Answers the new folder's path, or null once it has sent the refusal.
* `before(target, ownerId)` runs after the checks and BEFORE the clone, so a
* refusal there never leaves minutes of cloning behind.
*/
async function cloneInto(req, res, body, before) {
const identity = require("../lib/git-identity");
let url;
try {
@@ -744,7 +750,8 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
typeof body.url === "string" ? body.url.trim() : body.url
);
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
res.status(400).json({ error: { code: err.code, message: err.message } });
return null;
}
const name =
typeof body.name === "string" && body.name.trim()
@@ -757,45 +764,57 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
/[/\\\0]/.test(name) ||
Buffer.byteLength(name) > 255
) {
return res
res
.status(400)
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
return null;
}
if (typeof body.parent !== "string" || !path.isAbsolute(body.parent)) {
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
return null;
}
if (body.tool !== undefined && !aiTools.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
return null;
}
const once = body.credentials;
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
return res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
return null;
}
const own = scope.ownFolder(req);
const limited = Boolean(req.user) && req.user.role !== "admin";
if (limited && !own) return refuseFolder(res);
if (limited && !own) {
refuseFolder(res);
return null;
}
const dir = own ? lanesLib.canonicalCwd(path.resolve(body.parent)) : path.resolve(body.parent);
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) {
refuseFolder(res);
return null;
}
try {
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
} catch {
return res
.status(400)
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
res.status(400).json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
return null;
}
const target = path.join(dir, name);
if (fs.existsSync(target) || isSymlink(target)) {
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
return null;
}
if (!scope.folderAllowed(req, target)) {
refuseFolder(res);
return null;
}
if (scope.folderTakenByOther(req, target)) {
refuseOwned(res);
return null;
}
if (!scope.folderAllowed(req, target)) return refuseFolder(res);
if (scope.folderTakenByOther(req, target)) return refuseOwned(res);
const ownerId = scope.ownerFor(req);
try {
// Before the clone: a workspace this user may not own there, or a tool
// that is disabled, is refused up front — not after minutes of cloning,
// leaving a clone with no workspace on it.
lanesLib.validateTool(body.tool || "claude");
require("../lib/run-as").assertOwnerFits(target, ownerId);
await before(target, ownerId);
await identity.cloneRepo({
url,
parent: dir,
@@ -810,7 +829,7 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
? 409
: 500;
return res.status(status).json({
res.status(status).json({
error: {
code: err.code || "ECLONE",
message: err.message,
@@ -819,7 +838,22 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
: {}),
},
});
return null;
}
return { target, name, ownerId };
}
router.post("/clone", sameOriginGuard, async (req, res) => {
const body = req.body || {};
// Before the clone: a workspace this user may not own there, or a tool
// that is disabled, is refused up front — not after minutes of cloning,
// leaving a clone with no workspace on it.
const done = await cloneInto(req, res, body, (target, ownerId) => {
lanesLib.validateTool(body.tool || "claude");
require("../lib/run-as").assertOwnerFits(target, ownerId);
});
if (!done) return;
const { target, name, ownerId } = done;
try {
const lane = lanesLib.createLane({
cwd: lanesLib.canonicalCwd(target),
@@ -838,6 +872,57 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
}
});
/**
* Clone a related repo as one of a workspace's extra folders: the same clone
* as above (URL allowlist, the folder account's own identity, `ECREDENTIALS`
* / `ESSHAUTH`), always BESIDE the workspace folder (its parent — no path
* from the request), then `lane_sources`' own folder checks. The clone is the
* user's from then on: removing the extra folder never deletes it.
*/
router.post("/:id/sources/clone", sameOriginGuard, async (req, res) => {
const lane = lanesLib.getLane(Number.parseInt(req.params.id, 10));
if (!lane) {
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
const sources = require("../lib/lane-sources");
if (!sources.supports(lane.tool)) {
return res
.status(400)
.json({ error: { code: "EUNSUPPORTED", message: "this tool takes no extra folders" } });
}
const body = req.body || {};
const done = await cloneInto(
req,
res,
{
url: body.url,
name: body.name,
credentials: body.credentials,
parent: path.dirname(lane.cwd),
},
() => {}
);
if (!done) return;
try {
const allowed = (p) => scope.folderAllowed(req, lanesLib.canonicalCwd(path.resolve(p)));
sources.addFolder(lane.id, done.target, { allowed });
broadcastLane(lane.id);
return res.status(201).json({
path: done.target,
instructions: lane.instructions || "",
supported: true,
maxFileBytes: sources.MAX_FILE_BYTES,
maxInstructions: sources.MAX_INSTRUCTIONS,
sources: sources.listSources(lane.id),
});
} catch (err) {
// The clone stays where it is; it can still be added by its path.
return res
.status(err.code === "EFOLDER" ? 403 : 400)
.json({ error: { code: err.code || "ESOURCE", message: err.message }, path: done.target });
}
});
function isSymlink(p) {
try {
return fs.lstatSync(p).isSymbolicLink();