Files
AgentHub/server/__tests__/server-mode-review-fixes.test.js
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

353 lines
14 KiB
JavaScript

/**
* @file server-mode-review-fixes.test.js
* @description Regressions for the server-mode review's confirmed defects: a
* member cannot read another user's agent through `POST /api/agents`, cannot
* PATCH the pane/session columns of a workspace, cannot open the account pool;
* a transcript `agent_id`/`run_id` cannot walk out of its folder (any mode);
* push subscriptions belong to the user who made them, and a member's quota
* view names no 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 TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-fixes-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Named locks live under LANES_ROOT/.locks — never the real one.
process.env.LANES_ROOT = path.join(TMP, "lanes");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
let laneMia;
let laneMax;
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 }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "developer");
const max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
laneMia = lane.run("/w/mia", mia.id).lastInsertRowid;
laneMax = lane.run("/w/max", max.id).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s-max', 'max', 'active', '/w/max', ?)"
).run(laneMax);
db.prepare(
"INSERT INTO agents (id, session_id, name, type) VALUES ('a-max', 's-max', 'Main', 'main')"
).run();
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("server mode review fixes", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("does not hand back another user's agent from POST /api/agents", async () => {
const mia = await login("mia", "member-pass-1");
const r = await call("POST", "/api/agents", {
cookie: mia,
body: { id: "a-max", session_id: `s-mia-${laneMia}`, name: "x" },
});
assert.equal(r.status, 404);
assert.ok(!JSON.stringify(r.body).includes("s-max"));
});
it("lets a member PATCH only a workspace's typed fields", async () => {
const mia = await login("mia", "member-pass-1");
const bad = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: mia,
body: { run_id: `agenthub-lane-${laneMax}` },
});
assert.equal(bad.status, 403);
assert.equal(db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(laneMia).run_id, null);
const ok = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: mia,
body: { title: "Mine" },
});
assert.equal(ok.status, 200);
const admin = await login("boss", "admin-pass-1");
const staff = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: admin,
body: { notes: "x", status: "idle" },
});
assert.equal(staff.status, 200);
});
it("keeps the account pool and account names from members", async () => {
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403);
const q = await call("GET", "/api/pricing/quota", { cookie: mia });
assert.equal(q.status, 200);
for (const t of q.body.tools || []) assert.equal(t.account, null);
});
it("scopes push subscriptions to their user", async () => {
const mia = await login("mia", "member-pass-1");
const max = await login("max", "member-pass-2");
const sub = (endpoint, cookie) =>
call("POST", "/api/push/subscribe", {
cookie,
body: { endpoint, keys: { p256dh: "p", auth: "a" } },
});
await sub("https://push.example/mia", mia);
await sub("https://push.example/max", max);
const owners = db
.prepare("SELECT endpoint, user_id FROM push_subscriptions ORDER BY endpoint")
.all()
.map((r) => [r.endpoint.split("/").pop(), r.user_id]);
assert.deepEqual(
owners.map(([e]) => e),
["max", "mia"]
);
assert.notEqual(owners[0][1], owners[1][1]);
// max cannot unsubscribe mia's browser.
await call("DELETE", "/api/push/subscribe", {
cookie: max,
body: { endpoint: "https://push.example/mia" },
});
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM push_subscriptions").get().n, 2);
});
});
describe("transcript ids (every mode)", () => {
it("refuses an agent_id or run_id that is a path", async () => {
delete process.env.AGENTHUB_MODE;
for (const q of ["agent_id=../../x", "run_id=..%2F..%2Fx", "agent_id=a%2Fb"]) {
const r = await call("GET", `/api/sessions/s-max/transcript?${q}`);
assert.equal(r.status, 400, q);
assert.equal(r.body.error.code, "EBADID");
}
const ok = await call("GET", "/api/sessions/s-max/transcript?agent_id=a-max");
assert.equal(ok.status, 200);
});
});
describe("server mode review fixes, second round", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses a write whose Origin is not the dashboard's own", async () => {
const mia = await login("mia", "member-pass-1");
const res = await fetch(`${BASE}/api/lanes/${laneMia}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
cookie: mia,
origin: "https://wiki.example.com",
},
body: JSON.stringify({ title: "csrf" }),
});
assert.equal(res.status, 403);
assert.equal((await res.json()).error.code, "EBADORIGIN");
const own = await fetch(`${BASE}/api/lanes/${laneMia}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie: mia, origin: BASE },
body: JSON.stringify({ title: "fine" }),
});
assert.equal(own.status, 200);
});
it("counts parallel failed logins against the limit", async () => {
const tries = await Promise.all(
Array.from({ length: 25 }, () =>
fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-forwarded-for": "192.0.2.55" },
body: JSON.stringify({ username: "max", password: "wrong" }),
}).then((r) => r.status)
)
);
assert.ok(tries.filter((s) => s === 401).length <= 10, JSON.stringify(tries));
assert.ok(tries.includes(429));
});
it("refuses a member a workspace that overlaps another user's, for any tool", async () => {
const mia = await login("mia", "member-pass-1");
for (const body of [{ cwd: "/w/max", tool: "codex" }, { cwd: "/w/max/sub" }, { cwd: "/w" }]) {
const r = await call("POST", "/api/lanes", { cookie: mia, body });
assert.equal(r.status, 409, JSON.stringify(body));
assert.equal(r.body.error.code, "EOWNED");
}
});
it("keeps members from planting sessions and from the shared pet store", async () => {
const mia = await login("mia", "member-pass-1");
const s = await call("POST", "/api/sessions", {
cookie: mia,
body: { id: "fake-1", name: "x" },
});
assert.equal(s.status, 403);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE id = 'fake-1'").get().n, 0);
const p = await call("DELETE", "/api/pets/some-pet", { cookie: mia });
assert.equal(p.status, 403);
});
it("lets a live pane's CLI act as its workspace owner, and nothing else", async () => {
const { execFileSync } = require("node:child_process");
const crypto = require("node:crypto");
const run = `agenthub-lane-${laneMia}`;
const token = "ahpane_test-token-value-0123456789";
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
VALUES (?, 'interactive', '/w/mia', 'running', ?, ?)`
).run(run, laneMia, crypto.createHash("sha256").update(token).digest("hex"));
const asPane = (p, extra = {}) =>
fetch(BASE + p, { headers: { "x-agenthub-pane-token": token, ...extra } }).then(
(r) => r.status
);
// No live tmux session yet: the credential is dead.
assert.equal(await asPane("/api/sessions"), 401);
execFileSync("tmux", ["new-session", "-d", "-s", run, "sleep", "120"]);
try {
assert.equal(await asPane("/api/sessions"), 200);
assert.equal(await asPane(`/api/lanes/${laneMax}`), 404); // still a member
assert.equal(await asPane("/api/sessions", { "x-forwarded-for": "203.0.113.1" }), 401);
assert.equal(
await fetch(BASE + "/api/sessions", {
headers: { "x-agenthub-pane-token": "ahpane_wrong" },
}).then((r) => r.status),
401
);
} finally {
execFileSync("tmux", ["kill-session", "-t", `=${run}`]);
}
});
it("sends a member the first frames of a session their own pane starts", async () => {
const WebSocket = require("ws");
const cookie = await login("mia", "member-pass-1");
const run = `agenthub-lane-${laneMia}-3`;
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id)
VALUES (?, 'interactive', '/w/mia', 'running', ?)`
).run(run, laneMia);
const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", { headers: { cookie } });
const seen = [];
ws.on("message", (m) => seen.push(JSON.parse(String(m))));
await new Promise((resolve, reject) => ws.on("open", resolve).on("error", reject));
try {
// The session is new: it has no workspace until bindRunSession stamps
// it, so frames sent before that stamp reached no member.
const res = await call("POST", "/api/hooks/event", {
body: {
hook_type: "SessionStart",
run_id: run,
data: { session_id: "s-new-mia", cwd: "/w/mia", source: "startup" },
},
});
assert.equal(res.status, 200);
await new Promise((r) => setTimeout(r, 300));
const mine = seen.filter((m) => JSON.stringify(m.data || {}).includes("s-new-mia"));
assert.ok(
mine.some((m) => m.type === "new_event"),
`got ${seen.map((m) => m.type).join(", ")}`
);
} finally {
ws.close();
}
});
it("sends members the config-change signal without its payload", () => {
const scope = require("../lib/scope");
const a = scope.broadcastAudience("cc_config_changed", { type: "profile", name: "boss@x" });
assert.equal(a.strip, true);
assert.equal(scope.audienceIncludes(a, { id: 1, role: "developer" }), true);
});
});
describe("named locks", () => {
it("never takes a path for a lock name (any mode)", () => {
const namedLock = require("../lib/named-lock");
for (const bad of ["../x", "..", ".", "a/b", "a\\b", "", "x".repeat(256)]) {
assert.throws(() => namedLock.tryAcquire(bad, "me"), { code: "EBADNAME" }, bad);
assert.throws(() => namedLock.release(bad, "me"), { code: "EBADNAME" }, bad);
}
assert.throws(() => namedLock.tryAcquire("ok", " "), { code: "EBADHOLDER" });
// A holder with spaces keeps working — and now really holds the lock.
assert.deepEqual(namedLock.tryAcquire("ci", "ci bot"), { acquired: true });
assert.equal(namedLock.tryAcquire("ci", "other").acquired, false);
namedLock.release("ci", "ci bot");
// Names that were always valid directory names keep working.
for (const good of ["db:migrate", "build web", "-x"]) {
assert.deepEqual(namedLock.tryAcquire(good, "me"), { acquired: true }, good);
namedLock.release(good, "me");
}
});
it("gives each member their own lock namespace", async () => {
process.env.AGENTHUB_MODE = "server";
const mia = await login("mia", "member-pass-1");
const max = await login("max", "member-pass-2");
const take = (cookie, holder) =>
call("POST", "/api/locks/deploy/acquire", { cookie, body: { holder } });
assert.equal((await take(mia, "mia-agent")).status, 200);
assert.equal((await take(max, "max-agent")).status, 200); // not mia's lock
const seen = (await call("GET", "/api/locks", { cookie: max })).body.locks;
assert.deepEqual(
seen.map((l) => [l.name, l.holder]),
[["deploy", "max-agent"]]
);
const bad = await call("POST", "/api/locks/..%2F..%2Fx/acquire", {
cookie: mia,
body: { holder: "h" },
});
assert.ok([400, 404].includes(bad.status));
await call("POST", "/api/locks/deploy/release", { cookie: mia, body: { holder: "mia-agent" } });
await call("POST", "/api/locks/deploy/release", { cookie: max, body: { holder: "max-agent" } });
});
});
describe("data export", () => {
it("never writes a pane credential's hash into a bundle", async () => {
delete process.env.AGENTHUB_MODE;
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, pane_token_hash)
VALUES ('agenthub-lane-999@x', 'interactive', '/w', 'killed', 'deadbeefhash')`
).run();
const text = await (await fetch(`${BASE}/api/settings/export`)).text();
assert.ok(text.includes("agenthub-lane-999@x"));
assert.ok(!text.includes("deadbeefhash"));
const { buildExportBundle } = require("../lib/data-transfer");
const bundle = buildExportBundle(db, require("../db").stmts);
assert.ok(bundle.dashboard_runs.some((r) => r.id === "agenthub-lane-999@x"));
assert.ok(!JSON.stringify(bundle).includes("deadbeefhash"));
});
});