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:
+71
-2
@@ -279,8 +279,8 @@ function stopHostPanes(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Disable (ending every login) or re-enable a user. Users are never deleted:
|
||||
* S2 will hang owned rows off their id. */
|
||||
/** Disable (ending every login) or re-enable a user — the reversible way out;
|
||||
* `deleteUser` below is the one that removes an account for good. */
|
||||
function setDisabled(username, disabled) {
|
||||
const u = requireUser(username);
|
||||
const linux = require("./linux-accounts");
|
||||
@@ -298,6 +298,74 @@ function setDisabled(username, disabled) {
|
||||
if (disabled && hasAccount) linux.provision("disable", u.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE a user — the Users page's "Delete user" (an admin, password
|
||||
* confirmed by the route). In order, so a failure leaves the account usable
|
||||
* rather than half gone: their logins end and the panes of their workspaces
|
||||
* stop; under S3 the Linux account `ah-<name>` and its whole home are removed
|
||||
* (`agenthub-provision delete`; EPROVISION stops here, with no row touched);
|
||||
* without S3 their own folder under AGENTHUB_USERS_ROOT goes. Then the rows:
|
||||
* their workspaces' records, prefs, push subscriptions, personal AI Proxy
|
||||
* profiles and the user. Session history and the audit log stay. Never the
|
||||
* last enabled admin (ELASTADMIN); the route refuses yourself (ESELF).
|
||||
* A user made again under the same name gets a NEW, empty Linux account.
|
||||
*/
|
||||
function deleteUser(username) {
|
||||
const u = requireUser(username);
|
||||
if (u.role === "admin") {
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
|
||||
}
|
||||
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
|
||||
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
const pty = require("./pty-run");
|
||||
for (const lane of lanes) {
|
||||
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
|
||||
try {
|
||||
pty.killRun(pty.runIdForLane(lane.id, slot));
|
||||
} catch {
|
||||
/* not running */
|
||||
}
|
||||
}
|
||||
}
|
||||
const linux = require("./linux-accounts");
|
||||
if (linux.s3Enabled()) {
|
||||
// Idempotent: a user with no Linux account (made before S3) or no home
|
||||
// still passes; anything the helper refuses stops the delete here.
|
||||
if (linux.LINUX_NAME_RE.test(u.username)) linux.provision("delete", u.username);
|
||||
} else {
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
|
||||
if (root && /^[a-z][a-z0-9_-]{0,63}$/i.test(u.username)) {
|
||||
const home = path.join(path.resolve(root), u.username);
|
||||
// Only a real folder directly in the users root — never a link.
|
||||
const st = fs.lstatSync(home, { throwIfNoEntry: false });
|
||||
if (st?.isDirectory()) fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
const lanesLib = require("./lanes");
|
||||
for (const lane of lanes) lanesLib.deleteLane(lane.id);
|
||||
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
|
||||
try {
|
||||
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
|
||||
} catch {
|
||||
/* an older database without the column */
|
||||
}
|
||||
try {
|
||||
require("node:fs").rmSync(
|
||||
require("node:path").join(require("./ai-proxies").proxiesDir(), "users", String(u.id)),
|
||||
{ recursive: true, force: true }
|
||||
);
|
||||
} catch {
|
||||
/* no personal profiles */
|
||||
}
|
||||
sql("DELETE FROM users WHERE id = ?").run(u.id);
|
||||
return { id: u.id, username: u.username, workspaces: lanes.length };
|
||||
}
|
||||
|
||||
/** Give a workspace (a lane id) to a user — for the ones made before server
|
||||
* mode, which have no owner and so reach no member. */
|
||||
function assignWorkspace(laneId, username) {
|
||||
@@ -603,6 +671,7 @@ module.exports = {
|
||||
setPassword,
|
||||
setRole,
|
||||
setDisabled,
|
||||
deleteUser,
|
||||
assignWorkspace,
|
||||
unassignWorkspace,
|
||||
ownedWorkspaces,
|
||||
|
||||
Reference in New Issue
Block a user