b7f3735b29
- 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.
353 lines
14 KiB
JavaScript
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"));
|
|
});
|
|
});
|