fa156d6a59
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
280 lines
11 KiB
JavaScript
280 lines
11 KiB
JavaScript
/**
|
|
* @file server-mode-auth.test.js
|
|
* @description Server mode's login gate (server/lib/auth.js, routes/auth.js):
|
|
* local mode untouched, every API call behind a login, roles (analyst
|
|
* read-only, admin-only config writes), hooks from this machine only, logout
|
|
* and disable ending a login, the failed-login throttle, the WebSocket check,
|
|
* and the same-origin guard accepting the dashboard's own allowed Host.
|
|
* @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("path");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `server-mode-test-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const auth = require("../lib/auth");
|
|
const { sameOriginGuard } = require("../lib/same-origin");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
async function call(method, p, { cookie, body, headers = {} } = {}) {
|
|
const res = await fetch(BASE + p, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
...headers,
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
let json = null;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
/* not JSON */
|
|
}
|
|
return { status: res.status, body: json, setCookie: res.headers.get("set-cookie") };
|
|
}
|
|
|
|
async function loginAs(username, password) {
|
|
const r = await call("POST", "/api/auth/login", { body: { username, password } });
|
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
return r.setCookie.split(";")[0];
|
|
}
|
|
|
|
before(async () => {
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
await auth.createUser("boss", "admin-pass-1", "admin");
|
|
await auth.createUser("mia", "member-pass-1", "developer");
|
|
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
|
});
|
|
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
server?.close();
|
|
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
|
|
fs.rmSync(f, { force: true });
|
|
}
|
|
});
|
|
|
|
describe("local mode (the default)", () => {
|
|
it("needs no login and says it is local", async () => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
assert.equal((await call("GET", "/api/sessions")).status, 200);
|
|
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "local" });
|
|
assert.equal((await call("POST", "/api/auth/login", { body: {} })).status, 404);
|
|
// The one local viewer keeps their prefs too.
|
|
const chat = { prefs: { "agenthub.chatUi": "1" } };
|
|
assert.equal((await call("PUT", "/api/me/prefs", { body: chat })).status, 200);
|
|
assert.equal((await call("GET", "/api/me/prefs")).body.prefs["agenthub.chatUi"], "1");
|
|
});
|
|
});
|
|
|
|
describe("server mode", () => {
|
|
before(() => {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
});
|
|
|
|
it("refuses the API without a login, but answers health and /auth/me", async () => {
|
|
const r = await call("GET", "/api/sessions");
|
|
assert.equal(r.status, 401);
|
|
assert.equal(r.body.error.code, "EAUTH");
|
|
assert.equal((await call("GET", "/api/health")).status, 200);
|
|
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "server", user: null });
|
|
});
|
|
|
|
it("rejects a wrong password and sets an httpOnly strict cookie on a right one", async () => {
|
|
const bad = await call("POST", "/api/auth/login", { body: { username: "mia", password: "x" } });
|
|
assert.equal(bad.status, 401);
|
|
assert.equal(bad.body.error.code, "EBADLOGIN");
|
|
const ok = await call("POST", "/api/auth/login", {
|
|
body: { username: "MIA", password: "member-pass-1" },
|
|
});
|
|
assert.equal(ok.status, 200);
|
|
assert.equal(ok.body.user.username, "mia");
|
|
assert.match(ok.setCookie, /agenthub_sid=[^;]+; Path=\/; HttpOnly; SameSite=Strict/);
|
|
const cookie = ok.setCookie.split(";")[0];
|
|
assert.equal((await call("GET", "/api/sessions", { cookie })).status, 200);
|
|
assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "developer");
|
|
});
|
|
|
|
it("lets an analyst work but keeps the host's config writes an admin's", async () => {
|
|
const analyst = await loginAs("ana", "analyst-pass-1");
|
|
assert.equal((await call("GET", "/api/sessions", { cookie: analyst })).status, 200);
|
|
// Past the role gate: the route itself answers the empty body.
|
|
const w = await call("POST", "/api/lanes/ensure", { cookie: analyst, body: {} });
|
|
assert.ok(![401, 403].includes(w.status), `got ${w.status}`);
|
|
for (const [method, url] of [
|
|
["PUT", "/api/settings/no-such-route"],
|
|
["POST", "/api/settings/quit"],
|
|
["POST", "/api/alerts/rules"],
|
|
["DELETE", "/api/alerts/rules/x"],
|
|
]) {
|
|
const r = await call(method, url, { cookie: analyst, body: {} });
|
|
assert.equal(r.status, 403, `${method} ${url}`);
|
|
assert.equal(r.body.error.code, "EADMIN");
|
|
}
|
|
|
|
const member = await loginAs("mia", "member-pass-1");
|
|
// A path no router answers: the guard decides before any handler could
|
|
// write the real settings file.
|
|
const m = await call("PUT", "/api/settings/no-such-route", { cookie: member, body: {} });
|
|
assert.equal(m.status, 403);
|
|
assert.equal(m.body.error.code, "EADMIN");
|
|
|
|
const admin = await loginAs("boss", "admin-pass-1");
|
|
const a = await call("PUT", "/api/settings/no-such-route", { cookie: admin, body: {} });
|
|
assert.equal(a.status, 404);
|
|
});
|
|
|
|
it("takes hooks from this machine only", async () => {
|
|
const remote = await call("POST", "/api/hooks/event", {
|
|
body: {},
|
|
headers: { "x-forwarded-for": "203.0.113.9" },
|
|
});
|
|
assert.equal(remote.status, 403);
|
|
assert.equal(remote.body.error.code, "ELOCALONLY");
|
|
const local = await call("POST", "/api/hooks/event", { body: {} });
|
|
assert.ok(![401, 403].includes(local.status), `got ${local.status}`);
|
|
});
|
|
|
|
it("ends a login on logout and on disable", async () => {
|
|
const one = await loginAs("mia", "member-pass-1");
|
|
await call("POST", "/api/auth/logout", { cookie: one });
|
|
assert.equal((await call("GET", "/api/sessions", { cookie: one })).status, 401);
|
|
|
|
const two = await loginAs("mia", "member-pass-1");
|
|
auth.setDisabled("mia", true);
|
|
assert.equal((await call("GET", "/api/sessions", { cookie: two })).status, 401);
|
|
const again = await call("POST", "/api/auth/login", {
|
|
body: { username: "mia", password: "member-pass-1" },
|
|
});
|
|
assert.equal(again.status, 401);
|
|
auth.setDisabled("mia", false);
|
|
});
|
|
|
|
it("keeps each user's UI prefs apart, and only for a logged-in caller", async () => {
|
|
assert.equal((await call("GET", "/api/me/prefs")).status, 401);
|
|
const boss = await loginAs("boss", "admin-pass-1");
|
|
const ana = await loginAs("ana", "analyst-pass-1");
|
|
const on = { prefs: { "agenthub.chatUi": "1" } };
|
|
const put = await call("PUT", "/api/me/prefs", { cookie: boss, body: on });
|
|
assert.deepEqual(put.body.prefs, { "agenthub.chatUi": "1" });
|
|
assert.equal(
|
|
(await call("GET", "/api/me/prefs", { cookie: ana })).body.prefs["agenthub.chatUi"],
|
|
undefined
|
|
);
|
|
for (const prefs of [{ "agenthub.chatUi": true }, { userKey: "u1" }]) {
|
|
const bad = await call("PUT", "/api/me/prefs", { cookie: ana, body: { prefs } });
|
|
assert.equal(bad.status, 400);
|
|
assert.equal(bad.body.error.code, "EBADPREFS");
|
|
}
|
|
// null removes the key: back to the default.
|
|
await call("PUT", "/api/me/prefs", {
|
|
cookie: boss,
|
|
body: { prefs: { "agenthub.chatUi": null } },
|
|
});
|
|
assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: boss })).body.prefs, {});
|
|
// The appearance (useTheme's storage keys) follows the account too.
|
|
const look = {
|
|
theme: "light",
|
|
"agenthub.theme.light": "canvas",
|
|
"agenthub.theme.dark": "darcula",
|
|
};
|
|
const saved = await call("PUT", "/api/me/prefs", { cookie: ana, body: { prefs: look } });
|
|
assert.equal(saved.status, 200);
|
|
assert.deepEqual(saved.body.prefs, look);
|
|
});
|
|
|
|
it("checks a WebSocket upgrade's login and Origin", async () => {
|
|
const cookie = await loginAs("boss", "admin-pass-1");
|
|
const req = (headers, url = "/ws") => ({ url, headers: { host: "127.0.0.1:1", ...headers } });
|
|
assert.equal(auth.isWebSocketLoggedIn(req({})), false);
|
|
assert.equal(auth.isWebSocketLoggedIn(req({ cookie })), true);
|
|
assert.equal(auth.isWebSocketLoggedIn(req({ cookie, origin: "https://evil.example" })), false);
|
|
const analyst = await loginAs("ana", "analyst-pass-1");
|
|
assert.equal(auth.isWebSocketLoggedIn(req({ cookie: analyst })), true);
|
|
// Which runs an analyst may type into is the run check's (canSeeRun), not this one's.
|
|
assert.equal(
|
|
auth.isWebSocketLoggedIn(req({ cookie: analyst }, "/ws-pty/agenthub-lane-1")),
|
|
true
|
|
);
|
|
});
|
|
|
|
// Last: it locks this test's address out of logging in.
|
|
it("throttles repeated failed logins", async () => {
|
|
let last;
|
|
for (let i = 0; i < 11; i++) {
|
|
last = await call("POST", "/api/auth/login", { body: { username: "boss", password: "no" } });
|
|
}
|
|
assert.equal(last.status, 429);
|
|
assert.equal(last.body.error.code, "ETOOMANY");
|
|
});
|
|
});
|
|
|
|
describe("sameOriginGuard", () => {
|
|
const run = (headers) => {
|
|
let status = 200;
|
|
let passed = false;
|
|
const res = {
|
|
status(s) {
|
|
status = s;
|
|
return this;
|
|
},
|
|
json() {
|
|
return this;
|
|
},
|
|
};
|
|
sameOriginGuard({ headers }, res, () => (passed = true));
|
|
return passed ? 200 : status;
|
|
};
|
|
|
|
it("accepts the dashboard's own allowed Host as an Origin", () => {
|
|
process.env.DASHBOARD_ALLOWED_HOSTS = "agenthub.example.com";
|
|
try {
|
|
assert.equal(
|
|
run({ origin: "https://agenthub.example.com", host: "agenthub.example.com" }),
|
|
200
|
|
);
|
|
assert.equal(run({ origin: "https://evil.example", host: "agenthub.example.com" }), 403);
|
|
assert.equal(run({ origin: "https://evil.example", host: "evil.example" }), 403);
|
|
assert.equal(run({ origin: "http://localhost:4820", host: "localhost:4820" }), 200);
|
|
assert.equal(run({}), 200);
|
|
} finally {
|
|
delete process.env.DASHBOARD_ALLOWED_HOSTS;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("server mode prefix checks follow Express routing", () => {
|
|
before(() => {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
});
|
|
it("refuses a member a staff-only router reached by case or encoding", async () => {
|
|
const r = await call("POST", "/api/auth/login", {
|
|
body: { username: "mia", password: "member-pass-1" },
|
|
headers: { "x-forwarded-for": "198.51.100.7" },
|
|
});
|
|
const cookie = r.setCookie.split(";")[0];
|
|
for (const p of ["/api/webhooks", "/api/Webhooks", "/api/%77ebhooks", "/api//webhooks"]) {
|
|
const res = await call("GET", p, { cookie });
|
|
assert.ok([403, 404].includes(res.status), `${p} answered ${res.status}`);
|
|
}
|
|
const hook = await call("POST", "/api/Hooks/event", {
|
|
body: {},
|
|
headers: { "x-forwarded-for": "203.0.113.9" },
|
|
});
|
|
assert.equal(hook.status, 403);
|
|
});
|
|
});
|