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:
@@ -3186,6 +3186,48 @@ describe("GET /api/sessions/:id/file", () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
assert.equal((await fetch(url(report))).status, 404);
|
||||
});
|
||||
|
||||
it("serves a file the session's Bash or reply named as a whole path (issue #76)", async () => {
|
||||
const sid = "named-file-" + Date.now();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-named-file-"));
|
||||
const report = path.join(dir, "REPORT_ssh_ec2.md");
|
||||
const out = path.join(dir, "out.txt");
|
||||
const chart = path.join(dir, "chart.png");
|
||||
const stem = path.join(dir, "REPORT");
|
||||
const unread = path.join(dir, "locked.md");
|
||||
fs.writeFileSync(report, "# Audit");
|
||||
fs.writeFileSync(out, "generated");
|
||||
fs.writeFileSync(chart, "PNGBYTES");
|
||||
fs.writeFileSync(stem, "stem");
|
||||
fs.writeFileSync(unread, "locked");
|
||||
fs.chmodSync(unread, 0o000);
|
||||
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
|
||||
await post("/api/hooks/event", {
|
||||
hook_type: "PostToolUse",
|
||||
data: {
|
||||
session_id: sid,
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: `python gen.py -o ${out} && plot ${chart} ${unread}` },
|
||||
tool_response: { stdout: "done", stderr: "" },
|
||||
},
|
||||
});
|
||||
await post("/api/hooks/event", {
|
||||
hook_type: "Stop",
|
||||
data: { session_id: sid, last_assistant_message: `Báo cáo: \`${report}\`.` },
|
||||
});
|
||||
const url = (p) => `/api/sessions/${sid}/file?path=${encodeURIComponent(p)}`;
|
||||
|
||||
assert.deepEqual((await fetch(url(report))).body, { text: "# Audit", truncated: false });
|
||||
assert.deepEqual((await fetch(url(out))).body, { text: "generated", truncated: false });
|
||||
const img = await fetch(`/api/sessions/${sid}/image?path=${encodeURIComponent(chart)}`);
|
||||
assert.equal(img.status, 200);
|
||||
// Only a WHOLE path: `<dir>/REPORT` is a prefix of the named report, not named itself.
|
||||
assert.equal((await fetch(url(stem))).status, 404);
|
||||
// Named, but its account could not read it (root sees through the mode).
|
||||
if (process.getuid?.() !== 0) assert.equal((await fetch(url(unread))).status, 404);
|
||||
fs.chmodSync(unread, 0o600);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/sessions/:id/image", () => {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @file Tests for the Artifacts hub (server/lib/artifacts.js, routes/artifacts.js):
|
||||
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
|
||||
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
|
||||
* symlink/secret refusal, install into the caller's Claude home with
|
||||
* replace moving the old one aside), plugins (marketplace allowlist, install
|
||||
* argv), sharing refused in local mode, visibility for a server-mode
|
||||
* developer, the admin's installs switch, and same-origin writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "artifacts-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const artifacts = require("../lib/artifacts");
|
||||
|
||||
const ws = path.join(ROOT, "ws");
|
||||
let lane;
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function request(method, p, body, headers = {}) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
text: await r.text(),
|
||||
})).then((r) => {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(r.text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return { ...r, body: json };
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(path.join(ws, "out"), { recursive: true });
|
||||
fs.writeFileSync(path.join(ws, "out", "report.html"), "<h1>hi</h1><script>x()</script>");
|
||||
fs.writeFileSync(path.join(ws, "main.js"), "code");
|
||||
const skill = path.join(ws, ".claude", "skills", "deploy");
|
||||
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(skill, "SKILL.md"), "---\nname: deploy\ndescription: Ship it\n---\nDo it.\n");
|
||||
fs.writeFileSync(path.join(skill, "scripts", "run.sh"), "echo hi\n");
|
||||
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
|
||||
lane = lanesLib.createLane({ cwd: ws, title: "ws" });
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s1', 's1', 'active', ?, ?)").run(
|
||||
ws,
|
||||
lane.id
|
||||
);
|
||||
const ev = db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PostToolUse', ?, ?)"
|
||||
);
|
||||
ev.run("Write", JSON.stringify({ tool_input: { file_path: path.join(ws, "out", "report.html") } }));
|
||||
ev.run("Edit", JSON.stringify({ tool_input: { file_path: path.join(ws, "main.js") } }));
|
||||
ev.run("Write", JSON.stringify({ tool_input: { file_path: "/etc/passwd.md" } }));
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((r) => server.close(r));
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("pages", () => {
|
||||
it("accepts only a claude.ai link, and keeps one row per link", async () => {
|
||||
for (const url of ["http://claude.ai/x", "https://evil.example/claude.ai/", "javascript:x"]) {
|
||||
const r = await request("POST", "/api/artifacts/page", { url });
|
||||
assert.equal(r.status, 400, url);
|
||||
assert.equal(r.body.error.code, "EBADURL");
|
||||
}
|
||||
const url = "https://claude.ai/code/artifact/abc-123";
|
||||
const a = await request("POST", "/api/artifacts/page", { url, title: "One" });
|
||||
assert.equal(a.status, 201);
|
||||
const b = await request("POST", "/api/artifacts/page", { url, title: "Two" });
|
||||
assert.equal(b.body.item.id, a.body.item.id);
|
||||
assert.equal(b.body.item.title, "Two");
|
||||
});
|
||||
|
||||
it("captures a publish from the Artifact tool's hook, owned by the session's workspace", () => {
|
||||
const row = artifacts.captureFromHook("s1", {
|
||||
tool_input: { action: "publish", file_path: "/x/plan.html" },
|
||||
tool_response: "Published: https://claude.ai/artifact/zz9-Q\nWatching it.",
|
||||
});
|
||||
assert.equal(row.url, "https://claude.ai/artifact/zz9-Q");
|
||||
assert.equal(row.title, "plan.html");
|
||||
assert.equal(row.lane_id, lane.id);
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_input: { action: "read" } }), null);
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("files", () => {
|
||||
it("lists only documents a session wrote inside the workspace", async () => {
|
||||
const r = await request("GET", `/api/artifacts/candidates?laneId=${lane.id}`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.items.map((i) => i.path),
|
||||
["out/report.html"]
|
||||
);
|
||||
});
|
||||
|
||||
it("pins a snapshot and serves it sandboxed, never a path outside it", async () => {
|
||||
const r = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "out/report.html" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
const id = r.body.item.id;
|
||||
// The live file changes; the artifact does not.
|
||||
fs.writeFileSync(path.join(ws, "out", "report.html"), "changed");
|
||||
const raw = await request("GET", `/api/artifacts/${id}/raw`);
|
||||
assert.equal(raw.status, 200);
|
||||
assert.match(raw.text, /<h1>hi<\/h1>/);
|
||||
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(raw.headers.get("x-content-type-options"), "nosniff");
|
||||
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||
assert.equal(other.status, 404);
|
||||
|
||||
const escape = await request("POST", "/api/artifacts/file", { laneId: lane.id, path: "../t.db" });
|
||||
assert.equal(escape.status, 400);
|
||||
|
||||
const del = await request("DELETE", `/api/artifacts/${id}`);
|
||||
assert.equal(del.status, 200);
|
||||
assert.equal((await request("GET", `/api/artifacts/${id}`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills", () => {
|
||||
it("snapshots a workspace skill and installs it into the caller's Claude home", async () => {
|
||||
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
|
||||
assert.deepEqual(list.body.items, [{ name: "deploy", from: "workspace" }]);
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
assert.equal(r.body.item.description, "Ship it");
|
||||
assert.deepEqual(
|
||||
r.body.item.files.map((f) => f.path).sort(),
|
||||
["SKILL.md", "scripts/run.sh"]
|
||||
);
|
||||
const id = r.body.item.id;
|
||||
|
||||
const first = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
assert.equal(first.status, 200, first.text);
|
||||
const target = path.join(process.env.CLAUDE_HOME, "skills", "deploy");
|
||||
assert.equal(fs.readFileSync(path.join(target, "scripts", "run.sh"), "utf8"), "echo hi\n");
|
||||
|
||||
const again = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
assert.equal(again.status, 409);
|
||||
assert.equal(again.body.error.code, "EEXISTS");
|
||||
fs.writeFileSync(path.join(target, "mine.txt"), "keep me");
|
||||
const replaced = await request("POST", `/api/artifacts/${id}/install`, { replace: true });
|
||||
assert.equal(replaced.status, 200, replaced.text);
|
||||
assert.ok(!fs.existsSync(path.join(target, "mine.txt")));
|
||||
const aside = path.join(process.env.CLAUDE_HOME, "agenthub-replaced-skills");
|
||||
const [old] = fs.readdirSync(aside);
|
||||
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
|
||||
});
|
||||
|
||||
it("refuses a skill holding a symlink or a secret-looking file", async () => {
|
||||
const bad = path.join(ws, ".claude", "skills", "leaky");
|
||||
fs.mkdirSync(bad, { recursive: true });
|
||||
fs.writeFileSync(path.join(bad, "SKILL.md"), "x");
|
||||
fs.symlinkSync("/etc/passwd", path.join(bad, "link"));
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "leaky" });
|
||||
assert.equal(r.body.error.code, "ESYMLINK");
|
||||
fs.unlinkSync(path.join(bad, "link"));
|
||||
fs.writeFileSync(path.join(bad, ".env"), "KEY=1");
|
||||
const s = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "leaky" });
|
||||
assert.equal(s.body.error.code, "ESECRET");
|
||||
});
|
||||
|
||||
it("is not installed while an admin has turned installs off", async () => {
|
||||
const item = (await request("GET", "/api/artifacts?kind=skill")).body.items[0];
|
||||
const off = await request("PUT", "/api/artifacts/settings", { installsEnabled: false });
|
||||
assert.equal(off.body.installsEnabled, false);
|
||||
const r = await request("POST", `/api/artifacts/${item.id}/install`, { replace: true });
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EINSTALLSOFF");
|
||||
await request("PUT", "/api/artifacts/settings", { installsEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugins", () => {
|
||||
it("takes only name@marketplace from owner/repo or a remote URL, and installs without -y", async () => {
|
||||
const bad = await request("POST", "/api/artifacts/plugin", { key: "x@y", market: "/etc" });
|
||||
assert.equal(bad.body.error.code, "EBADMARKET");
|
||||
const dash = await request("POST", "/api/artifacts/plugin", { key: "-x@y", market: "o/r" });
|
||||
assert.equal(dash.body.error.code, "EBADPLUGIN");
|
||||
const r = await request("POST", "/api/artifacts/plugin", { key: "tool@market", market: "o/r" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
const calls = [];
|
||||
artifacts.__setExecImpl(async (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
return { stdout: "" };
|
||||
});
|
||||
const inst = await request("POST", `/api/artifacts/${r.body.item.id}/install`, {});
|
||||
assert.equal(inst.status, 200, inst.text);
|
||||
assert.deepEqual(calls, [
|
||||
["claude", "plugin", "marketplace", "add", "o/r"],
|
||||
["claude", "plugin", "install", "--scope", "user", "tool@market"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharing and visibility", () => {
|
||||
it("refuses to share in local mode", async () => {
|
||||
const item = (await request("GET", "/api/artifacts")).body.items[0];
|
||||
const r = await request("PATCH", `/api/artifacts/${item.id}`, { shared: true });
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "ELOCALMODE");
|
||||
});
|
||||
|
||||
it("shows a server-mode developer their own rows and shared ones only", () => {
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'a', 7, 'https://claude.ai/a')").run();
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'b', 8, 'https://claude.ai/b', 1)").run();
|
||||
db.prepare("INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'c', 8, 'https://claude.ai/c')").run();
|
||||
const dev = { method: "GET", user: { id: 7, role: "developer", username: "d" } };
|
||||
const titles = artifacts.list(dev).map((i) => i.title).sort();
|
||||
assert.deepEqual(titles, ["a", "b"]);
|
||||
assert.deepEqual(artifacts.list(dev, { scope: "hub" }).map((i) => i.title), ["b"]);
|
||||
const hidden = db.prepare("SELECT id FROM artifacts WHERE title = 'c'").get().id;
|
||||
assert.equal(artifacts.canSeeId(dev, hidden), false);
|
||||
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
|
||||
assert.throws(() => artifacts.update(dev, shared, { title: "x" }), { code: "EFORBIDDEN" });
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await request(
|
||||
"POST",
|
||||
"/api/artifacts/page",
|
||||
{ url: "https://claude.ai/artifact/q" },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
@@ -150,6 +150,36 @@ describe("dashboard-runs", () => {
|
||||
assert.deepEqual(offered(), []);
|
||||
});
|
||||
|
||||
it("does not offer a pane that ended by itself while its tmux server ran on", () => {
|
||||
const { db } = require("../db");
|
||||
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (21, 'w', '/j')").run();
|
||||
const session = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, ?, 'active', '/j')"
|
||||
);
|
||||
for (const id of ["q-codex", "q-signal", "q-crash", "q-reboot"]) session.run(id, id);
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, data) VALUES ('q-signal', 'SessionEnd', ?)"
|
||||
).run(JSON.stringify({ reason: "other" }));
|
||||
start("agenthub-lane-21", { laneId: 21, sessionId: "q-codex", tool: "codex" });
|
||||
start("agenthub-lane-21-1", { laneId: 21, sessionId: "q-signal" });
|
||||
start("agenthub-lane-21-2", { laneId: 21, sessionId: "q-crash" });
|
||||
start("agenthub-lane-21-3", { laneId: 21, sessionId: "q-reboot", tool: "codex" });
|
||||
|
||||
// The host's tmux has run since long before; lane 21-3's server is gone.
|
||||
const since = (id) => (id === "agenthub-lane-21-3" ? null : 0);
|
||||
runs.reconcileOrphans((id) => !id.startsWith("agenthub-lane-21"), since);
|
||||
const status = (id) => runs.getRun(id).status;
|
||||
assert.equal(status("agenthub-lane-21"), "abandoned", "Codex quit by the user");
|
||||
assert.equal(status("agenthub-lane-21-1"), "abandoned", "closed, tmux ran on");
|
||||
assert.equal(status("agenthub-lane-21-2"), "interrupted", "Claude never said it ended");
|
||||
assert.equal(status("agenthub-lane-21-3"), "interrupted", "died with its server");
|
||||
|
||||
// A server newer than the run restarted under it: interrupted too.
|
||||
start("agenthub-lane-21-4", { laneId: 21, sessionId: "q-codex", tool: "codex" });
|
||||
runs.reconcileOrphans((id) => !id.startsWith("agenthub-lane-21"), () => Date.now());
|
||||
assert.equal(status("agenthub-lane-21-4"), "interrupted");
|
||||
});
|
||||
|
||||
it("gives a hookless tool's session the lane of the run it started in", () => {
|
||||
const { db } = require("../db");
|
||||
start("agenthub-lane-7-1", {
|
||||
|
||||
@@ -192,6 +192,54 @@ describe("POST /api/lanes/clone", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/lanes/:id/sources/clone", () => {
|
||||
it("clones beside the workspace folder and adds it as an extra folder", async () => {
|
||||
const home = path.join(WORK, "app");
|
||||
fs.mkdirSync(home);
|
||||
const made = await request("POST", "/api/lanes/ensure", { cwd: home });
|
||||
assert.ok([200, 201].includes(made.status), JSON.stringify(made.body));
|
||||
const id = made.body.lane.id;
|
||||
|
||||
const r = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||
url: "https://forge.test/o/r.git",
|
||||
name: "lib",
|
||||
// Ignored: the clone always lands beside the workspace folder.
|
||||
parent: path.join(ROOT, "elsewhere"),
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const target = path.join(WORK, "lib");
|
||||
assert.equal(fs.realpathSync(r.body.path), fs.realpathSync(target));
|
||||
assert.equal(fs.readFileSync(path.join(target, "README.md"), "utf8"), "hi\n");
|
||||
const folders = r.body.sources.filter((s) => s.kind === "folder");
|
||||
assert.deepEqual(
|
||||
folders.map((f) => fs.realpathSync(f.path)),
|
||||
[fs.realpathSync(target)]
|
||||
);
|
||||
|
||||
const again = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||
url: "https://forge.test/o/r.git",
|
||||
name: "lib",
|
||||
});
|
||||
assert.equal(again.status, 409);
|
||||
assert.equal(again.body.error.code, "EEXISTS");
|
||||
|
||||
const login = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||
url: "https://private.test/o/r.git",
|
||||
name: "private-lib",
|
||||
});
|
||||
assert.equal(login.status, 409);
|
||||
assert.equal(login.body.error.code, "ECREDENTIALS");
|
||||
assert.equal(login.body.error.auth.host, "private.test");
|
||||
});
|
||||
|
||||
it("answers 404 for a workspace that does not exist", async () => {
|
||||
const r = await request("POST", "/api/lanes/99999/sources/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
});
|
||||
assert.equal(r.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/me/git", () => {
|
||||
it("makes a key once, returns only its public half, and deletes it", async () => {
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
@@ -219,6 +267,29 @@ describe("/api/me/git", () => {
|
||||
assert.equal(del.body.ssh, null);
|
||||
});
|
||||
|
||||
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
|
||||
await request("POST", "/api/me/git/ssh-key", {});
|
||||
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
|
||||
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
|
||||
assert.equal(rsa.body.ssh.type, "rsa");
|
||||
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
|
||||
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
|
||||
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
|
||||
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
|
||||
assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced");
|
||||
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.match(bits, /^4096 .*\(RSA\)/);
|
||||
assert.equal(
|
||||
(await request("POST", "/api/me/git/ssh-key", { replace: true, type: "dsa" })).status,
|
||||
201
|
||||
);
|
||||
// An unknown type falls back to the default (the route only passes rsa through).
|
||||
assert.equal((await request("GET", "/api/me/git")).body.ssh.type, "ed25519");
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
});
|
||||
|
||||
it("reads and sets the commit author, refusing a bad one", async () => {
|
||||
const r0 = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r0.body.author, { name: null, email: null });
|
||||
@@ -282,10 +353,10 @@ describe("git-identity, member account (S3, stubbed)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the key and the logins in the member's own home", () => {
|
||||
it("keeps the key and the logins in the member's own home", async () => {
|
||||
const account = identity.accountForDir(path.join(HOME, "proj"));
|
||||
assert.equal(account.kind, "member");
|
||||
identity.generateSshKey(account);
|
||||
await identity.generateSshKey(account);
|
||||
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
|
||||
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
|
||||
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @file Tests for deleting a user (`DELETE /api/users/:id`, auth.deleteUser):
|
||||
* the admin's own password again, never yourself (ESELF) or the last admin
|
||||
* (ELASTADMIN); the user's logins end, their workspaces' records, prefs and
|
||||
* folder go, the audit log keeps the deed — and under S3 the root helper is
|
||||
* asked to `delete` the Linux account and home FIRST, so its refusal leaves
|
||||
* every row in place.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-delete-"));
|
||||
const USERS = path.join(TMP, "users");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const ids = {};
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||
}
|
||||
|
||||
const lane = (cwd, owner) =>
|
||||
Number(
|
||||
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(cwd, owner)
|
||||
.lastInsertRowid
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||
fs.mkdirSync(path.join(USERS, "mia", "proj"), { recursive: true });
|
||||
fs.writeFileSync(path.join(USERS, "mia", "proj", "a.txt"), "x");
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
ids.miaLane = lane(path.join(USERS, "mia", "proj"), ids.mia);
|
||||
db.prepare("INSERT INTO user_prefs (user_key, prefs) VALUES (?, '{}')").run(`u${ids.mia}`);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
delete process.env.AGENTHUB_SERVER_CONF;
|
||||
linux.__setRunImpl(null);
|
||||
linux.__resetConfCache();
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Delete user (DELETE /api/users/:id)", () => {
|
||||
it("needs the admin's own password, and never deletes yourself", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const wrong = await call("DELETE", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: "nope" },
|
||||
});
|
||||
assert.equal(wrong.status, 403);
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
const self = await call("DELETE", `/api/users/${ids.boss}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(self.status, 409);
|
||||
assert.equal(self.body.error.code, "ESELF");
|
||||
assert.ok(db.prepare("SELECT 1 FROM users WHERE id = ?").get(ids.mia));
|
||||
});
|
||||
|
||||
it("refuses a developer", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const r = await call("DELETE", `/api/users/${ids.boss}`, {
|
||||
cookie: mia,
|
||||
body: { currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("deletes the user, their logins, workspaces' records, prefs and folder — audited", async () => {
|
||||
const miaCookie = await login("mia", "member-pass-1");
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("DELETE", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.equal(db.prepare("SELECT 1 FROM users WHERE id = ?").get(ids.mia), undefined);
|
||||
assert.equal(db.prepare("SELECT 1 FROM lanes WHERE id = ?").get(ids.miaLane), undefined);
|
||||
assert.equal(
|
||||
db.prepare("SELECT 1 FROM user_prefs WHERE user_key = ?").get(`u${ids.mia}`),
|
||||
undefined
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(USERS, "mia")), "her folder is gone");
|
||||
// Her old login no longer works.
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: miaCookie })).status, 401);
|
||||
const audit = db
|
||||
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC")
|
||||
.get(ids.mia);
|
||||
assert.match(audit.path, /^delete mia/);
|
||||
// The same name can be made again, as a new account.
|
||||
const again = await auth.createUser("mia", "member-pass-3", "developer");
|
||||
assert.notEqual(again.id, ids.mia);
|
||||
});
|
||||
|
||||
it("never deletes the last admin", () => {
|
||||
assert.throws(() => auth.deleteUser("boss"), { code: "ELASTADMIN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Delete user under S3", () => {
|
||||
const CONF = path.join(TMP, "server.conf");
|
||||
let calls;
|
||||
before(() => {
|
||||
fs.writeFileSync(CONF, `HOST_USER=h\nHOST_UID=1000\nUSERS_ROOT=${USERS}\n`);
|
||||
process.env.AGENTHUB_SERVER_CONF = CONF;
|
||||
linux.__resetConfCache();
|
||||
});
|
||||
|
||||
it("asks the root helper to delete the account and home first", async () => {
|
||||
calls = [];
|
||||
linux.__setRunImpl((argv) => {
|
||||
calls.push(argv);
|
||||
return "ok";
|
||||
});
|
||||
const u = await auth.createUser("leo", "member-pass-2", "developer");
|
||||
calls = [];
|
||||
auth.deleteUser("leo");
|
||||
assert.deepEqual(calls, [["/opt/agenthub/sbin/agenthub-provision", "delete", "leo"]]);
|
||||
assert.equal(db.prepare("SELECT 1 FROM users WHERE id = ?").get(u.id), undefined);
|
||||
});
|
||||
|
||||
it("keeps every row when the helper refuses", async () => {
|
||||
linux.__setRunImpl(() => "ok");
|
||||
const u = await auth.createUser("zoe", "member-pass-4", "developer");
|
||||
const zl = lane(path.join(USERS, "zoe", "p"), u.id);
|
||||
linux.__setRunImpl(() => {
|
||||
throw Object.assign(new Error("x"), { stderr: "ah-zoe is not an AgentHub member" });
|
||||
});
|
||||
assert.throws(() => auth.deleteUser("zoe"), { code: "EPROVISION" });
|
||||
assert.ok(db.prepare("SELECT 1 FROM users WHERE id = ?").get(u.id));
|
||||
assert.ok(db.prepare("SELECT 1 FROM lanes WHERE id = ?").get(zl));
|
||||
});
|
||||
});
|
||||
@@ -914,6 +914,37 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_sources_lane ON lane_sources(lane_id);
|
||||
`);
|
||||
|
||||
// The Artifacts hub (server/lib/artifacts.js, docs/plans/artifacts/): what
|
||||
// agents produced — a claude.ai page, a file, a skill, a plugin — kept by its
|
||||
// OWNER and, in server mode, shared to every user when `shared` is set. A
|
||||
// file or skill is a snapshot under <data dir>/artifacts/<snapshot>/, never
|
||||
// the owner's live copy. Additive.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS artifacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('page', 'file', 'skill', 'plugin')),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
owner_id INTEGER,
|
||||
lane_id INTEGER,
|
||||
session_id TEXT,
|
||||
source TEXT,
|
||||
market TEXT,
|
||||
url TEXT,
|
||||
snapshot TEXT,
|
||||
files TEXT,
|
||||
size INTEGER,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
shared_at TEXT,
|
||||
installs INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_artifacts_owner ON artifacts(owner_id, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_artifacts_shared ON artifacts(shared, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(session_id);
|
||||
`);
|
||||
|
||||
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
||||
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
||||
// becomes one run; each archived feature becomes one more. That is all the
|
||||
|
||||
+6
-4
@@ -210,6 +210,7 @@ function createApp() {
|
||||
app.use("/api/locks", locksRouter);
|
||||
app.use("/api/pets", petsRouter);
|
||||
app.use("/api/snapshots", snapshotsRouter);
|
||||
app.use("/api/artifacts", require("./routes/artifacts"));
|
||||
app.use("/api/alerts", alertsRouter);
|
||||
app.use("/api/budgets", budgetsRouter);
|
||||
app.use("/api/query", queryRouter);
|
||||
@@ -762,7 +763,7 @@ function startBackgroundServices() {
|
||||
if (Number.isFinite(HEAL_MS) && HEAL_MS > 0) {
|
||||
const { healAllRunIds } = require("./routes/lanes");
|
||||
const { reconcileOrphans } = require("./lib/dashboard-runs");
|
||||
const { liveSessions } = require("./lib/tmux");
|
||||
const { liveSessions, serverStartedAt } = require("./lib/tmux");
|
||||
const anyRun = require("./db").db.prepare(
|
||||
`SELECT EXISTS (SELECT 1 FROM lanes WHERE run_id IS NOT NULL)
|
||||
OR EXISTS (SELECT 1 FROM dashboard_runs WHERE status IN ('running', 'spawning')) AS any`
|
||||
@@ -782,7 +783,8 @@ function startBackgroundServices() {
|
||||
healAllRunIds(live);
|
||||
// A pane that quit (Ctrl+C, `exit`) ends its run record too; one that
|
||||
// died under its agent (killed tmux, crash) is offered for resume.
|
||||
if (reconcileOrphans((id) => live.has(id)) > 0) broadcast("runs_interrupted", {});
|
||||
if (reconcileOrphans((id) => live.has(id), serverStartedAt) > 0)
|
||||
broadcast("runs_interrupted", {});
|
||||
} catch (err) {
|
||||
console.warn("run heal failed:", err.message);
|
||||
}
|
||||
@@ -841,8 +843,8 @@ function startBackgroundServices() {
|
||||
// this process, owns it.
|
||||
try {
|
||||
const { reconcileOrphans } = require("./lib/dashboard-runs");
|
||||
const { hasSession } = require("./lib/tmux");
|
||||
const reconciled = reconcileOrphans(hasSession);
|
||||
const { hasSession, serverStartedAt } = require("./lib/tmux");
|
||||
const reconciled = reconcileOrphans(hasSession, serverStartedAt);
|
||||
if (reconciled > 0) {
|
||||
console.log(`[runs] reconciled ${reconciled} orphan run(s) → interrupted/abandoned`);
|
||||
}
|
||||
|
||||
@@ -494,6 +494,7 @@ async function testProxy(input = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
proxiesDir,
|
||||
listProxies,
|
||||
getProxy,
|
||||
createProxy,
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* @file artifacts.js
|
||||
* @description The Artifacts hub (docs/plans/artifacts/README.md): what agents
|
||||
* produce, kept per OWNER and — in server mode — shared to every user.
|
||||
*
|
||||
* Four kinds:
|
||||
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
||||
* only as `https://claude.ai/…`. Captured from the `Artifact`
|
||||
* tool's PostToolUse hook (`captureFromHook`) or pasted by hand.
|
||||
* - `file` a file an agent wrote in a workspace, pinned by the user.
|
||||
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
|
||||
* `.claude/skills/<name>`).
|
||||
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
|
||||
* files.
|
||||
*
|
||||
* Rules:
|
||||
* - A `file` or `skill` is a SNAPSHOT taken when it is added, written under
|
||||
* `<data dir>/artifacts/<snapshot>/` and never touched again. Every read a
|
||||
* viewer makes is of that copy — no route reads another user's home for
|
||||
* them — and a path from a request only ever picks one of the snapshot's
|
||||
* own recorded files (`snapshotFile`).
|
||||
* - Visibility: in local mode every row; in server mode a developer sees
|
||||
* their own rows plus shared ones, admin and analyst see every row (like
|
||||
* sessions), and only the owner (or an admin) changes or deletes one.
|
||||
* - Installing a shared skill or plugin is the viewer's click, into the
|
||||
* VIEWER's own account only (`installTarget`): a member's home under S3
|
||||
* (written AS them through `write-as`, `claude` run as them through sudo),
|
||||
* the host's Claude home for an admin or in local mode. An existing skill
|
||||
* is never overwritten without `replace`, and the old one is moved aside to
|
||||
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
|
||||
* installed without `-y`, so one whose marketplace declares an install
|
||||
* command fails rather than runs unseen. An admin can turn installs off
|
||||
* for everyone (`setInstallsEnabled`). Share, unshare and install are
|
||||
* written to `audit_log`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { promisify } = require("node:util");
|
||||
const { getDataDir, getClaudeHome } = require("./claude-home");
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const fail = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
|
||||
const isServer = () => require("./auth").isServerMode();
|
||||
|
||||
const FILE_MAX_BYTES = 25 * 1024 * 1024;
|
||||
const SKILL_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const SKILL_MAX_FILES = 300;
|
||||
const SKILL_MAX_DEPTH = 6;
|
||||
const TITLE_MAX = 200;
|
||||
const DESCRIPTION_MAX = 2000;
|
||||
|
||||
const KINDS = ["page", "file", "skill", "plugin"];
|
||||
// What the file finder offers to pin: documents, not source code.
|
||||
const DOC_EXT =
|
||||
/\.(html?|md|markdown|mdx|pdf|png|jpe?g|gif|webp|svg|csv|tsv|json|ya?ml|txt|log|xlsx?|docx?|pptx?|ipynb|mp4|webm|mp3|wav|zip)$/i;
|
||||
const PAGE_URL_RE = /^https:\/\/claude\.ai\/[A-Za-z0-9._~\-/?#=&%]+$/;
|
||||
const PAGE_IN_TEXT_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+[A-Za-z0-9._~\-/?#=&%]*/;
|
||||
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
||||
const SNAPSHOT_RE = /^[A-Za-z0-9_-]{32}$/;
|
||||
// A skill may not carry a login or a key: refused, never copied.
|
||||
const SECRET_NAME_RE =
|
||||
/(^\.env(\..*)?$|credential|secret|password|token|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|\.netrc$)/i;
|
||||
|
||||
const root = () => path.join(getDataDir(), "artifacts");
|
||||
const installsOffFlag = () => path.join(root(), "installs-off");
|
||||
|
||||
// ── Visibility ────────────────────────────────────────────────────────────
|
||||
|
||||
/** `{where, params}` over an `artifacts` alias `a`: the rows this request sees. */
|
||||
function visibleWhere(req) {
|
||||
const u = req?.user;
|
||||
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
||||
return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] };
|
||||
}
|
||||
|
||||
const canSee = (req, row) => {
|
||||
const u = req?.user;
|
||||
if (!row) return false;
|
||||
if (!u || u.role === "admin" || u.role === "analyst") return true;
|
||||
return row.owner_id === u.id || row.shared === 1;
|
||||
};
|
||||
|
||||
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
||||
const canChange = (req, row) => !req?.user || req.user.role === "admin" || row.owner_id === req.user.id;
|
||||
|
||||
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
||||
function canSeeId(req, id) {
|
||||
return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id)));
|
||||
}
|
||||
|
||||
function load(req, id) {
|
||||
const row = /^\d+$/.test(String(id))
|
||||
? sql("SELECT * FROM artifacts WHERE id = ?").get(Number(id))
|
||||
: null;
|
||||
if (!canSee(req, row)) throw fail("ENOTFOUND", "artifact not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
function view(row, req) {
|
||||
if (!row) return null;
|
||||
const owner =
|
||||
row.owner_id == null
|
||||
? null
|
||||
: sql("SELECT username, display_name FROM users WHERE id = ?").get(row.owner_id);
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
ownerId: row.owner_id,
|
||||
owner: owner ? owner.display_name || owner.username : null,
|
||||
mine: !req?.user || row.owner_id === req.user.id,
|
||||
canChange: canChange(req, row),
|
||||
laneId: row.lane_id,
|
||||
sessionId: row.session_id,
|
||||
source: row.source,
|
||||
market: row.market,
|
||||
url: row.url,
|
||||
files: row.files ? JSON.parse(row.files) : [],
|
||||
size: row.size,
|
||||
shared: row.shared === 1,
|
||||
sharedAt: row.shared_at,
|
||||
installs: row.installs,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows this request may see. `scope`: `mine` (own rows; every row in
|
||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
||||
*/
|
||||
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
||||
const v = visibleWhere(req);
|
||||
const where = [v.where];
|
||||
const params = [...v.params];
|
||||
if (scope === "mine" && req?.user) {
|
||||
where.push("a.owner_id = ?");
|
||||
params.push(req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
}
|
||||
if (kind) {
|
||||
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
|
||||
where.push("a.kind = ?");
|
||||
params.push(kind);
|
||||
}
|
||||
if (laneId != null && laneId !== "") {
|
||||
where.push("a.lane_id = ?");
|
||||
params.push(Number(laneId));
|
||||
}
|
||||
if (sessionId) {
|
||||
where.push("a.session_id = ?");
|
||||
params.push(String(sessionId));
|
||||
}
|
||||
if (q && String(q).trim()) {
|
||||
where.push("(a.title LIKE ? OR a.description LIKE ? OR a.source LIKE ?)");
|
||||
const like = `%${String(q).trim().replace(/[%_]/g, (c) => `\\${c}`)}%`;
|
||||
params.push(like, like, like);
|
||||
}
|
||||
const n = Math.min(Math.max(Number(limit) || 200, 1), 500);
|
||||
const rows = sql(
|
||||
`SELECT a.* FROM artifacts a WHERE ${where.join(" AND ")} ORDER BY a.updated_at DESC, a.id DESC LIMIT ${n}`
|
||||
).all(...params);
|
||||
return rows.map((r) => view(r, req));
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function cleanTitle(v, fallback) {
|
||||
const t = typeof v === "string" ? v.trim().slice(0, TITLE_MAX) : "";
|
||||
return t || String(fallback || "Untitled").slice(0, TITLE_MAX);
|
||||
}
|
||||
const cleanDescription = (v) =>
|
||||
typeof v === "string" ? v.trim().slice(0, DESCRIPTION_MAX) : "";
|
||||
|
||||
function insert(fields) {
|
||||
const cols = Object.keys(fields);
|
||||
const info = sql(
|
||||
`INSERT INTO artifacts (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`
|
||||
).run(...cols.map((c) => fields[c]));
|
||||
return sql("SELECT * FROM artifacts WHERE id = ?").get(Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
function newSnapshotDir() {
|
||||
const id = crypto.randomBytes(24).toString("base64url");
|
||||
const dir = path.join(root(), id);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
return { id, dir };
|
||||
}
|
||||
|
||||
function dropSnapshot(id) {
|
||||
if (!id || !SNAPSHOT_RE.test(id)) return;
|
||||
fs.rmSync(path.join(root(), id), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function laneOf(laneId) {
|
||||
const lane = require("./lanes").getLane(Number(laneId));
|
||||
if (!lane) throw fail("ENOLANE", "workspace not found");
|
||||
return lane;
|
||||
}
|
||||
|
||||
/** The owner of a session's workspace (null: unknown or local mode). */
|
||||
function sessionOwner(sessionId) {
|
||||
const owners = [...require("./scope").sessionOwners(sessionId)];
|
||||
return owners.length ? owners[0] : null;
|
||||
}
|
||||
|
||||
/** A session's workspace, if one owns it. */
|
||||
function sessionLane(sessionId) {
|
||||
const row =
|
||||
sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sessionId)) ||
|
||||
sql("SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL LIMIT 1").get(
|
||||
String(sessionId)
|
||||
);
|
||||
return row?.lane_id ?? null;
|
||||
}
|
||||
|
||||
// ── page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function validPageUrl(url) {
|
||||
const u = typeof url === "string" ? url.trim() : "";
|
||||
if (!PAGE_URL_RE.test(u) || u.length > 2000) {
|
||||
throw fail("EBADURL", "a claude.ai link (https://claude.ai/…) is required");
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
/** Add (or retitle) a claude.ai page by hand. */
|
||||
function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
const u = validPageUrl(url);
|
||||
const ownerId = req?.user ? req.user.id : null;
|
||||
const same = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?"
|
||||
).get(u, ownerId);
|
||||
if (same) {
|
||||
sql(
|
||||
"UPDATE artifacts SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(cleanTitle(title, same.title), same.id);
|
||||
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
|
||||
}
|
||||
return view(
|
||||
insert({
|
||||
kind: "page",
|
||||
title: cleanTitle(title, u.replace(/^https:\/\//, "")),
|
||||
description: cleanDescription(description),
|
||||
owner_id: ownerId,
|
||||
lane_id: laneId != null && laneId !== "" ? Number(laneId) : null,
|
||||
session_id: sessionId ? String(sessionId) : null,
|
||||
url: u,
|
||||
}),
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
||||
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
|
||||
* owned by the session's workspace owner. Returns the row or null.
|
||||
*/
|
||||
function captureFromHook(sessionId, data) {
|
||||
const input = data?.tool_input || {};
|
||||
if (input.action && input.action !== "publish") return null;
|
||||
const text = JSON.stringify(data?.tool_response ?? "");
|
||||
const m = PAGE_IN_TEXT_RE.exec(text);
|
||||
if (!m) return null;
|
||||
const url = m[0].replace(/[\\"]+$/, "");
|
||||
if (!PAGE_URL_RE.test(url)) return null;
|
||||
const ownerId = sessionOwner(sessionId);
|
||||
const same = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?"
|
||||
).get(url, ownerId);
|
||||
if (same) return same;
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
return insert({
|
||||
kind: "page",
|
||||
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
|
||||
description: cleanDescription(input.description),
|
||||
owner_id: ownerId,
|
||||
lane_id: sessionLane(sessionId),
|
||||
session_id: String(sessionId),
|
||||
source: file ? file.slice(0, 1000) : null,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
// ── file ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Files the workspace's sessions wrote (Write/Edit/MultiEdit/NotebookEdit),
|
||||
* documents only, inside the workspace folder, newest first — what the user
|
||||
* can pin. `pinned` marks the ones already kept.
|
||||
*/
|
||||
function fileCandidates(laneId) {
|
||||
const lane = laneOf(laneId);
|
||||
const rows = sql(
|
||||
`SELECT session_id,
|
||||
COALESCE(json_extract(data, '$.tool_input.file_path'),
|
||||
json_extract(data, '$.tool_input.notebook_path')) AS fp,
|
||||
created_at
|
||||
FROM events
|
||||
WHERE event_type = 'PostToolUse'
|
||||
AND tool_name IN ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')
|
||||
AND session_id IN (SELECT id FROM sessions WHERE lane_id = ?
|
||||
UNION SELECT session_id FROM dashboard_runs
|
||||
WHERE lane_id = ? AND session_id IS NOT NULL)
|
||||
ORDER BY id DESC LIMIT 5000`
|
||||
).all(lane.id, lane.id);
|
||||
const pinned = new Set(
|
||||
sql("SELECT source FROM artifacts WHERE kind = 'file' AND lane_id = ?")
|
||||
.all(lane.id)
|
||||
.map((r) => r.source)
|
||||
);
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const r of rows) {
|
||||
if (typeof r.fp !== "string" || !path.isAbsolute(r.fp) || !DOC_EXT.test(r.fp)) continue;
|
||||
const rel = path.relative(lane.cwd, r.fp);
|
||||
if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
let exists = false;
|
||||
try {
|
||||
exists = fs.lstatSync(r.fp).isFile();
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
out.push({
|
||||
path: rel.split(path.sep).join("/"),
|
||||
sessionId: r.session_id,
|
||||
at: r.created_at,
|
||||
exists,
|
||||
pinned: pinned.has(rel.split(path.sep).join("/")),
|
||||
});
|
||||
if (out.length >= 200) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
|
||||
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
|
||||
const lane = laneOf(laneId);
|
||||
const { openDownload } = require("./lane-file-transfer");
|
||||
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
|
||||
if (file.size > FILE_MAX_BYTES) {
|
||||
file.stream.destroy();
|
||||
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
||||
}
|
||||
const snap = newSnapshotDir();
|
||||
const name = file.name;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 });
|
||||
file.stream.on("error", reject);
|
||||
out.on("error", reject);
|
||||
out.on("finish", resolve);
|
||||
file.stream.pipe(out);
|
||||
});
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
throw err;
|
||||
}
|
||||
const relPosix = require("./git-diff").lanePath(rel);
|
||||
return view(
|
||||
insert({
|
||||
kind: "file",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description),
|
||||
owner_id: req?.user ? req.user.id : lane.owner_id ?? null,
|
||||
lane_id: lane.id,
|
||||
session_id: sessionId ? String(sessionId) : null,
|
||||
source: relPosix,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify([{ path: name, size: file.size }]),
|
||||
size: file.size,
|
||||
}),
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
// ── skill ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The account a request's own Claude config lives in: `{acct, claudeHome}`.
|
||||
* A member under S3 (their home), else the host's Claude home — only in
|
||||
* local mode or for an admin, since it is the host's own configuration.
|
||||
*/
|
||||
function ownAccount(req) {
|
||||
if (!req?.user) return { acct: null, claudeHome: getClaudeHome() };
|
||||
const runAs = require("./run-as");
|
||||
let acct = null;
|
||||
try {
|
||||
acct = runAs.accountForUser(req.user.username);
|
||||
} catch {
|
||||
acct = null;
|
||||
}
|
||||
if (acct) return { acct, claudeHome: path.join(acct.home, ".claude") };
|
||||
if (req.user.role === "admin") return { acct: null, claudeHome: getClaudeHome() };
|
||||
throw fail(
|
||||
"ENOACCOUNT",
|
||||
"your account has no Claude config of its own on this server (one Linux user per account is not set up)"
|
||||
);
|
||||
}
|
||||
|
||||
/** Skill folders the request may share: its own and the workspace's. */
|
||||
function availableSkills(req, laneId) {
|
||||
const out = [];
|
||||
const scan = (dir, from) => {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const d of names) {
|
||||
if (!d.isDirectory() || !SKILL_NAME_RE.test(d.name)) continue;
|
||||
try {
|
||||
if (fs.lstatSync(path.join(dir, d.name, "SKILL.md")).isFile()) {
|
||||
out.push({ name: d.name, from });
|
||||
}
|
||||
} catch {
|
||||
/* not a skill */
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
scan(path.join(ownAccount(req).claudeHome, "skills"), "user");
|
||||
} catch {
|
||||
/* no own config: only the workspace's */
|
||||
}
|
||||
if (laneId != null && laneId !== "") {
|
||||
scan(path.join(laneOf(laneId).cwd, ".claude", "skills"), "workspace");
|
||||
}
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Every regular file under `dir`, capped; a symlink is refused, never followed. */
|
||||
function walkSkill(dir) {
|
||||
const files = [];
|
||||
let total = 0;
|
||||
const walk = (abs, rel, depth) => {
|
||||
if (depth > SKILL_MAX_DEPTH) throw fail("ETOOBIG", "the skill folder is nested too deep");
|
||||
for (const d of fs.readdirSync(abs, { withFileTypes: true })) {
|
||||
if (d.name === ".git" || d.name === "node_modules") continue;
|
||||
const childAbs = path.join(abs, d.name);
|
||||
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||
const st = fs.lstatSync(childAbs);
|
||||
if (st.isSymbolicLink()) throw fail("ESYMLINK", `the skill holds a symlink: ${childRel}`);
|
||||
if (st.isDirectory()) {
|
||||
walk(childAbs, childRel, depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (!st.isFile()) continue;
|
||||
if (SECRET_NAME_RE.test(d.name)) {
|
||||
throw fail("ESECRET", `the skill holds a file that looks like a secret: ${childRel}`);
|
||||
}
|
||||
total += st.size;
|
||||
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
|
||||
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
files.push({ abs: childAbs, path: childRel, size: st.size });
|
||||
}
|
||||
};
|
||||
walk(dir, "", 0);
|
||||
return { files, total };
|
||||
}
|
||||
|
||||
const inside = (parent, child) => {
|
||||
const rel = path.relative(parent, child);
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
};
|
||||
|
||||
/** Snapshot one skill folder — the user's own (`laneId` absent) or a workspace's. */
|
||||
function addSkill(req, { name, laneId, title, description }) {
|
||||
if (typeof name !== "string" || !SKILL_NAME_RE.test(name)) {
|
||||
throw fail("EBADNAME", "a skill folder name");
|
||||
}
|
||||
let base;
|
||||
let lane = null;
|
||||
if (laneId != null && laneId !== "") {
|
||||
lane = laneOf(laneId);
|
||||
base = path.join(lane.cwd, ".claude", "skills");
|
||||
} else {
|
||||
base = path.join(ownAccount(req).claudeHome, "skills");
|
||||
}
|
||||
const dir = path.join(base, name);
|
||||
let real;
|
||||
try {
|
||||
real = fs.realpathSync(dir);
|
||||
} catch {
|
||||
throw fail("ENOFILE", "no such skill");
|
||||
}
|
||||
// The folder must really be where its name says (no symlinked skill).
|
||||
const top = lane ? fs.realpathSync(lane.cwd) : fs.realpathSync(path.dirname(base));
|
||||
if (!inside(top, real) || path.basename(real) !== name) {
|
||||
throw fail("ESYMLINK", "the skill folder is a link elsewhere");
|
||||
}
|
||||
if (!fs.existsSync(path.join(real, "SKILL.md"))) throw fail("ENOFILE", "no SKILL.md in that folder");
|
||||
const { files, total } = walkSkill(real);
|
||||
const runAs = require("./run-as");
|
||||
const snap = newSnapshotDir();
|
||||
try {
|
||||
for (const f of files) {
|
||||
const target = path.join(snap.dir, ...f.path.split("/"));
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
throw err;
|
||||
}
|
||||
let summary = "";
|
||||
try {
|
||||
const head = fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8");
|
||||
summary = /^description:\s*(.+)$/m.exec(head)?.[1]?.trim() || "";
|
||||
} catch {
|
||||
/* no frontmatter */
|
||||
}
|
||||
return view(
|
||||
insert({
|
||||
kind: "skill",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: lane ? lane.id : null,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(files.map((f) => ({ path: f.path, size: f.size }))),
|
||||
size: total,
|
||||
}),
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
// ── plugin ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A marketplace a plugin may be installed from: `owner/repo` or a remote URL. */
|
||||
function validMarket(market) {
|
||||
const m = typeof market === "string" ? market.trim() : "";
|
||||
if (GITHUB_REPO_RE.test(m)) return m;
|
||||
try {
|
||||
return require("./git-ops").validateRemoteUrl(m);
|
||||
} catch {
|
||||
throw fail("EBADMARKET", "marketplace: owner/repo or an https/ssh git URL");
|
||||
}
|
||||
}
|
||||
|
||||
function addPlugin(req, { key, market, title, description }) {
|
||||
const k = typeof key === "string" ? key.trim() : "";
|
||||
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
return view(
|
||||
insert({
|
||||
kind: "plugin",
|
||||
title: cleanTitle(title, k),
|
||||
description: cleanDescription(description),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
source: k,
|
||||
market: validMarket(market),
|
||||
}),
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
// ── change / delete ───────────────────────────────────────────────────────
|
||||
|
||||
function audit(req, row, action) {
|
||||
if (!req?.user) return;
|
||||
try {
|
||||
require("./audit").recordAccountChange(
|
||||
req.user.id,
|
||||
row.owner_id ?? null,
|
||||
"POST",
|
||||
`artifact ${action}: #${row.id} ${row.kind} "${row.title}"`
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[artifacts] could not audit:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function update(req, id, body = {}) {
|
||||
const row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
||||
const sets = [];
|
||||
const params = [];
|
||||
if ("title" in body) {
|
||||
sets.push("title = ?");
|
||||
params.push(cleanTitle(body.title, row.title));
|
||||
}
|
||||
if ("description" in body) {
|
||||
sets.push("description = ?");
|
||||
params.push(cleanDescription(body.description));
|
||||
}
|
||||
let shareChange = null;
|
||||
if ("shared" in body) {
|
||||
if (typeof body.shared !== "boolean") throw fail("EBADFIELD", "shared: true or false");
|
||||
if (!isServer()) throw fail("ELOCALMODE", "sharing needs server mode");
|
||||
if (body.shared !== (row.shared === 1)) {
|
||||
shareChange = body.shared ? "shared" : "unshared";
|
||||
sets.push("shared = ?", "shared_at = ?");
|
||||
params.push(body.shared ? 1 : 0, body.shared ? new Date().toISOString() : null);
|
||||
}
|
||||
}
|
||||
if (!sets.length) return view(row, req);
|
||||
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
|
||||
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
|
||||
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
if (shareChange) audit(req, next, shareChange);
|
||||
return view(next, req);
|
||||
}
|
||||
|
||||
function remove(req, id) {
|
||||
const row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
|
||||
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
if (row.shared === 1) audit(req, row, "deleted");
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The absolute path of one RECORDED file of a row's snapshot (`rel` must match). */
|
||||
function snapshotFile(req, id, rel) {
|
||||
const row = load(req, id);
|
||||
if (!row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) throw fail("ENOFILE", "no files");
|
||||
const files = row.files ? JSON.parse(row.files) : [];
|
||||
const want = typeof rel === "string" && rel ? rel : files[0]?.path;
|
||||
const hit = files.find((f) => f.path === want);
|
||||
if (!hit) throw fail("ENOFILE", "no such file in this artifact");
|
||||
return { row, abs: path.join(root(), row.snapshot, ...hit.path.split("/")), name: path.basename(hit.path), size: hit.size };
|
||||
}
|
||||
|
||||
// ── install ───────────────────────────────────────────────────────────────
|
||||
|
||||
const installsEnabled = () => !fs.existsSync(installsOffFlag());
|
||||
|
||||
function setInstallsEnabled(on) {
|
||||
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
|
||||
if (on) fs.rmSync(installsOffFlag(), { force: true });
|
||||
else fs.writeFileSync(installsOffFlag(), "", { mode: 0o600 });
|
||||
return installsEnabled();
|
||||
}
|
||||
|
||||
function installable(req, row) {
|
||||
if (row.kind !== "skill" && row.kind !== "plugin") {
|
||||
throw fail("EBADKIND", "only a skill or a plugin is installed");
|
||||
}
|
||||
if (!installsEnabled()) throw fail("EINSTALLSOFF", "an admin turned Hub installs off");
|
||||
}
|
||||
|
||||
/** Install a skill snapshot into the viewer's own `~/.claude/skills/<name>`. */
|
||||
async function installSkill(req, row, { replace = false } = {}) {
|
||||
const { acct, claudeHome } = ownAccount(req);
|
||||
const runAs = require("./run-as");
|
||||
const skills = path.join(claudeHome, "skills");
|
||||
const target = path.join(skills, row.source);
|
||||
const taken = (() => {
|
||||
try {
|
||||
fs.lstatSync(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (taken && !replace) throw fail("EEXISTS", `a skill named ${row.source} is already installed`);
|
||||
if (taken) {
|
||||
const aside = path.join(
|
||||
claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}-${new Date().toISOString().replace(/[:.]/g, "-")}`
|
||||
);
|
||||
if (acct) {
|
||||
if (!runAs.writeAsCanMove()) {
|
||||
throw fail("EUPGRADE", "replacing needs the updated helper: run `sudo agenthub server install-system` once");
|
||||
}
|
||||
runAs.mkdirAs(acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(acct, target, ["move", aside]);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
||||
fs.renameSync(target, aside);
|
||||
}
|
||||
}
|
||||
const files = row.files ? JSON.parse(row.files) : [];
|
||||
for (const f of files) {
|
||||
const from = path.join(root(), row.snapshot, ...f.path.split("/"));
|
||||
const to = path.join(target, ...f.path.split("/"));
|
||||
const content = fs.readFileSync(from);
|
||||
if (acct) {
|
||||
runAs.mkdirAs(acct, path.dirname(to));
|
||||
runAs.writeFileAs(to, content);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.writeFileSync(to, content);
|
||||
}
|
||||
}
|
||||
return { installed: target, replaced: taken };
|
||||
}
|
||||
|
||||
let execImpl = promisify(require("node:child_process").execFile);
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
|
||||
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
|
||||
async function installPlugin(req, row) {
|
||||
const { acct } = ownAccount(req);
|
||||
const steps = [
|
||||
["plugin", "marketplace", "add", validMarket(row.market)],
|
||||
["plugin", "install", "--scope", "user", row.source],
|
||||
];
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
let run;
|
||||
if (acct) {
|
||||
const runAs = require("./run-as");
|
||||
const claude = require("./shared-tools").sharedPath("claude");
|
||||
if (!claude) throw fail("ENOTSHARED", "claude is not in the shared tools yet");
|
||||
const env = { HOME: acct.home, DISABLE_AUTOUPDATER: "1" };
|
||||
run = (args) => {
|
||||
const [cmd, argv] = runAs.sudoArgv(acct, claude, args, env);
|
||||
return execImpl(cmd, argv, { cwd: acct.home, timeout: 300_000, encoding: "utf8" });
|
||||
};
|
||||
} else {
|
||||
const env = { ...process.env, DISABLE_AUTOUPDATER: "1" };
|
||||
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
||||
run = (args) => execImpl("claude", args, { timeout: 300_000, encoding: "utf8", env });
|
||||
}
|
||||
const lastLine = (e) =>
|
||||
String(e?.stderr || e?.stdout || e?.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
try {
|
||||
await run(steps[0]);
|
||||
} catch (e) {
|
||||
// Already added is fine; anything else is the answer.
|
||||
if (!/already/i.test(String(e?.stderr || "") + String(e?.stdout || ""))) {
|
||||
throw fail("EPLUGIN", `marketplace: ${lastLine(e)}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await run(steps[1]);
|
||||
} catch (e) {
|
||||
throw fail("EPLUGIN", `install: ${lastLine(e)}`);
|
||||
}
|
||||
return { installed: row.source };
|
||||
}
|
||||
|
||||
async function install(req, id, opts = {}) {
|
||||
const row = load(req, id);
|
||||
installable(req, row);
|
||||
const result =
|
||||
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
|
||||
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "installed");
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
DOC_EXT,
|
||||
list,
|
||||
load,
|
||||
view,
|
||||
canSeeId,
|
||||
addPage,
|
||||
captureFromHook,
|
||||
fileCandidates,
|
||||
pinFile,
|
||||
availableSkills,
|
||||
addSkill,
|
||||
addPlugin,
|
||||
update,
|
||||
remove,
|
||||
snapshotFile,
|
||||
install,
|
||||
installsEnabled,
|
||||
setInstallsEnabled,
|
||||
__setExecImpl,
|
||||
};
|
||||
+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,
|
||||
|
||||
@@ -288,6 +288,17 @@ const userExitStmt = db.prepare(`
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const anyEndStmt = db.prepare(`
|
||||
SELECT 1 FROM events WHERE session_id = ? AND event_type = 'SessionEnd' LIMIT 1
|
||||
`);
|
||||
// Whether the pane's tmux server was already up when the run started (and is
|
||||
// still): then the pane ended on its own, not with its server.
|
||||
function tmuxOutlived(row, serverStartedAt) {
|
||||
const since = serverStartedAt(row.id);
|
||||
const started = Date.parse(row.started_at || "");
|
||||
return Number.isFinite(since) && Number.isFinite(started) && since <= started;
|
||||
}
|
||||
|
||||
// A shell has no session, so its row only tracks the open tab: once closed it
|
||||
// is dropped — the sidebar keeps no list of closed Terminal tabs.
|
||||
const pruneShellsStmt = db.prepare(`
|
||||
@@ -303,12 +314,16 @@ const pruneShellsStmt = db.prepare(`
|
||||
* A pane AgentHub closed itself (Stop, close tab, Quit) is already `killed`.
|
||||
* Of the rest, an agent session the user did not exit is `interrupted` — a
|
||||
* reboot, a killed tmux, a crash — and offered for resume
|
||||
* (`listInterrupted`); everything else is `abandoned`. Only Claude reports
|
||||
* how it ended, so any other tool's vanished pane counts as interrupted.
|
||||
* (`listInterrupted`); everything else is `abandoned`. A pane whose tmux
|
||||
* server kept running since before it started (`serverStartedAt(id)`, epoch
|
||||
* ms or null) ended by itself — the user quit Codex, `exit`ed, closed it —
|
||||
* so it is not offered either: for Claude only when it said it ended (any
|
||||
* SessionEnd; none at all is a crash), for any other tool, which says
|
||||
* nothing, always. A server that cannot be asked counts as gone.
|
||||
*
|
||||
* Returns the number of rows updated.
|
||||
*/
|
||||
function reconcileOrphans(isAlive = () => false) {
|
||||
function reconcileOrphans(isAlive = () => false, serverStartedAt = () => null) {
|
||||
try {
|
||||
let changes = 0;
|
||||
const { unseenMemberFolder } = require("./linux-accounts");
|
||||
@@ -317,7 +332,11 @@ function reconcileOrphans(isAlive = () => false) {
|
||||
const interrupted =
|
||||
row.tool !== "shell" &&
|
||||
!!row.session_id &&
|
||||
!(row.tool === "claude" && userExitStmt.get(row.session_id));
|
||||
!(row.tool === "claude" && userExitStmt.get(row.session_id)) &&
|
||||
!(
|
||||
tmuxOutlived(row, serverStartedAt) &&
|
||||
(row.tool !== "claude" || anyEndStmt.get(row.session_id))
|
||||
);
|
||||
changes += endStmt.run({
|
||||
id: row.id,
|
||||
status: interrupted ? "interrupted" : "abandoned",
|
||||
|
||||
+89
-26
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* Whose identity is decided by an ACCOUNT, never by the request:
|
||||
* - a member (S3, `run-as.js`): everything lives in their own home, under
|
||||
* `~/.config/agenthub/` — `ssh/id_ed25519` (+ `.pub`) and `git-credentials`
|
||||
* `~/.config/agenthub/` — `ssh/id_ed25519` or `ssh/id_rsa` (+ `.pub`) and `git-credentials`
|
||||
* (git's own `credential-store` format) — written only AS the member
|
||||
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
|
||||
* Their git runs as them, so it reads these itself; no secret ever crosses
|
||||
@@ -61,18 +61,26 @@ function accountForRequest(req) {
|
||||
return { kind: "host", writable: !user || user.role === "admin" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and
|
||||
* RSA 4096 for hosts that refuse ed25519 (AWS IAM / CodeCommit takes only
|
||||
* `ssh-rsa`). One key per account at a time, in the file of its type.
|
||||
*/
|
||||
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
|
||||
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
|
||||
|
||||
function paths(account) {
|
||||
if (account.kind === "member") {
|
||||
const base = path.join(account.acct.home, ".config", "agenthub");
|
||||
return {
|
||||
base,
|
||||
sshDir: path.join(base, "ssh"),
|
||||
key: path.join(base, "ssh", "id_ed25519"),
|
||||
key: path.join(base, "ssh", KEY_FILES.ed25519),
|
||||
store: path.join(base, "git-credentials"),
|
||||
};
|
||||
}
|
||||
const sshDir = path.join(getClaudeHome(), "agenthub-ssh");
|
||||
return { base: sshDir, sshDir, key: path.join(sshDir, "id_ed25519"), store: null };
|
||||
return { base: sshDir, sshDir, key: path.join(sshDir, KEY_FILES.ed25519), store: null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +138,31 @@ const u32 = (n) => {
|
||||
};
|
||||
const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)]);
|
||||
|
||||
/** An SSH mpint: big-endian, with a 0 byte in front when the top bit is set. */
|
||||
const mpint = (b) => sshString(b[0] & 0x80 ? Buffer.concat([Buffer.from([0]), b]) : b);
|
||||
|
||||
/**
|
||||
* A fresh RSA 4096 pair: the private key as PKCS#1 PEM (`BEGIN RSA PRIVATE
|
||||
* KEY`, which OpenSSH reads), the public one as an `ssh-rsa` line. Made off
|
||||
* the event loop — 4096-bit generation takes a second.
|
||||
*/
|
||||
async function makeRsaKeyPair(comment) {
|
||||
const pair = await require("node:util").promisify(crypto.generateKeyPair)("rsa", {
|
||||
modulusLength: 4096,
|
||||
publicExponent: 0x10001,
|
||||
});
|
||||
const jwk = pair.publicKey.export({ format: "jwk" });
|
||||
const blob = Buffer.concat([
|
||||
sshString(Buffer.from("ssh-rsa")),
|
||||
mpint(Buffer.from(jwk.e, "base64url")),
|
||||
mpint(Buffer.from(jwk.n, "base64url")),
|
||||
]);
|
||||
return {
|
||||
privateKey: pair.privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||
publicKey: `ssh-rsa ${blob.toString("base64")} ${comment}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh ed25519 pair in OpenSSH's formats: `{privateKey, publicKey}` text,
|
||||
* unencrypted (`openssh-key-v1`, cipher none) — the dashboard has no terminal
|
||||
@@ -175,36 +208,65 @@ function fingerprint(publicKey) {
|
||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
/** `{publicKey, fingerprint}` of the account's key, or null — never the private half. */
|
||||
function sshKeyInfo(account) {
|
||||
const { key } = paths(account);
|
||||
const publicKey = readText(account, `${key}.pub`);
|
||||
if (!publicKey || !/^ssh-ed25519 [A-Za-z0-9+/=]+/.test(publicKey)) return null;
|
||||
// The private half is only looked at, never read into this process.
|
||||
if (!isOwnFile(account, key)) return null;
|
||||
return { publicKey: publicKey.trim(), fingerprint: fingerprint(publicKey) };
|
||||
/** `{type, file}` of the account's key (ed25519 first), or null. */
|
||||
function currentKey(account) {
|
||||
const { sshDir } = paths(account);
|
||||
for (const type of Object.keys(KEY_FILES)) {
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
const publicKey = readText(account, `${file}.pub`);
|
||||
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
|
||||
// The private half is only looked at, never read into this process.
|
||||
if (!isOwnFile(account, file)) continue;
|
||||
return { type, file, publicKey: publicKey.trim() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Make the account's key; an existing one is replaced only with `replace`. */
|
||||
function generateSshKey(account, { replace = false, comment } = {}) {
|
||||
/** The private key file a clone or fetch uses, or null. */
|
||||
const keyFile = (account) => currentKey(account)?.file ?? null;
|
||||
|
||||
/** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */
|
||||
function sshKeyInfo(account) {
|
||||
const k = currentKey(account);
|
||||
return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the account's key (`type`: ed25519, the default, or rsa); an existing
|
||||
* one is replaced only with `replace` — and a key of the other type goes, so
|
||||
* there is one key per account.
|
||||
*/
|
||||
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
|
||||
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
if (sshKeyInfo(account) && !replace) {
|
||||
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
|
||||
}
|
||||
const label = String(comment || "agenthub")
|
||||
.replace(/[^\w.@+-]/g, "")
|
||||
.slice(0, 64);
|
||||
const { privateKey, publicKey } = makeKeyPair(label || "agenthub");
|
||||
const { key } = paths(account);
|
||||
writeText(account, key, privateKey, { secret: true });
|
||||
writeText(account, `${key}.pub`, publicKey, { secret: false });
|
||||
const label =
|
||||
String(comment || "agenthub")
|
||||
.replace(/[^\w.@+-]/g, "")
|
||||
.slice(0, 64) || "agenthub";
|
||||
const { privateKey, publicKey } =
|
||||
type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label);
|
||||
const { sshDir } = paths(account);
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
writeText(account, file, privateKey, { secret: true });
|
||||
writeText(account, `${file}.pub`, publicKey, { secret: false });
|
||||
for (const [other, name] of Object.entries(KEY_FILES)) {
|
||||
if (other === type) continue;
|
||||
const f = path.join(sshDir, name);
|
||||
if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) {
|
||||
removeFile(account, f);
|
||||
removeFile(account, `${f}.pub`);
|
||||
}
|
||||
}
|
||||
return sshKeyInfo(account);
|
||||
}
|
||||
|
||||
function removeSshKey(account) {
|
||||
const { key } = paths(account);
|
||||
if (!sshKeyInfo(account)) return false;
|
||||
removeFile(account, key);
|
||||
removeFile(account, `${key}.pub`);
|
||||
const k = currentKey(account);
|
||||
if (!k) return false;
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -354,7 +416,7 @@ async function sshConfigFor(dir) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const key = sshKeyInfo(account) ? paths(account).key : null;
|
||||
const key = keyFile(account);
|
||||
return [
|
||||
"-c",
|
||||
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
@@ -464,7 +526,7 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
||||
|
||||
if (remote.scheme === "ssh") {
|
||||
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
||||
const key = sshKeyInfo(account) ? paths(account).key : null;
|
||||
const key = keyFile(account);
|
||||
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
|
||||
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
||||
// records the host key, a CHANGED one still fails.
|
||||
@@ -552,6 +614,7 @@ module.exports = {
|
||||
accountForDir,
|
||||
accountForRequest,
|
||||
makeKeyPair,
|
||||
makeRsaKeyPair,
|
||||
fingerprint,
|
||||
sshKeyInfo,
|
||||
generateSshKey,
|
||||
|
||||
@@ -23,7 +23,7 @@ const { execFileSync } = require("node:child_process");
|
||||
/** Lower case, a letter first, ≤ 29 so `ah-<name>` fits Linux's 32. */
|
||||
const LINUX_NAME_RE = /^[a-z][a-z0-9_-]{0,28}$/;
|
||||
const PROVISION = "/opt/agenthub/sbin/agenthub-provision";
|
||||
const ACTIONS = new Set(["create", "disable", "enable"]);
|
||||
const ACTIONS = new Set(["create", "disable", "enable", "delete"]);
|
||||
|
||||
const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf";
|
||||
|
||||
@@ -121,7 +121,8 @@ function __setRunImpl(fn) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create / disable / enable the Linux account of `username`. A no-op outside
|
||||
* Create / disable / enable / delete (account AND home) the Linux account of
|
||||
* `username`. A no-op outside
|
||||
* S3 (returns null). Throws EPROVISION with the helper's own words on failure.
|
||||
*/
|
||||
function provision(action, username) {
|
||||
@@ -135,7 +136,12 @@ function provision(action, username) {
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}`), {
|
||||
// An installed helper older than this build (root's copy is refreshed
|
||||
// only by install-system) does not know the action yet.
|
||||
const hint = /unknown action/.test(why)
|
||||
? " — the server helper is older than this AgentHub: run `sudo agenthub server install-system` once"
|
||||
: "";
|
||||
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}${hint}`), {
|
||||
code: "EPROVISION",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +154,11 @@ const PATH_RULES = [
|
||||
[/^\/workflows\/session\/([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
|
||||
[/^\/workflows\/runs\/([^/]+)/i, (req, m) => canSeeRowSession(req, "workflows", m[1], "run_id")],
|
||||
[/^\/alerts\/([^/]+)\/ack/i, (req, m) => canSeeRowSession(req, "alert_events", m[1])],
|
||||
// `/artifacts/<id>…`: the owner's, or a shared one (server/lib/artifacts.js).
|
||||
[
|
||||
/^\/artifacts\/(\d+)(?:\/|$)/i,
|
||||
(req, m) => require("./artifacts").canSeeId(req, m[1]),
|
||||
],
|
||||
[
|
||||
/^\/snapshots\/([^/]+)$/i,
|
||||
(req, m) => req.method !== "DELETE" || canSeeRowSession(req, "session_snapshots", m[1]),
|
||||
@@ -296,6 +301,9 @@ function broadcastAudience(type, data) {
|
||||
case "lane_hook_result":
|
||||
case "lane_runtime":
|
||||
return { owners: laneOwners(d.laneId) };
|
||||
// A shared artifact is everyone's news; a private one, its owner's.
|
||||
case "artifact_changed":
|
||||
return d.shared ? null : { owners: new Set(d.ownerId != null ? [d.ownerId] : []) };
|
||||
case "update_status":
|
||||
// No payload: each client refetches its own `GET /api/run/interrupted`.
|
||||
case "runs_interrupted":
|
||||
|
||||
@@ -386,6 +386,22 @@ function serverPid() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the tmux server that holds (or held) session `name` started, in epoch
|
||||
* ms — the host's, or the member's under S3 — or null when there is no such
|
||||
* server or it cannot be asked. A pane gone while its server ran on since
|
||||
* before the pane began exited by itself; one whose server is gone or newer
|
||||
* died with it (a reboot, a killed tmux).
|
||||
*/
|
||||
function serverStartedAt(name) {
|
||||
try {
|
||||
const out = String(onServerOf(name, ["display-message", "-p", "#{start_time}"])).trim();
|
||||
return /^\d+$/.test(out) ? Number(out) * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `-e NAME=value` pairs for tmux, in a stable order. */
|
||||
function envFlags(env) {
|
||||
const flags = [];
|
||||
@@ -703,6 +719,7 @@ module.exports = {
|
||||
capturePane,
|
||||
sendKey,
|
||||
killSession,
|
||||
serverStartedAt,
|
||||
listSessions,
|
||||
liveSessions,
|
||||
isTmuxAvailable,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* @file artifacts.js (routes)
|
||||
* @description `/api/artifacts` — the Artifacts hub (server/lib/artifacts.js):
|
||||
* list, add (a claude.ai page, a pinned workspace file, a skill, a plugin),
|
||||
* rename/share/unshare, delete, read a snapshot file (preview/download, under
|
||||
* a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's
|
||||
* origin), and install a shared skill or plugin into the caller's own
|
||||
* account. Every write is same-origin. A path in a request only ever names a
|
||||
* file inside a workspace (pin, under the read path's guards) or one of a
|
||||
* snapshot's own recorded files.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
const fs = require("node:fs");
|
||||
const artifacts = require("../lib/artifacts");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { broadcast } = require("../websocket");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const STATUS = {
|
||||
ENOTFOUND: 404,
|
||||
ENOLANE: 404,
|
||||
ENOFILE: 404,
|
||||
EFORBIDDEN: 403,
|
||||
ESECRET: 403,
|
||||
EFOREIGN: 403,
|
||||
EINSTALLSOFF: 403,
|
||||
EEXISTS: 409,
|
||||
ENOACCOUNT: 409,
|
||||
EUPGRADE: 409,
|
||||
ENOTSHARED: 409,
|
||||
EPLUGIN: 502,
|
||||
ETOOBIG: 413,
|
||||
};
|
||||
|
||||
function sendError(res, err) {
|
||||
if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) {
|
||||
return res
|
||||
.status(STATUS[err.code] || 400)
|
||||
.json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
console.warn("[artifacts]", err);
|
||||
return res.status(500).json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
}
|
||||
|
||||
/** Tell clients a row changed: everyone when shared, else its owner (+ staff). */
|
||||
function changed(row) {
|
||||
if (!row) return;
|
||||
broadcast("artifact_changed", { id: row.id, ownerId: row.ownerId ?? row.owner_id ?? null, shared: !!(row.shared === true || row.shared === 1) });
|
||||
}
|
||||
|
||||
const wrap = (fn) => async (req, res) => {
|
||||
try {
|
||||
await fn(req, res);
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
};
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
wrap((req, res) => {
|
||||
const q = req.query;
|
||||
res.json({
|
||||
items: artifacts.list(req, {
|
||||
scope: q.scope,
|
||||
kind: q.kind || undefined,
|
||||
q: q.q,
|
||||
laneId: q.laneId,
|
||||
sessionId: q.sessionId,
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings",
|
||||
wrap((req, res) => {
|
||||
const server = require("../lib/auth").isServerMode();
|
||||
res.json({
|
||||
serverMode: server,
|
||||
canShare: server,
|
||||
installsEnabled: artifacts.installsEnabled(),
|
||||
canManage: !req.user || req.user.role === "admin",
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/settings",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
if (req.user && req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: { code: "EADMIN", message: "admin only" } });
|
||||
}
|
||||
const on = req.body?.installsEnabled;
|
||||
if (typeof on !== "boolean") {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||
}
|
||||
artifacts.setInstallsEnabled(on);
|
||||
if (req.user) {
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
null,
|
||||
"PUT",
|
||||
`artifact hub installs ${on ? "on" : "off"}`
|
||||
);
|
||||
}
|
||||
res.json({ installsEnabled: artifacts.installsEnabled() });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/candidates",
|
||||
wrap((req, res) => res.json({ items: artifacts.fileCandidates(req.query.laneId) }))
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/skills",
|
||||
wrap((req, res) => res.json({ items: artifacts.availableSkills(req, req.query.laneId) }))
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/page",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const item = artifacts.addPage(req, req.body || {});
|
||||
changed(item);
|
||||
res.status(201).json({ item });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/file",
|
||||
sameOriginGuard,
|
||||
wrap(async (req, res) => {
|
||||
const item = await artifacts.pinFile(req, req.body || {});
|
||||
changed(item);
|
||||
res.status(201).json({ item });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/skill",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const item = artifacts.addSkill(req, req.body || {});
|
||||
changed(item);
|
||||
res.status(201).json({ item });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/plugin",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const item = artifacts.addPlugin(req, req.body || {});
|
||||
changed(item);
|
||||
res.status(201).json({ item });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:id",
|
||||
wrap((req, res) => res.json({ item: artifacts.view(artifacts.load(req, req.params.id), req) }))
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:id",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const item = artifacts.update(req, req.params.id, req.body || {});
|
||||
changed(item);
|
||||
res.json({ item });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const row = artifacts.remove(req, req.params.id);
|
||||
changed(row);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
// One recorded file of a snapshot. Always sandboxed; `?download=1` makes it
|
||||
// an attachment. The type is only ever one of a fixed list, else a download.
|
||||
const TYPES = {
|
||||
html: "text/html; charset=utf-8",
|
||||
htm: "text/html; charset=utf-8",
|
||||
md: "text/plain; charset=utf-8",
|
||||
markdown: "text/plain; charset=utf-8",
|
||||
txt: "text/plain; charset=utf-8",
|
||||
log: "text/plain; charset=utf-8",
|
||||
csv: "text/plain; charset=utf-8",
|
||||
tsv: "text/plain; charset=utf-8",
|
||||
json: "text/plain; charset=utf-8",
|
||||
yaml: "text/plain; charset=utf-8",
|
||||
yml: "text/plain; charset=utf-8",
|
||||
sh: "text/plain; charset=utf-8",
|
||||
py: "text/plain; charset=utf-8",
|
||||
js: "text/plain; charset=utf-8",
|
||||
ts: "text/plain; charset=utf-8",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
pdf: "application/pdf",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
};
|
||||
|
||||
router.get(
|
||||
"/:id/raw",
|
||||
wrap((req, res) => {
|
||||
const { abs, name } = artifacts.snapshotFile(req, req.params.id, req.query.path);
|
||||
const ext = name.split(".").pop().toLowerCase();
|
||||
const type = TYPES[ext];
|
||||
const download = req.query.download === "1" || !type;
|
||||
res.set({
|
||||
"Content-Type": download ? "application/octet-stream" : type,
|
||||
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, max-age=60",
|
||||
...(download
|
||||
? { "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(name)}` }
|
||||
: {}),
|
||||
});
|
||||
fs.createReadStream(abs)
|
||||
.on("error", () => res.destroy())
|
||||
.pipe(res);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/install",
|
||||
sameOriginGuard,
|
||||
wrap(async (req, res) => {
|
||||
const result = await artifacts.install(req, req.params.id, {
|
||||
replace: req.body?.replace === true,
|
||||
});
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -26,6 +26,7 @@ const STATUS = {
|
||||
EHOSTIDENTITY: 403,
|
||||
EBADAUTHOR: 400,
|
||||
ESTOREUNREADABLE: 409,
|
||||
EBADKEYTYPE: 400,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -66,8 +67,9 @@ router.get("/", async (req, res) => {
|
||||
router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
identity.generateSshKey(account, {
|
||||
await identity.generateSshKey(account, {
|
||||
replace: req.body?.replace === true,
|
||||
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
|
||||
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
|
||||
});
|
||||
res.status(201).json(await view(account));
|
||||
|
||||
@@ -1230,6 +1230,17 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
broadcast("new_event", event);
|
||||
// A claude.ai page the session published goes into its owner's Artifacts.
|
||||
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
|
||||
try {
|
||||
const row = require("../lib/artifacts").captureFromHook(sessionId, data);
|
||||
if (row) {
|
||||
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[artifacts] capture failed:", err.message);
|
||||
}
|
||||
}
|
||||
return event;
|
||||
});
|
||||
|
||||
|
||||
+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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -102,6 +102,7 @@ const STATUS = {
|
||||
EEXISTS: 409,
|
||||
ESELF: 409,
|
||||
EOWNERFOLDER: 409, // S3: a workspace in one member's home, assigned to another
|
||||
ELASTADMIN: 409,
|
||||
ETOOMANY: 429,
|
||||
// S3: the Linux account could not be created/locked (the helper said why).
|
||||
EPROVISION: 502,
|
||||
@@ -248,6 +249,27 @@ router.patch(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete a user for good (auth.deleteUser): their logins, panes, workspaces'
|
||||
* records and — under S3 — their Linux account and whole home. Never
|
||||
* yourself (ESELF) or the last admin (ELASTADMIN); the admin's password
|
||||
* again, like every account change, and written to the audit log.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
accountChange(async (req) => {
|
||||
const u = target(req);
|
||||
if (u.id === req.user.id) {
|
||||
throw Object.assign(new Error("you cannot delete yourself"), { code: "ESELF" });
|
||||
}
|
||||
const gone = auth.deleteUser(u.username);
|
||||
return {
|
||||
user: auth.publicUser(u),
|
||||
action: `delete ${u.username} (${gone.workspaces} workspace(s), home removed)`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/password",
|
||||
accountChange(async (req, body) => {
|
||||
|
||||
Reference in New Issue
Block a user