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.
284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
/**
|
|
* @file scope.test.js
|
|
* @description Server mode's row ownership helpers (server/lib/scope.js): a
|
|
* member sees only sessions/agents/events/runs of the workspaces they own
|
|
* (stamped `sessions.lane_id`, or a `dashboard_runs` row of theirs holding the
|
|
* session), an admin/analyst/local request sees everything, and a member's
|
|
* workspace folder stays inside `AGENTHUB_USERS_ROOT/<username>`.
|
|
* @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(), "scope-test-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
|
|
const { db } = require("../db");
|
|
const scope = require("../lib/scope");
|
|
|
|
const member = (id, username = `u${id}`) => ({ user: { id, username, role: "developer" } });
|
|
const admin = { user: { id: 99, username: "boss", role: "admin" } };
|
|
const analyst = { user: { id: 98, username: "ana", role: "analyst" }, method: "GET" };
|
|
const local = {};
|
|
|
|
before(() => {
|
|
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
|
|
lane.run("/w/one", 1); // id 1, user 1
|
|
lane.run("/w/two", 2); // id 2, user 2
|
|
lane.run("/w/host", null); // id 3, nobody's
|
|
lane.run("/w/ana", 98); // id 4, the analyst's
|
|
const s = db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
|
|
);
|
|
s.run("s1", "one", 1);
|
|
s.run("s2", "two", 2);
|
|
s.run("s3", "host", 3);
|
|
s.run("s-unstamped", "pending", null);
|
|
s.run("s-free", "outside", null);
|
|
db.prepare(
|
|
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES ('agenthub-lane-1-2', 's-unstamped', 'interactive', '/w', 'running', 1)"
|
|
).run();
|
|
db.prepare(
|
|
"INSERT INTO agents (id, session_id, name, type) VALUES ('a2', 's2', 'Main', 'main')"
|
|
).run();
|
|
});
|
|
|
|
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
|
|
function visibleSessions(req) {
|
|
const w = scope.sessionWhere(req);
|
|
return db
|
|
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
|
|
.all(...w.params)
|
|
.map((r) => r.id);
|
|
}
|
|
|
|
describe("scope", () => {
|
|
it("shows a member only their own workspaces' sessions, unstamped ones included", () => {
|
|
assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1"]);
|
|
assert.deepEqual(visibleSessions(member(2)), ["s2"]);
|
|
assert.deepEqual(visibleSessions(member(3)), []);
|
|
});
|
|
|
|
it("limits an analyst's writes to their own workspaces", () => {
|
|
const write = { ...analyst, method: "POST" };
|
|
assert.equal(scope.memberId(analyst), null);
|
|
assert.equal(scope.memberId(write), 98);
|
|
assert.equal(scope.canSeeLane(write, 2), false);
|
|
assert.equal(scope.canSeeLane(write, 4), true);
|
|
assert.equal(scope.canSeeRun(write, "agenthub-lane-1-2"), false);
|
|
assert.deepEqual(visibleSessions(write), []);
|
|
});
|
|
|
|
it("shows admin, analyst and local mode everything", () => {
|
|
for (const req of [admin, analyst, local]) {
|
|
assert.equal(visibleSessions(req).length, 5);
|
|
assert.equal(scope.canSeeLane(req, 2), true);
|
|
assert.equal(scope.visibleLaneIds(req), null);
|
|
}
|
|
});
|
|
|
|
it("answers per-id checks for lanes, sessions, agents and runs", () => {
|
|
const m = member(1);
|
|
assert.equal(scope.canSeeLane(m, 1), true);
|
|
assert.equal(scope.canSeeLane(m, 2), false);
|
|
assert.equal(scope.canSeeLane(m, 3), false);
|
|
assert.equal(scope.canSeeLane(m, 404), false);
|
|
assert.equal(scope.canSeeSession(m, "s1"), true);
|
|
assert.equal(scope.canSeeSession(m, "s-unstamped"), true);
|
|
assert.equal(scope.canSeeSession(m, "s2"), false);
|
|
assert.equal(scope.canSeeSession(m, "nope"), false);
|
|
assert.equal(scope.canSeeAgent(m, "a2"), false);
|
|
assert.equal(scope.canSeeAgent(member(2), "a2"), true);
|
|
assert.equal(scope.canSeeRun(m, "agenthub-lane-1"), true);
|
|
assert.equal(scope.canSeeRun(m, "agenthub-lane-1-2"), true);
|
|
assert.equal(scope.canSeeRun(m, "agenthub-lane-2"), false);
|
|
assert.equal(scope.canSeeRun(m, "agenthub-lane-12"), false);
|
|
assert.deepEqual(scope.visibleLaneIds(m), [1]);
|
|
});
|
|
|
|
it("scopes tables keyed by session id", () => {
|
|
const w = scope.bySessionWhere(member(2));
|
|
const rows = db.prepare(`SELECT id FROM agents WHERE ${w.where}`).all(...w.params);
|
|
assert.deepEqual(
|
|
rows.map((r) => r.id),
|
|
["a2"]
|
|
);
|
|
const none = scope.bySessionWhere(member(1));
|
|
assert.equal(
|
|
db.prepare(`SELECT id FROM agents WHERE ${none.where}`).all(...none.params).length,
|
|
0
|
|
);
|
|
});
|
|
|
|
it("keeps a member's workspace folder inside their own root", () => {
|
|
process.env.AGENTHUB_USERS_ROOT = "/srv/agenthub/users";
|
|
try {
|
|
const m = member(1, "alice");
|
|
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice"), true);
|
|
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/repo"), true);
|
|
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/..x"), true);
|
|
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/bob"), false);
|
|
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/../bob"), false);
|
|
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alicex"), false);
|
|
assert.equal(scope.folderAllowed(admin, "/etc"), true);
|
|
} finally {
|
|
delete process.env.AGENTHUB_USERS_ROOT;
|
|
}
|
|
assert.equal(scope.folderAllowed(member(1), "/anywhere"), true);
|
|
});
|
|
|
|
it("never lets a request choose the owner", () => {
|
|
assert.equal(scope.ownerFor(member(7)), 7);
|
|
assert.equal(scope.ownerFor(local), null);
|
|
});
|
|
});
|
|
|
|
describe("scopeGuard", () => {
|
|
const run = (req) => {
|
|
let status = 200;
|
|
let passed = false;
|
|
const res = {
|
|
status(s) {
|
|
status = s;
|
|
return this;
|
|
},
|
|
json() {
|
|
return this;
|
|
},
|
|
};
|
|
scope.scopeGuard({ method: "GET", query: {}, body: {}, ...req }, res, () => (passed = true));
|
|
return passed ? 200 : status;
|
|
};
|
|
const m1 = member(1).user;
|
|
|
|
it("refuses a member another user's row named in the path", () => {
|
|
assert.equal(run({ user: m1, path: "/sessions/s1" }), 200);
|
|
assert.equal(run({ user: m1, path: "/sessions/s2" }), 404);
|
|
assert.equal(run({ user: m1, path: "/sessions/s2/transcript" }), 404);
|
|
assert.equal(run({ user: m1, path: "/sessions/facets" }), 200);
|
|
assert.equal(run({ user: m1, path: "/agents/a2" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/1/git/changes" }), 200);
|
|
assert.equal(run({ user: m1, path: "/workspaces/2/start", method: "POST" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/3" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/ensure", method: "POST" }), 200);
|
|
assert.equal(run({ user: m1, path: "/run/agenthub-lane-2-3" }), 404);
|
|
assert.equal(run({ user: m1, path: "/run/agenthub-lane-1" }), 200);
|
|
assert.equal(run({ user: m1, path: "/pricing/cost/s2" }), 404);
|
|
assert.equal(run({ user: m1, path: "/sessions/s%32" }), 404); // encoded "s2"
|
|
});
|
|
|
|
it("refuses a member another user's row named in the query or body", () => {
|
|
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "2" } }), 404);
|
|
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "1" } }), 200);
|
|
assert.equal(run({ user: m1, path: "/agents", query: { session_ids: "s1,s2" } }), 404);
|
|
assert.equal(run({ user: m1, path: "/run", method: "POST", body: { laneId: 2 } }), 404);
|
|
assert.equal(
|
|
run({ user: m1, path: "/lanes/1/start", method: "POST", body: { resumeSessionId: "s2" } }),
|
|
404
|
|
);
|
|
assert.equal(run({ user: m1, path: "/lanes/1/git/changes", query: { session: "s2" } }), 404);
|
|
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "1 OR 1" } }), 404);
|
|
// `?session_id[]=…` / `?lane[]=…` arrive as arrays, and a JSON body may
|
|
// carry objects: a member's request with one is refused outright.
|
|
assert.equal(run({ user: m1, path: "/agents", query: { session_id: ["s2"] } }), 404);
|
|
assert.equal(run({ user: m1, path: "/agents", query: { session_ids: ["s1", "s2"] } }), 404);
|
|
assert.equal(run({ user: m1, path: "/sessions", query: { lane: ["1", "2"] } }), 404);
|
|
assert.equal(
|
|
run({ user: m1, path: "/agents", method: "POST", body: { session_id: { $ne: 1 } } }),
|
|
404
|
|
);
|
|
});
|
|
|
|
it("lets admin and local requests through", () => {
|
|
for (const user of [admin.user, undefined]) {
|
|
assert.equal(run({ user, path: "/sessions/s2" }), 200);
|
|
assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 200);
|
|
}
|
|
});
|
|
|
|
it("lets an analyst read every row but write only their own", () => {
|
|
const user = analyst.user;
|
|
assert.equal(run({ user, path: "/sessions/s2" }), 200);
|
|
assert.equal(run({ user, path: "/lanes/2", method: "HEAD" }), 200);
|
|
assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 404);
|
|
assert.equal(run({ user, path: "/sessions/s2", method: "PATCH" }), 404);
|
|
assert.equal(run({ user, path: "/lanes/2", method: "DELETE" }), 404);
|
|
assert.equal(run({ user, path: "/lanes/4", method: "DELETE" }), 200);
|
|
});
|
|
});
|
|
|
|
describe("broadcastAudience", () => {
|
|
const m1 = member(1).user;
|
|
const m2 = member(2).user;
|
|
const reaches = (type, data, user) =>
|
|
scope.audienceIncludes(scope.broadcastAudience(type, data), user);
|
|
|
|
it("sends a session's updates only to its owner (and staff)", () => {
|
|
assert.equal(reaches("session_updated", { id: "s1" }, m1), true);
|
|
assert.equal(reaches("session_updated", { id: "s1" }, m2), false);
|
|
assert.equal(reaches("session_updated", { id: "s-unstamped" }, m1), true);
|
|
assert.equal(reaches("new_event", { session_id: "s2" }, m2), true);
|
|
assert.equal(reaches("new_event", { session_id: "s2" }, m1), false);
|
|
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
|
|
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
|
|
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
|
|
});
|
|
|
|
it("routes workspace messages by owner and keeps unknown types from members", () => {
|
|
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m1), true);
|
|
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m2), false);
|
|
assert.equal(reaches("lane_update", { removed: 2 }, m1), true);
|
|
assert.equal(reaches("lane_runtime", { laneId: 2 }, m1), false);
|
|
assert.equal(reaches("alert_triggered", { session_id: null }, m1), false);
|
|
assert.equal(reaches("alert_triggered", { session_id: null }, analyst.user), true);
|
|
assert.equal(reaches("update_status", {}, m1), true);
|
|
assert.equal(reaches("import.progress", {}, m1), false);
|
|
assert.equal(reaches("some_future_type", {}, m1), false);
|
|
});
|
|
});
|
|
|
|
describe("scopeGuard against the router's own leniency", () => {
|
|
const run = (req) => {
|
|
let status = 200;
|
|
let passed = false;
|
|
const res = {
|
|
status(s) {
|
|
status = s;
|
|
return this;
|
|
},
|
|
json() {
|
|
return this;
|
|
},
|
|
};
|
|
scope.scopeGuard({ method: "GET", query: {}, body: {}, ...req }, res, () => (passed = true));
|
|
return passed ? 200 : status;
|
|
};
|
|
const m1 = member(1).user;
|
|
|
|
it("matches case-insensitively and on decoded segments, like Express routes", () => {
|
|
assert.equal(run({ user: m1, path: "/Lanes/2" }), 404);
|
|
assert.equal(run({ user: m1, path: "/WORKSPACES/2/start" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/%32" }), 404);
|
|
assert.equal(run({ user: m1, path: "/Sessions/s2" }), 404);
|
|
assert.equal(run({ user: m1, path: "//sessions/s2" }), 404);
|
|
assert.equal(run({ user: m1, path: "/run/%61genthub-lane-2" }), 404);
|
|
assert.equal(run({ user: m1, path: "/Pricing/Cost/s2" }), 404);
|
|
});
|
|
|
|
it("reads a lane id the way SQLite will, and fails closed on odd ones", () => {
|
|
assert.equal(run({ user: m1, path: "/lanes/2.0" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/02" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/01" }), 200);
|
|
assert.equal(run({ user: m1, path: "/lanes/1.0" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/1e1/sources" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/abc" }), 404);
|
|
assert.equal(run({ user: m1, path: "/lanes/Ensure", method: "POST" }), 200);
|
|
assert.equal(run({ user: m1, path: "/lanes/pipeline-stats" }), 200);
|
|
});
|
|
});
|