c47dd230d4
v2.7.3's ENOCWD left a new member no web path to a first project: their home is no workspace (EFOLDER) and a not-yet-existing folder could not start. run-as.makeMemberFolder: mkdir -p through write-as, AS the member, only inside their own home (never the home itself, never a host folder, never for a member with no account). Test: server-s3-lanes.
195 lines
7.4 KiB
JavaScript
195 lines
7.4 KiB
JavaScript
/**
|
|
* @file server-s3-lanes.test.js
|
|
* @description S3 plan, P3e: a workspace in a member's folder gets neither the
|
|
* managed-worktree mode nor the runtime layer (they run as the dashboard's own
|
|
* account and write into LANES_ROOT) — 403 EMEMBERLANE; a host workspace is
|
|
* untouched. A host plugin reload never types into a member's pane. An analyst
|
|
* WRITES like a developer: their workspace only in their own folder, run as
|
|
* their own Linux 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-lanes-")));
|
|
const USERS = path.join(TMP, "users");
|
|
const PROJ = path.join(USERS, "mia", "proj");
|
|
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.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");
|
|
|
|
let server;
|
|
let BASE;
|
|
let memberLane;
|
|
let hostLane;
|
|
let cookie;
|
|
|
|
const call = (method, p, body) =>
|
|
fetch(BASE + p, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", cookie },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
|
|
|
before(async () => {
|
|
fs.mkdirSync(PROJ, { recursive: true });
|
|
execFileSync("git", ["init", "-q", PROJ]);
|
|
execFileSync("git", [
|
|
"-C",
|
|
PROJ,
|
|
"-c",
|
|
"user.email=a@b",
|
|
"-c",
|
|
"user.name=a",
|
|
"commit",
|
|
"-q",
|
|
"--allow-empty",
|
|
"-m",
|
|
"x",
|
|
]);
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_PASSWD,
|
|
"ah-mia:x:5001:5001::/:/bin/sh\nah-ana:x:5002:5002::/:/bin/sh\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");
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
process.env.AGENTHUB_MODE = "server";
|
|
await auth.createUser("boss", "admin-pass-1", "admin");
|
|
await auth.createUser("mia", "member-pass-1", "developer");
|
|
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
|
const boss = db.prepare("SELECT id FROM users WHERE username = 'boss'").get().id;
|
|
const insert = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
|
|
memberLane = Number(insert.run(PROJ, boss).lastInsertRowid);
|
|
hostLane = Number(insert.run(path.join(TMP, "hostproj"), boss).lastInsertRowid);
|
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: "boss", password: "admin-pass-1" }),
|
|
});
|
|
cookie = login.headers.get("set-cookie").split(";")[0];
|
|
});
|
|
|
|
after(() => {
|
|
tmux.__reset();
|
|
delete process.env.AGENTHUB_MODE;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("no runtime layer and no managed worktree in a member's folder", () => {
|
|
for (const [method, p] of [
|
|
["POST", "up"],
|
|
["POST", "down"],
|
|
["POST", "hook/bootstrap"],
|
|
["POST", "sync-base"],
|
|
["POST", "profile/init"],
|
|
["POST", "mcp/sync"],
|
|
]) {
|
|
it(`refuses ${method} /:id/${p}`, async () => {
|
|
const r = await call(method, `/api/lanes/${memberLane}/${p}`, {});
|
|
assert.equal(r.status, 403, JSON.stringify(r.body));
|
|
assert.equal(r.body.error.code, "EMEMBERLANE");
|
|
});
|
|
}
|
|
|
|
it("refuses a managed worktree of a member's repository", async () => {
|
|
const r = await call("POST", "/api/lanes/worktree", { sourceRepo: PROJ, slug: "feat" });
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error.code, "EMEMBERLANE");
|
|
});
|
|
|
|
it("leaves a host workspace's runtime routes as they were", async () => {
|
|
const r = await call("POST", `/api/lanes/${hostLane}/up`, {});
|
|
assert.notEqual(r.body?.error?.code, "EMEMBERLANE");
|
|
});
|
|
});
|
|
|
|
describe("a host plugin reload", () => {
|
|
it("never types into a member's pane", () => {
|
|
const { reloadableLanes } = require("../lib/cc-reload");
|
|
const runs = require("../lib/pty-run");
|
|
const orig = runs.listRuns;
|
|
db.prepare(
|
|
"INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status, tool) VALUES (?, ?, ?, 'interactive', 'running', 'claude')"
|
|
).run(`agenthub-lane-${memberLane}`, memberLane, PROJ);
|
|
tmux.__setExecImpl((args) =>
|
|
args.includes("list-sessions") ? `agenthub-lane-${memberLane}\n` : "claude\n"
|
|
);
|
|
try {
|
|
assert.ok(!reloadableLanes().some((l) => l.id === `agenthub-lane-${memberLane}`));
|
|
} finally {
|
|
runs.listRuns = orig;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("an analyst's workspace is theirs, like a developer's", () => {
|
|
it("is created only in their own folder and runs as their Linux account", async () => {
|
|
const own = path.join(USERS, "ana", "proj");
|
|
fs.mkdirSync(own, { recursive: true });
|
|
process.env.AGENTHUB_USERS_ROOT = USERS;
|
|
try {
|
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: "ana", password: "analyst-pass-1" }),
|
|
});
|
|
const ca = login.headers.get("set-cookie").split(";")[0];
|
|
const post = (cwd) =>
|
|
fetch(`${BASE}/api/lanes`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", cookie: ca },
|
|
body: JSON.stringify({ cwd }),
|
|
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
|
const theirs = await post(path.join(USERS, "mia"));
|
|
assert.equal(theirs.status, 403);
|
|
assert.equal(theirs.body.error.code, "EFOLDER");
|
|
const mine = await post(own);
|
|
assert.equal(mine.status, 201, JSON.stringify(mine.body));
|
|
assert.equal(runAs.accountForLane(mine.body.lane.id).login, "ah-ana");
|
|
} finally {
|
|
delete process.env.AGENTHUB_USERS_ROOT;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("a member's new project folder", () => {
|
|
it("is made as the member, and nothing else is made for anyone", () => {
|
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
|
const sudo = fakeSudo();
|
|
runAs.__setExecImpl(sudo.exec);
|
|
const fresh = path.join(USERS, "mia", "brand-new", "sub");
|
|
assert.equal(runAs.makeMemberFolder(fresh), true);
|
|
assert.ok(fs.statSync(fresh).isDirectory());
|
|
const call = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
|
|
assert.ok(call && call.includes("ah-mia") && call.includes(fresh) && call.includes("mkdir"));
|
|
sudo.calls.length = 0;
|
|
assert.equal(runAs.makeMemberFolder(path.join(USERS, "mia")), false, "the home itself");
|
|
assert.equal(runAs.makeMemberFolder(path.join(TMP, "host-new")), false, "not a member's");
|
|
assert.equal(runAs.makeMemberFolder(path.join(USERS, "nobody", "x")), false, "no account");
|
|
assert.deepEqual(sudo.calls, []);
|
|
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
|
|
});
|
|
});
|