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:
+106
-21
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user