0c2f49876d
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
397 lines
17 KiB
JavaScript
397 lines
17 KiB
JavaScript
/**
|
|
* @file server-s3-review2.test.js
|
|
* @description S3 plan, second review: a member cannot point the dashboard's
|
|
* own rights at another account's files. A workspace folder swapped for a
|
|
* link is refused for file reads and saves (EFOREIGN); a member's git dir
|
|
* that names a path outside their home is never read or written by the host,
|
|
* and a FIFO there cannot hang it; a developer browses and lists branches
|
|
* only in their own folder; a transcript scan by id looks only in the
|
|
* session owner's home; a Codex journal flips only its own account's lanes,
|
|
* capped per home; nobody reads the dot folders at the top of a member's home
|
|
* (logins, pane tokens), and the home itself is no workspace; a disabled
|
|
* member's session neither throws in a read nor stops the liveness reaper.
|
|
* A fake passwd (every member = this test's uid) and server.conf make the
|
|
* members provisioned; fake sudo runs their commands as this account.
|
|
* @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 { execFileSync } = require("node:child_process");
|
|
|
|
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review2-")));
|
|
const USERS = path.join(TMP, "users");
|
|
const MIA = path.join(USERS, "mia");
|
|
const BOB = path.join(USERS, "bob");
|
|
const DAN = path.join(USERS, "dan");
|
|
const HOSTDIR = path.join(TMP, "hostdir");
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
|
process.env.CLAUDE_HOME = path.join(TMP, "host-claude");
|
|
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
const auth = require("../lib/auth");
|
|
const linux = require("../lib/linux-accounts");
|
|
const runAs = require("../lib/run-as");
|
|
const tmux = require("../lib/tmux");
|
|
const claudeHome = require("../lib/claude-home");
|
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
|
|
|
let server;
|
|
let BASE;
|
|
const cookies = {};
|
|
const lanes = {};
|
|
|
|
const call = (who, method, p, body) =>
|
|
fetch(BASE + p, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", cookie: cookies[who] },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
|
|
|
const gitInit = (dir) => {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
execFileSync("git", ["init", "-q", dir]);
|
|
execFileSync("git", [
|
|
"-C",
|
|
dir,
|
|
"-c",
|
|
"user.email=a@b",
|
|
"-c",
|
|
"user.name=a",
|
|
"commit",
|
|
"-q",
|
|
"--allow-empty",
|
|
"-m",
|
|
"x",
|
|
]);
|
|
};
|
|
|
|
const addLane = (cwd, owner, tool = "claude") =>
|
|
Number(
|
|
db
|
|
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, ?, ?, 'idle')")
|
|
.run(cwd, tool, db.prepare("SELECT id FROM users WHERE username = ?").get(owner).id)
|
|
.lastInsertRowid
|
|
);
|
|
|
|
before(async () => {
|
|
gitInit(path.join(MIA, "proj"));
|
|
gitInit(path.join(BOB, "proj"));
|
|
fs.writeFileSync(path.join(BOB, "proj", "bob.txt"), "bob's file\n");
|
|
fs.mkdirSync(HOSTDIR, { recursive: true });
|
|
fs.writeFileSync(path.join(HOSTDIR, "host.txt"), "host's file\n");
|
|
// mia swaps folders in her own home for links: to bob's, and to the host's.
|
|
fs.symlinkSync(path.join(BOB, "proj"), path.join(MIA, "tobob"));
|
|
fs.symlinkSync(HOSTDIR, path.join(MIA, "tohost"));
|
|
// bob's logins and a pane's handoff file, at the top of his home.
|
|
fs.mkdirSync(path.join(BOB, ".claude"), { recursive: true });
|
|
fs.writeFileSync(path.join(BOB, ".claude", ".credentials.json"), '{"token":"secret"}\n');
|
|
fs.mkdirSync(path.join(BOB, ".agenthub", "handoff"), { recursive: true });
|
|
fs.writeFileSync(path.join(BOB, ".agenthub", "handoff", "proxy-x--agenthub-lane-1.json"), "{}");
|
|
fs.writeFileSync(path.join(BOB, "notes.txt"), "plain\n");
|
|
fs.mkdirSync(path.join(DAN, "proj"), { recursive: true });
|
|
|
|
const uid = process.getuid();
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_PASSWD,
|
|
["mia", "bob", "ana", "dan"].map((n) => `ah-${n}:x:${uid}:${uid}::/:/bin/sh`).join("\n") + "\n"
|
|
);
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_SERVER_CONF,
|
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
|
);
|
|
linux.__resetConfCache();
|
|
runAs.__resetCache();
|
|
linux.__setRunImpl(() => "ok");
|
|
runAs.__setExecImpl(fakeSudo().exec);
|
|
tmux.__setExecImpl(() => "");
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
process.env.AGENTHUB_MODE = "server";
|
|
process.env.AGENTHUB_USERS_ROOT = USERS;
|
|
for (const [n, role] of [
|
|
["boss", "admin"],
|
|
["mia", "developer"],
|
|
["bob", "developer"],
|
|
["ana", "analyst"],
|
|
["dan", "developer"],
|
|
]) {
|
|
await auth.createUser(n, `${n}-pass-123`, role);
|
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
|
|
});
|
|
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
|
}
|
|
lanes.tobob = addLane(path.join(MIA, "tobob"), "mia");
|
|
lanes.tohost = addLane(path.join(MIA, "tohost"), "mia");
|
|
lanes.bobHome = addLane(BOB, "bob");
|
|
});
|
|
|
|
after(() => {
|
|
tmux.__reset();
|
|
delete process.env.AGENTHUB_MODE;
|
|
delete process.env.AGENTHUB_USERS_ROOT;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("a workspace folder swapped for a link", () => {
|
|
it("is refused for a file read, into another member's folder or the host's", async () => {
|
|
const a = await call("mia", "GET", `/api/lanes/${lanes.tobob}/git/file?path=bob.txt`);
|
|
assert.equal(a.status, 403, JSON.stringify(a.body));
|
|
assert.equal(a.body.error.code, "EFOREIGN");
|
|
const b = await call("mia", "GET", `/api/lanes/${lanes.tohost}/git/file?path=host.txt`);
|
|
assert.equal(b.status, 403, JSON.stringify(b.body));
|
|
});
|
|
|
|
it("is refused for a save", async () => {
|
|
const { writeLaneFile } = require("../lib/lane-file-write");
|
|
const sha = require("../lib/git-diff").contentSha(
|
|
fs.readFileSync(path.join(HOSTDIR, "host.txt"))
|
|
);
|
|
await assert.rejects(writeLaneFile(path.join(MIA, "tohost"), "host.txt", "pwned\n", sha), {
|
|
code: "EFOREIGN",
|
|
});
|
|
assert.equal(fs.readFileSync(path.join(HOSTDIR, "host.txt"), "utf8"), "host's file\n");
|
|
});
|
|
});
|
|
|
|
describe("a member's git dir that names a path outside their home", () => {
|
|
it("is neither read nor written by the host", () => {
|
|
const { excludeFromGit } = require("../lib/git-exclude");
|
|
// mia's repo: `.git` is a file pointing at a git dir she made in /tmp,
|
|
// whose info/exclude is a link to a host-owned file.
|
|
const evil = path.join(TMP, "evil");
|
|
gitInit(evil);
|
|
const target = path.join(HOSTDIR, "settings.json");
|
|
fs.writeFileSync(target, '{"a":1}\n');
|
|
fs.rmSync(path.join(evil, ".git", "info", "exclude"), { force: true });
|
|
fs.symlinkSync(target, path.join(evil, ".git", "info", "exclude"));
|
|
const repo = path.join(MIA, "redirect");
|
|
fs.mkdirSync(repo, { recursive: true });
|
|
fs.writeFileSync(path.join(repo, ".git"), `gitdir: ${path.join(evil, ".git")}\n`);
|
|
excludeFromGit(repo, [".claude/worktrees/"]);
|
|
assert.equal(fs.readFileSync(target, "utf8"), '{"a":1}\n');
|
|
});
|
|
|
|
it("cannot hang it on a FIFO", () => {
|
|
const { excludeFromGit } = require("../lib/git-exclude");
|
|
const repo = path.join(MIA, "fifo");
|
|
gitInit(repo);
|
|
const ex = path.join(repo, ".git", "info", "exclude");
|
|
fs.rmSync(ex, { force: true });
|
|
execFileSync("mkfifo", [ex]);
|
|
const t0 = Date.now();
|
|
excludeFromGit(repo, [".mcp.json"]);
|
|
assert.ok(Date.now() - t0 < 5000);
|
|
});
|
|
|
|
it("still gets its exclude line in its own repo", () => {
|
|
const { excludeFromGit } = require("../lib/git-exclude");
|
|
const repo = path.join(MIA, "proj");
|
|
excludeFromGit(repo, [".mcp.json"]);
|
|
const text = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
|
|
assert.match(text, /^\/\.mcp\.json$/m);
|
|
});
|
|
});
|
|
|
|
describe("a developer browses and lists branches only in their own folder", () => {
|
|
it("browse", async () => {
|
|
const other = await call("mia", "GET", `/api/lanes/browse?path=${encodeURIComponent(BOB)}`);
|
|
assert.equal(other.status, 403);
|
|
assert.equal(other.body.error.code, "EFOLDER");
|
|
const viaLink = await call(
|
|
"mia",
|
|
"GET",
|
|
`/api/lanes/browse?path=${encodeURIComponent(path.join(MIA, "tohost"))}`
|
|
);
|
|
assert.equal(viaLink.status, 403);
|
|
const own = await call("mia", "GET", "/api/lanes/browse");
|
|
assert.equal(own.status, 200, JSON.stringify(own.body));
|
|
assert.equal(own.body.path, MIA);
|
|
assert.equal(own.body.parent, null);
|
|
assert.ok(own.body.entries.some((e) => e.name === "proj"));
|
|
const admin = await call("boss", "GET", `/api/lanes/browse?path=${encodeURIComponent(BOB)}`);
|
|
assert.equal(admin.status, 200);
|
|
});
|
|
|
|
it("an admin starts in their own home, and opens the host's only with host=1", async () => {
|
|
// No Linux account: the host's home, and no Server folder tick.
|
|
const plain = await call("boss", "GET", "/api/lanes/browse");
|
|
assert.equal(plain.status, 200);
|
|
assert.equal(plain.body.path, os.homedir());
|
|
assert.equal(plain.body.hostHome, undefined);
|
|
// With one (ah-boss, home <USERS>/boss): their own home first.
|
|
const BOSS = path.join(USERS, "boss");
|
|
fs.mkdirSync(path.join(BOSS, "work"), { recursive: true });
|
|
const passwd = fs.readFileSync(process.env.AGENTHUB_PASSWD, "utf8");
|
|
const uid = process.getuid();
|
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, passwd + `ah-boss:x:${uid}:${uid}::/:/bin/sh\n`);
|
|
runAs.__resetCache();
|
|
try {
|
|
const own = await call("boss", "GET", "/api/lanes/browse");
|
|
assert.equal(own.status, 200, JSON.stringify(own.body));
|
|
assert.equal(own.body.path, BOSS);
|
|
assert.equal(own.body.home, BOSS);
|
|
assert.equal(own.body.hostHome, os.homedir());
|
|
assert.ok(own.body.parent, "an admin is not held to their home");
|
|
const host = await call("boss", "GET", "/api/lanes/browse?host=1");
|
|
assert.equal(host.body.path, os.homedir());
|
|
assert.equal(host.body.home, os.homedir());
|
|
// A developer's host=1 changes nothing: still their own folder.
|
|
const mia = await call("mia", "GET", "/api/lanes/browse?host=1");
|
|
assert.equal(mia.body.path, MIA);
|
|
assert.equal(mia.body.hostHome, undefined);
|
|
} finally {
|
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, passwd);
|
|
runAs.__resetCache();
|
|
}
|
|
});
|
|
|
|
it("hidden=1 shows dot entries, never the logins at the top of their home", async () => {
|
|
fs.mkdirSync(path.join(MIA, ".claude"), { recursive: true });
|
|
fs.mkdirSync(path.join(MIA, "proj", ".cfg"), { recursive: true });
|
|
const top = await call("mia", "GET", "/api/lanes/browse?hidden=1");
|
|
assert.equal(top.status, 200, JSON.stringify(top.body));
|
|
assert.equal(top.body.home, MIA);
|
|
assert.ok(!top.body.entries.some((e) => e.name.startsWith(".")));
|
|
const inner = await call(
|
|
"mia",
|
|
"GET",
|
|
`/api/lanes/browse?hidden=1&path=${encodeURIComponent(path.join(MIA, "proj"))}`
|
|
);
|
|
assert.ok(inner.body.entries.some((e) => e.name === ".cfg"));
|
|
});
|
|
|
|
it("mkdir: only in their own folder, made as them", async () => {
|
|
const mine = await call("mia", "POST", "/api/lanes/browse/mkdir", {
|
|
parent: MIA,
|
|
name: "fresh",
|
|
});
|
|
assert.equal(mine.status, 201, JSON.stringify(mine.body));
|
|
assert.ok(fs.statSync(path.join(MIA, "fresh")).isDirectory());
|
|
for (const parent of [BOB, path.join(MIA, "tohost")]) {
|
|
const r = await call("mia", "POST", "/api/lanes/browse/mkdir", { parent, name: "x" });
|
|
assert.equal(r.status, 403, `${parent}: ${JSON.stringify(r.body)}`);
|
|
assert.equal(r.body.error.code, "EFOLDER");
|
|
}
|
|
assert.ok(!fs.existsSync(path.join(BOB, "x")));
|
|
assert.ok(!fs.existsSync(path.join(HOSTDIR, "x")));
|
|
});
|
|
|
|
it("branches", async () => {
|
|
const other = await call(
|
|
"mia",
|
|
"GET",
|
|
`/api/lanes/branches?repo=${encodeURIComponent(path.join(BOB, "proj"))}`
|
|
);
|
|
assert.equal(other.status, 403);
|
|
const own = await call(
|
|
"mia",
|
|
"GET",
|
|
`/api/lanes/branches?repo=${encodeURIComponent(path.join(MIA, "proj"))}`
|
|
);
|
|
// Not refused (the real sudo it would run is not this test's).
|
|
assert.notEqual(own.status, 403, JSON.stringify(own.body));
|
|
});
|
|
});
|
|
|
|
describe("a transcript scan by id looks only in the session owner's home", () => {
|
|
it("never serves a file another member planted under that session id", () => {
|
|
const sid = "aaaaaaaa-1111-4222-8333-444444444444";
|
|
const cwd = path.join(MIA, "proj");
|
|
const planted = path.join(BOB, ".claude", "projects", "x");
|
|
fs.mkdirSync(path.join(planted, sid, "subagents"), { recursive: true });
|
|
fs.writeFileSync(path.join(planted, `${sid}.jsonl`), "{}\n");
|
|
fs.writeFileSync(path.join(planted, sid, "subagents", "agent-1.jsonl"), "{}\n");
|
|
assert.equal(claudeHome.getTranscriptPath(sid, cwd), null);
|
|
assert.equal(claudeHome.findTranscriptPath(sid, cwd), null);
|
|
assert.equal(claudeHome.findTranscriptPath(sid, null), null, "no folder: the host's dir only");
|
|
assert.equal(claudeHome.getSubagentTranscriptPath(sid, cwd, "1"), null);
|
|
assert.deepEqual(claudeHome.sessionProjectsDirs(cwd), [path.join(MIA, ".claude", "projects")]);
|
|
});
|
|
});
|
|
|
|
describe("a Codex journal speaks only for its own account", () => {
|
|
const journal = (home, name, cwd, mtime) => {
|
|
const day = path.join(home, ".codex", "sessions", "2026", "10", "09");
|
|
fs.mkdirSync(day, { recursive: true });
|
|
const f = path.join(day, `rollout-2026-10-09T00-00-00-${name}.jsonl`);
|
|
fs.writeFileSync(f, JSON.stringify({ type: "session_meta", payload: { cwd } }) + "\n");
|
|
fs.utimesSync(f, mtime / 1000, mtime / 1000);
|
|
return f;
|
|
};
|
|
|
|
it("never flips another member's lane, and one home cannot crowd the others out", () => {
|
|
const ingest = require("../lib/codex-ingest");
|
|
const bobLane = addLane(path.join(BOB, "proj"), "bob", "codex");
|
|
const now = Date.now();
|
|
// mia writes bob's folder into her own fresh journal…
|
|
journal(MIA, "forged", path.join(BOB, "proj"), now);
|
|
ingest.__resetCache();
|
|
ingest.ingestCodexOnce();
|
|
assert.equal(db.prepare("SELECT status FROM lanes WHERE id = ?").get(bobLane).status, "idle");
|
|
// …and keeps 40 fresher journals of her own; bob's real one must still count.
|
|
for (let i = 0; i < 41; i++) journal(MIA, `own${i}`, path.join(MIA, "proj"), now);
|
|
journal(BOB, "real", path.join(BOB, "proj"), now - 5000);
|
|
ingest.__resetCache();
|
|
ingest.ingestCodexOnce();
|
|
assert.equal(
|
|
db.prepare("SELECT status FROM lanes WHERE id = ?").get(bobLane).status,
|
|
"running"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the top of a member's home", () => {
|
|
it("is not readable through a workspace rooted at it, by anyone", async () => {
|
|
for (const p of [
|
|
".claude/.credentials.json",
|
|
".agenthub/handoff/proxy-x--agenthub-lane-1.json",
|
|
]) {
|
|
const r = await call("ana", "GET", `/api/lanes/${lanes.bobHome}/git/file?path=${p}`);
|
|
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
|
|
assert.equal(r.body.error.code, "ESECRET");
|
|
}
|
|
const ok = await call("ana", "GET", `/api/lanes/${lanes.bobHome}/git/file?path=notes.txt`);
|
|
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
});
|
|
|
|
it("is no workspace (S3)", async () => {
|
|
const r = await call("mia", "POST", "/api/lanes", { cwd: MIA });
|
|
assert.equal(r.status, 403, JSON.stringify(r.body));
|
|
assert.equal(r.body.error.code, "EFOLDER");
|
|
});
|
|
});
|
|
|
|
describe("a disabled member's session", () => {
|
|
it("is still read from their home, and does not stop the liveness reaper", () => {
|
|
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
|
|
const danCwd = path.join(DAN, "proj");
|
|
assert.equal(claudeHome.projectsDirFor(danCwd), path.join(DAN, ".claude", "projects"));
|
|
assert.doesNotThrow(() => claudeHome.getTranscriptPath("dan-sess", danCwd));
|
|
const ins = db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, tool, updated_at) VALUES (?, ?, 'active', ?, 'claude', '2020-01-01T00:00:00.000Z')"
|
|
);
|
|
ins.run("dan-sess", "dan", danCwd);
|
|
ins.run("host-sess", "host", HOSTDIR);
|
|
const { livenessReap } = require("../routes/hooks");
|
|
assert.doesNotThrow(() =>
|
|
livenessReap({ ignoreIdleGate: true, probe: { available: true, cwds: new Set() } })
|
|
);
|
|
const st = db.prepare("SELECT status FROM sessions WHERE id = 'host-sess'").get().status;
|
|
assert.notEqual(st, "active");
|
|
});
|
|
});
|