46e3899945
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again (a staged change since undone on disk) is no longer listed — JetBrains' GitChangesCollector drops it the same way. Mode-only changes stay listed. statusCounts (the dirty-tree safety checks) is unchanged. - Chat (#73): Claude Code runs no hook before a folder is trusted, so a new pane's session has no row; the chat now says the session has not started, offers Open terminal, and asks before typing a message or /model //effort into the pane (an Enter would pick the trust prompt's "No, exit"). - Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on each key (insertReplacementText), which xterm.js ignores — only each word's first letter reached the pane. The edit is now sent as DELs + the new tail. - Also: Ctrl+V pastes a screenshot into the terminal as a file path; the folder browser fallback lists files for a file field (`files=1`); the macOS native picker opens in front of the browser; a hidden tab is marked while a chat waits or once a turn ended; chat live line shows running calls, turn time and tokens, and "Done in …".
729 lines
31 KiB
JavaScript
729 lines
31 KiB
JavaScript
/**
|
|
* @file server-s3-review5.test.js
|
|
* @description S3 plan, fifth review: a member's Antigravity conversation is
|
|
* read from a private copy (a FIFO beside it blocks nothing); a developer's
|
|
* extra workspace folder is refused outside their home before it is probed;
|
|
* the config reader never serves the data dir (pane tokens, push keys, the
|
|
* database); a stopped or flooding member tmux server and a hung sudo never
|
|
* block the dashboard nor blind it to other members' panes; a one-line 512 MB
|
|
* file, a huge member file and a sparse transcript neither crash the server
|
|
* nor fill the host's disk, and the snapshot copy yields to the event loop
|
|
* and runs once per file; an async route that throws answers 500; a
|
|
* non-admin owns no host-folder workspace and a pane's cwd is its
|
|
* workspace's account's; a pane token works only from its pane's uid; an
|
|
* analyst's GET path stays in their own folder; a drop goes to the pane's
|
|
* account; an image a session read is served only from a member session's
|
|
* own home (a host session's from anywhere). A fake passwd (every member = this test's uid) and server.conf
|
|
* make the members provisioned; fake sudo runs their commands as this
|
|
* account — and a fake `sudo` on PATH that never returns stands in for a
|
|
* stalled member, in a child process.
|
|
* @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 { spawnSync } = require("node:child_process");
|
|
|
|
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review5-")));
|
|
const USERS = path.join(TMP, "users");
|
|
const MIA = path.join(USERS, "mia");
|
|
const BOB = path.join(USERS, "bob");
|
|
// The real layout: the data dir (and its database) inside CLAUDE_HOME.
|
|
const DATA = path.join(TMP, "host-claude", "agenthub");
|
|
process.env.DASHBOARD_DATA_DIR = DATA;
|
|
process.env.DASHBOARD_DB_PATH = path.join(DATA, "dashboard.db");
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
|
process.env.CLAUDE_HOME = path.join(TMP, "host-claude");
|
|
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
|
process.env.AGENTHUB_USERS_ROOT = USERS;
|
|
|
|
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");
|
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
|
|
|
let server;
|
|
let BASE;
|
|
const cookies = {};
|
|
|
|
const call = (who, method, p, body) =>
|
|
fetch(BASE + p, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", cookie: cookies[who] },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
|
|
|
const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
|
|
const addLane = (cwd, owner) =>
|
|
Number(
|
|
db
|
|
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')")
|
|
.run(cwd, idOf(owner)).lastInsertRowid
|
|
);
|
|
|
|
const UID = process.getuid();
|
|
let tmuxCalls = [];
|
|
let liveRuns = new Set();
|
|
const fakeTmux = (args) => {
|
|
tmuxCalls.push(args);
|
|
if (args[0] === "-V") return "tmux 3.4";
|
|
if (args.includes("has-session")) {
|
|
if (liveRuns.has(String(args.at(-1)).replace(/^=/, ""))) return "";
|
|
throw Object.assign(new Error("no"), { status: 1 });
|
|
}
|
|
return "";
|
|
};
|
|
|
|
before(async () => {
|
|
for (const d of [path.join(MIA, "proj"), path.join(MIA, "extra"), path.join(BOB, "private")])
|
|
fs.mkdirSync(d, { recursive: true });
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_PASSWD,
|
|
["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n"
|
|
);
|
|
runAs.__resetCache();
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_SERVER_CONF,
|
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
|
);
|
|
linux.__resetConfCache();
|
|
linux.__setRunImpl(() => "ok");
|
|
runAs.__setExecImpl(fakeSudo().exec);
|
|
tmux.__setExecImpl(fakeTmux);
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
process.env.AGENTHUB_MODE = "server";
|
|
for (const [n, role] of [
|
|
["mia", "developer"],
|
|
["bob", "developer"],
|
|
["ana", "analyst"],
|
|
["boss", "admin"],
|
|
]) {
|
|
await auth.createUser(n, `${n}-pass-123`, role);
|
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
|
|
});
|
|
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
|
}
|
|
});
|
|
|
|
after(() => {
|
|
tmux.__reset();
|
|
delete process.env.AGENTHUB_MODE;
|
|
delete process.env.AGENTHUB_USERS_ROOT;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("a member's Antigravity conversation", () => {
|
|
it("is read from a private copy: a FIFO beside it blocks nothing", () => {
|
|
const Database = require("better-sqlite3");
|
|
const dir = path.join(MIA, ".gemini", "antigravity-cli", "conversations");
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const file = path.join(dir, "7481e37e-9157-4145-b985-2ea9a2117f52.db");
|
|
const c = new Database(file);
|
|
c.exec(`CREATE TABLE gen_metadata (idx integer, data blob, PRIMARY KEY (idx));
|
|
CREATE TABLE trajectory_metadata_blob (id text, data blob, PRIMARY KEY (id));`);
|
|
c.close();
|
|
// A "hot journal" SQLite would open by name — and block on, as a FIFO.
|
|
assert.equal(spawnSync("mkfifo", [`${file}-journal`]).status, 0);
|
|
// In a child: the old open blocked its whole thread, which no test timeout interrupts.
|
|
const child = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"-e",
|
|
`const r = require(${JSON.stringify(path.join(__dirname, "..", "lib", "antigravity-usage"))}).readConversation(${JSON.stringify(file)});
|
|
process.stdout.write(JSON.stringify(r));`,
|
|
],
|
|
{ env: { ...process.env, AGENTHUB_MODE: "server" }, timeout: 15_000, encoding: "utf8" }
|
|
);
|
|
assert.equal(child.signal, null, "the read never blocked");
|
|
assert.equal(child.status, 0, child.stderr);
|
|
assert.deepEqual(JSON.parse(child.stdout), { cwd: null, usage: {}, model: null });
|
|
assert.ok(fs.statSync(`${file}-journal`).isFIFO(), "the member's files are left alone");
|
|
});
|
|
});
|
|
|
|
describe("a developer's extra workspace folder", () => {
|
|
it("is refused outside their own home before anything probes it", async () => {
|
|
const lane = addLane(path.join(MIA, "proj"), "mia");
|
|
const add = (p) => call("mia", "POST", `/api/lanes/${lane}/sources/folder`, { path: p });
|
|
// Bob's folder answers exactly like a folder that does not exist.
|
|
for (const p of [path.join(BOB, "private"), path.join(BOB, "nope")]) {
|
|
const r = await add(p);
|
|
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
|
|
assert.equal(r.body.error.code, "EFOLDER");
|
|
}
|
|
// A link in her own home that points out is refused too.
|
|
fs.symlinkSync(path.join(BOB, "private"), path.join(MIA, "to-bob"));
|
|
const linked = await add(path.join(MIA, "to-bob"));
|
|
assert.equal(linked.status, 403, JSON.stringify(linked.body));
|
|
// Her own folder is fine.
|
|
const mine = await add(path.join(MIA, "extra"));
|
|
assert.equal(mine.status, 201, JSON.stringify(mine.body));
|
|
assert.deepEqual(
|
|
mine.body.sources.map((s) => s.path),
|
|
[path.join(MIA, "extra")]
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the Claude config file reader", () => {
|
|
it("never serves AgentHub's data dir: pane tokens, push keys, the database", async () => {
|
|
fs.mkdirSync(path.join(DATA, "proxy-settings"), { recursive: true });
|
|
const token = path.join(DATA, "proxy-settings", "gw--agenthub-lane-4.json");
|
|
fs.writeFileSync(token, '{"env":{"ANTHROPIC_AUTH_TOKEN":"ahpane_SECRET"}}');
|
|
fs.writeFileSync(path.join(DATA, "vapid-keys.json"), '{"privateKey":"VAPID-SECRET"}');
|
|
// Through a link elsewhere in CLAUDE_HOME too.
|
|
fs.symlinkSync(token, path.join(TMP, "host-claude", "innocent.json"));
|
|
for (const who of ["ana", "boss"]) {
|
|
for (const f of [
|
|
token,
|
|
path.join(DATA, "vapid-keys.json"),
|
|
path.join(DATA, "dashboard.db"),
|
|
path.join(TMP, "host-claude", "innocent.json"),
|
|
]) {
|
|
const r = await call(who, "GET", `/api/cc-config/file?path=${encodeURIComponent(f)}`);
|
|
assert.equal(r.status, 400, `${who} ${f}: ${JSON.stringify(r.body).slice(0, 200)}`);
|
|
assert.ok(!/SECRET|SQLite/.test(JSON.stringify(r.body)));
|
|
}
|
|
}
|
|
fs.writeFileSync(path.join(TMP, "host-claude", "settings.json"), '{"ok":true}');
|
|
const ok = await call(
|
|
"ana",
|
|
"GET",
|
|
`/api/cc-config/file?path=${encodeURIComponent(path.join(TMP, "host-claude", "settings.json"))}`
|
|
);
|
|
assert.equal(ok.status, 200);
|
|
});
|
|
});
|
|
|
|
// ── Robustness: what a member can do to the dashboard's one thread ─────────
|
|
|
|
const LIB = path.join(__dirname, "..", "lib");
|
|
/** Run `code` in a child (the old code hung or crashed the whole process). */
|
|
const inChild = (code, env = {}) =>
|
|
spawnSync(process.execPath, ["-e", code], {
|
|
env: { ...process.env, AGENTHUB_MODE: "server", ...env },
|
|
timeout: 40_000,
|
|
encoding: "utf8",
|
|
});
|
|
/** A sparse file of `size` bytes (costs no disk), optionally with text at both ends. */
|
|
function sparse(file, size, { head = "", tail = "" } = {}) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, head);
|
|
fs.truncateSync(file, size - Buffer.byteLength(tail));
|
|
if (tail) fs.appendFileSync(file, tail);
|
|
}
|
|
|
|
describe("a member's stopped tmux server or planted FIFO", () => {
|
|
it("never blocks the dashboard: every sudo into a member is bounded", () => {
|
|
const lane = addLane(path.join(MIA, "tm"), "mia");
|
|
const net = require("node:net");
|
|
const sock = path.join(MIA, ".agenthub", "tmux", "sock");
|
|
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
|
fs.rmSync(sock, { force: true });
|
|
const srv = net.createServer().listen(sock);
|
|
// sudo that never returns: a tmux client talking to a SIGSTOPped server.
|
|
const bin = path.join(TMP, "fakebin");
|
|
fs.mkdirSync(bin, { recursive: true });
|
|
const spawned = path.join(TMP, "sudo-spawns");
|
|
fs.rmSync(spawned, { force: true });
|
|
fs.writeFileSync(path.join(bin, "sudo"), `#!/bin/sh\necho x >> '${spawned}'\nexec sleep 60\n`, {
|
|
mode: 0o755,
|
|
});
|
|
try {
|
|
// Polls like the heal beat / GET /api/lanes for 4 s — past the 3 s
|
|
// timeout — while timing every poll and the event loop's worst stall.
|
|
const child = inChild(
|
|
`const runAs = require(${JSON.stringify(`${LIB}/run-as`)});
|
|
const tmux = require(${JSON.stringify(`${LIB}/tmux`)});
|
|
const { monitorEventLoopDelay } = require("node:perf_hooks");
|
|
const proj = ${JSON.stringify(path.join(MIA, "proj"))};
|
|
const time = (f) => { const t = Date.now(); try { f(); return { ms: Date.now() - t }; }
|
|
catch (e) { return { code: e.code, ms: Date.now() - t }; } };
|
|
(async () => {
|
|
runAs.accountForSession("agenthub-lane-${lane}"); // opens the DB: not what is timed
|
|
const loop = monitorEventLoopDelay({ resolution: 10 });
|
|
loop.enable();
|
|
let live;
|
|
const first = time(() => (live = tmux.liveSessions()));
|
|
let slowest = 0;
|
|
const beat = setInterval(() => {
|
|
slowest = Math.max(slowest, time(() => tmux.liveSessions()).ms);
|
|
}, 50);
|
|
await new Promise((r) => setTimeout(r, 4000));
|
|
clearInterval(beat);
|
|
loop.disable();
|
|
const after = tmux.liveSessions();
|
|
process.stdout.write(JSON.stringify({
|
|
isSet: live instanceof Set,
|
|
keeps: live.has("agenthub-lane-${lane}"),
|
|
other: live.has("agenthub-lane-999999"),
|
|
keepsAfter: after.has("agenthub-lane-${lane}"),
|
|
first, slowest, stallMs: loop.max / 1e6,
|
|
sudos: require("node:fs").readFileSync(${JSON.stringify(spawned)}, "utf8").trim().split("\\n").length,
|
|
git: time(() => runAs.execIn(proj, "git", ["status"])),
|
|
write: time(() => runAs.writeAs(runAs.accountForPath(proj), proj + "/f.txt", "", "x")),
|
|
}));
|
|
})();`,
|
|
{ PATH: `${bin}:${process.env.PATH}` }
|
|
);
|
|
assert.equal(child.signal, null, "nothing hung");
|
|
assert.equal(child.status, 0, child.stderr);
|
|
const r = JSON.parse(child.stdout);
|
|
// Her panes are UNKNOWN, not dead: nothing of hers is ended for a stall.
|
|
assert.equal(r.isSet, true, "the host's answer still counts");
|
|
assert.equal(r.keeps, true);
|
|
assert.equal(r.other, false);
|
|
assert.equal(r.keepsAfter, true, "still unknown once the hung list was killed");
|
|
// Listing a member never waits for them: no poll, no event-loop turn
|
|
// takes anything like the 3 s a hung sudo is allowed.
|
|
assert.ok(r.first.ms < 100, `first list: ${r.first.ms} ms`);
|
|
assert.ok(r.slowest < 100, `slowest poll: ${r.slowest} ms`);
|
|
assert.ok(r.stallMs < 500, `event loop stalled ${r.stallMs} ms`);
|
|
// ONE sudo, killed at the timeout, then backed off — not one per poll.
|
|
assert.equal(r.sudos, 1);
|
|
assert.equal(r.git.code, "ETIMEDOUT");
|
|
assert.equal(r.write.code, "ETIMEDOUT");
|
|
} finally {
|
|
srv.close();
|
|
}
|
|
});
|
|
|
|
it("one member's failing server never blinds the dashboard to everyone else's", () => {
|
|
const net = require("node:net");
|
|
const sock = path.join(MIA, ".agenthub", "tmux", "sock");
|
|
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
|
fs.rmSync(sock, { force: true });
|
|
const srv = net.createServer().listen(sock);
|
|
const lane = addLane(path.join(MIA, "proj2"), "mia");
|
|
let memberCalls = 0;
|
|
let hostFails = false;
|
|
tmux.__reset();
|
|
tmux.__setExecImpl(() => {
|
|
if (hostFails) throw Object.assign(new Error("fork"), { code: "EAGAIN" });
|
|
return "agenthub-lane-1\n";
|
|
});
|
|
tmux.__setAccountImpl(
|
|
(name) => runAs.accountForSession(name),
|
|
() => {
|
|
memberCalls += 1;
|
|
throw Object.assign(new Error("spawnSync sudo ENOBUFS"), { code: "ENOBUFS" });
|
|
}
|
|
);
|
|
try {
|
|
const live = tmux.liveSessions();
|
|
assert.ok(live, "not null for everyone");
|
|
assert.equal(live.has("agenthub-lane-1"), true);
|
|
assert.equal(live.has(`agenthub-lane-${lane}`), true, "hers: unknown, kept");
|
|
assert.equal(live.has("agenthub-lane-999999"), false);
|
|
tmux.__setExecImpl(() => "agenthub-lane-1\n");
|
|
tmux.liveSessions();
|
|
assert.equal(memberCalls, 1, "a flooding server is not asked again at once");
|
|
hostFails = true;
|
|
tmux.__setExecImpl(() => {
|
|
throw Object.assign(new Error("fork"), { code: "EAGAIN" });
|
|
});
|
|
assert.equal(tmux.liveSessions(), null, "our own failure still proves nothing");
|
|
} finally {
|
|
srv.close();
|
|
tmux.__reset();
|
|
tmux.__setExecImpl(fakeTmux);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("a member's huge or sparse files", () => {
|
|
it("a 600 MB line without a newline never crashes the transcript reader", () => {
|
|
const big = path.join(TMP, "one-line.jsonl");
|
|
sparse(big, 600 * 1024 * 1024, { head: '{"type":"user"' });
|
|
try {
|
|
const child = inChild(
|
|
`require(${JSON.stringify(path.join(__dirname, "..", "..", "scripts", "import-history"))})
|
|
.parseSessionFile(${JSON.stringify(big)})
|
|
.then(() => process.stdout.write("survived"));`
|
|
);
|
|
assert.equal(child.status, 0, child.stderr.slice(-400));
|
|
assert.equal(child.stdout, "survived");
|
|
} finally {
|
|
fs.rmSync(big, { force: true });
|
|
}
|
|
});
|
|
|
|
it("readLines keeps line numbers and never builds an over-long line", async () => {
|
|
const f = path.join(TMP, "lines.jsonl");
|
|
fs.writeFileSync(f, `a\r\n\n${"x".repeat(3 << 20)}\nlast`);
|
|
const out = [];
|
|
for await (const l of runAs.readLines(f, { maxLine: 1 << 20 })) out.push(l);
|
|
assert.deepEqual(out, ["a", "", "", "last"]);
|
|
fs.rmSync(f);
|
|
});
|
|
|
|
it("a member's file is read only up to its cap", async () => {
|
|
const f = path.join(MIA, ".codex", "auth.json");
|
|
sparse(f, 70 * 1024 * 1024);
|
|
assert.throws(() => runAs.readFileSafe(f, "utf8"), { code: "ETOOBIG" });
|
|
await assert.rejects(runAs.readFileSafeAsync(f), { code: "ETOOBIG" });
|
|
fs.writeFileSync(f, `{"pad":"${"x".repeat(2 << 20)}"}`);
|
|
assert.throws(() => runAs.readFileSafe(f, "utf8", { max: 1 << 20 }), { code: "ETOOBIG" });
|
|
fs.writeFileSync(f, '{"ok":1}');
|
|
assert.equal(runAs.readFileSafe(f, "utf8"), '{"ok":1}');
|
|
// A member transcript past the cap is not read on the event loop.
|
|
const t = path.join(MIA, ".claude", "projects", "p", "big.jsonl");
|
|
sparse(t, 300 * 1024 * 1024);
|
|
const real = fs.readSync;
|
|
let reads = 0;
|
|
fs.readSync = (...a) => {
|
|
reads += 1;
|
|
return real(...a);
|
|
};
|
|
try {
|
|
const TranscriptCache = require("../lib/transcript-cache");
|
|
new TranscriptCache()._streamRange(t, 0, fs.statSync(t).size);
|
|
} finally {
|
|
fs.readSync = real;
|
|
}
|
|
assert.equal(reads, 0);
|
|
fs.rmSync(t);
|
|
});
|
|
|
|
it("the transcript snapshot copy yields to the event loop between chunks", async () => {
|
|
const { snapshotTranscript } = require("../../scripts/import-history");
|
|
const { getTranscriptSnapshotDir } = require("../lib/claude-home");
|
|
const proj = path.join(MIA, ".claude", "projects", "-p");
|
|
const sid = "11111111-2222-4333-8444-777777777777";
|
|
const src = path.join(proj, `${sid}.jsonl`);
|
|
fs.mkdirSync(proj, { recursive: true });
|
|
fs.writeFileSync(src, Buffer.alloc(64 * 1024 * 1024, 0x61));
|
|
let ticks = 0;
|
|
let maxGap = 0;
|
|
let last = performance.now();
|
|
let done = false;
|
|
const tick = () => {
|
|
const now = performance.now();
|
|
maxGap = Math.max(maxGap, now - last);
|
|
last = now;
|
|
ticks += 1;
|
|
if (!done) setImmediate(tick);
|
|
};
|
|
setImmediate(tick);
|
|
try {
|
|
await snapshotTranscript(src, sid);
|
|
} finally {
|
|
done = true;
|
|
}
|
|
assert.ok(ticks >= 8, `only ${ticks} event-loop ticks ran during the copy`);
|
|
assert.ok(maxGap < 50, `the event loop was blocked for ${maxGap.toFixed(1)} ms`);
|
|
const copy = path.join(getTranscriptSnapshotDir(), `${sid}.jsonl`);
|
|
assert.ok(fs.readFileSync(copy).equals(fs.readFileSync(src)));
|
|
fs.rmSync(src);
|
|
fs.rmSync(copy);
|
|
});
|
|
|
|
it("two overlapping snapshots copy a file once, and a failed copy only logs", async () => {
|
|
const { snapshotTranscript } = require("../../scripts/import-history");
|
|
const { getTranscriptSnapshotDir } = require("../lib/claude-home");
|
|
const proj = path.join(MIA, ".claude", "projects", "-p");
|
|
const sid = "11111111-2222-4333-8444-888888888888";
|
|
const src = path.join(proj, `${sid}.jsonl`);
|
|
fs.mkdirSync(path.join(proj, sid, "subagents"), { recursive: true });
|
|
fs.writeFileSync(src, '{"a":1}\n'.repeat(1 << 18));
|
|
fs.writeFileSync(path.join(proj, sid, "subagents", "agent-x.jsonl"), '{"b":1}\n');
|
|
// The subagent's snapshot folder cannot be made: its parent is a file.
|
|
fs.mkdirSync(getTranscriptSnapshotDir(), { recursive: true });
|
|
fs.writeFileSync(path.join(getTranscriptSnapshotDir(), sid), "");
|
|
const realRename = fs.promises.rename;
|
|
const realWarn = console.warn;
|
|
let renames = 0;
|
|
const warned = [];
|
|
fs.promises.rename = (...a) => {
|
|
renames += 1;
|
|
return realRename(...a);
|
|
};
|
|
console.warn = (...a) => warned.push(a.join(" "));
|
|
try {
|
|
await Promise.all([snapshotTranscript(src, sid), snapshotTranscript(src, sid)]);
|
|
} finally {
|
|
fs.promises.rename = realRename;
|
|
console.warn = realWarn;
|
|
}
|
|
assert.equal(renames, 1);
|
|
const copy = path.join(getTranscriptSnapshotDir(), `${sid}.jsonl`);
|
|
assert.ok(fs.readFileSync(copy).equals(fs.readFileSync(src)));
|
|
assert.ok(
|
|
warned.some((w) => w.includes("agent-x.jsonl")),
|
|
JSON.stringify(warned)
|
|
);
|
|
assert.deepEqual(
|
|
fs.readdirSync(getTranscriptSnapshotDir()).filter((f) => f.endsWith(".tmp")),
|
|
[]
|
|
);
|
|
});
|
|
|
|
it("the transcript snapshot never fills the host's disk from a sparse file", async () => {
|
|
const { snapshotTranscript } = require("../../scripts/import-history");
|
|
const { getTranscriptSnapshotDir } = require("../lib/claude-home");
|
|
const proj = path.join(MIA, ".claude", "projects", "-p");
|
|
const huge = "11111111-2222-4333-8444-555555555555";
|
|
sparse(path.join(proj, `${huge}.jsonl`), 300 * 1024 * 1024);
|
|
await snapshotTranscript(path.join(proj, `${huge}.jsonl`), huge);
|
|
assert.equal(fs.existsSync(path.join(getTranscriptSnapshotDir(), `${huge}.jsonl`)), false);
|
|
// Under the cap: copied, and its hole stays a hole.
|
|
const holey = "11111111-2222-4333-8444-666666666666";
|
|
sparse(path.join(proj, `${holey}.jsonl`), 32 * 1024 * 1024, {
|
|
head: '{"a":1}\n',
|
|
tail: '{"z":1}\n',
|
|
});
|
|
await snapshotTranscript(path.join(proj, `${holey}.jsonl`), holey);
|
|
const copy = path.join(getTranscriptSnapshotDir(), `${holey}.jsonl`);
|
|
const st = fs.statSync(copy);
|
|
assert.equal(st.size, 32 * 1024 * 1024);
|
|
assert.ok(st.blocks * 512 < 4 * 1024 * 1024, `copy allocated ${st.blocks * 512} bytes`);
|
|
const text = fs.readFileSync(copy);
|
|
assert.equal(text.subarray(0, 8).toString(), '{"a":1}\n');
|
|
assert.equal(text.subarray(-8).toString(), '{"z":1}\n');
|
|
});
|
|
});
|
|
|
|
describe("a request that makes an async route throw", () => {
|
|
it("answers an error instead of taking the server down", async () => {
|
|
const r = await call("mia", "POST", "/api/lanes/worktree", {
|
|
sourceRepo: path.join(USERS, "ghost", "x"),
|
|
branch: "b",
|
|
});
|
|
assert.equal(r.status, 403, JSON.stringify(r.body));
|
|
assert.equal(r.body.error.code, "EMEMBERLANE");
|
|
// Any async handler that rejects: a 500, not an exit.
|
|
const express = require("express");
|
|
const app = express();
|
|
app.get("/boom", async () => {
|
|
await null;
|
|
throw new Error("boom");
|
|
});
|
|
const s = await new Promise((ok) => {
|
|
const h = app.listen(0, () => ok(h));
|
|
});
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${s.address().port}/boom`);
|
|
assert.equal(res.status, 500);
|
|
} finally {
|
|
s.close();
|
|
}
|
|
assert.equal((await call("mia", "GET", "/api/lanes")).status, 200, "still serving");
|
|
});
|
|
});
|
|
|
|
describe("who a pane runs as", () => {
|
|
const pty = require("../lib/pty-run");
|
|
const HOST = path.join(TMP, "hostdir");
|
|
before(() => fs.mkdirSync(HOST, { recursive: true }));
|
|
|
|
it("a non-admin owns no workspace outside their own home: no host pane for them", () => {
|
|
const lane = addLane(HOST, "boss");
|
|
assert.throws(() => auth.assignWorkspace(lane, "mia"), { code: "EOWNERFOLDER" });
|
|
assert.throws(() => auth.assignWorkspace(lane, "ana"), { code: "EOWNERFOLDER" });
|
|
// One that got there anyway (assigned before this rule, or a demoted admin's).
|
|
const theirs = addLane(path.join(HOST, "x"), "mia");
|
|
assert.throws(
|
|
() => pty.spawnRun({ laneId: theirs, cwd: path.join(HOST, "x"), tool: "claude" }),
|
|
{
|
|
code: "EOWNERFOLDER",
|
|
}
|
|
);
|
|
assert.throws(() => pty.spawnShell({ laneId: theirs, cwd: path.join(HOST, "x") }), {
|
|
code: "EOWNERFOLDER",
|
|
});
|
|
fs.mkdirSync(path.join(HOST, "y"), { recursive: true });
|
|
const viaRoute = addLane(path.join(HOST, "y"), "mia");
|
|
return call("mia", "POST", "/api/run", { laneId: viaRoute, cwd: path.join(HOST, "y") }).then(
|
|
(r) => {
|
|
assert.equal(r.status, 409, JSON.stringify(r.body));
|
|
assert.equal(r.body.error.code, "EOWNERFOLDER");
|
|
}
|
|
);
|
|
});
|
|
|
|
it("demoting or disabling a user ends their panes that run as the host", () => {
|
|
const lane = addLane(path.join(HOST, "z"), "boss");
|
|
const run = pty.runIdForLane(lane, 0);
|
|
liveRuns = new Set([run]);
|
|
tmuxCalls = [];
|
|
try {
|
|
auth.setRole("boss", "developer");
|
|
assert.ok(
|
|
tmuxCalls.some((a) => a[0] === "kill-session" && String(a.at(-1)).includes(run)),
|
|
"the host pane was killed"
|
|
);
|
|
} finally {
|
|
auth.setRole("boss", "admin");
|
|
liveRuns = new Set();
|
|
}
|
|
});
|
|
|
|
it("a pane's cwd must be its workspace's account's (POST /api/run names the cwd)", async () => {
|
|
const mine = addLane(path.join(MIA, "own"), "mia");
|
|
assert.throws(
|
|
() => pty.spawnRun({ laneId: mine, cwd: path.join(BOB, "private"), tool: "claude" }),
|
|
{ code: "ECWDACCOUNT" }
|
|
);
|
|
const host = addLane(path.join(HOST, "h"), "boss");
|
|
const r = await call("boss", "POST", "/api/run", { laneId: host, cwd: path.join(MIA, "proj") });
|
|
assert.equal(r.status, 409, JSON.stringify(r.body));
|
|
assert.equal(r.body.error.code, "ECWDACCOUNT");
|
|
});
|
|
});
|
|
|
|
describe("a pane token", () => {
|
|
const hookAuth = require("../lib/hook-auth");
|
|
const crypto = require("node:crypto");
|
|
|
|
it("is accepted only from the Linux account its pane runs as", async () => {
|
|
const lane = addLane(path.join(MIA, "pt"), "mia");
|
|
const run = `agenthub-lane-${lane}`;
|
|
const token = "ahpane_review5-token-value-0123456789";
|
|
db.prepare(
|
|
`INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
|
|
VALUES (?, 'interactive', ?, 'running', ?, ?)`
|
|
).run(run, path.join(MIA, "pt"), lane, crypto.createHash("sha256").update(token).digest("hex"));
|
|
tmux.__setAccountImpl(
|
|
(name) => runAs.accountForSession(name),
|
|
(acct, args) => fakeTmux(args)
|
|
);
|
|
liveRuns = new Set([run]);
|
|
const asPane = () =>
|
|
fetch(`${BASE}/api/sessions`, { headers: { "x-agenthub-pane-token": token } }).then(
|
|
(r) => r.status
|
|
);
|
|
try {
|
|
hookAuth.__setPeerUid(() => UID); // mia's uid in this test's passwd
|
|
assert.equal(await asPane(), 200);
|
|
hookAuth.__setPeerUid(() => UID + 7); // another local account
|
|
assert.equal(await asPane(), 401);
|
|
hookAuth.__setPeerUid(() => null); // a peer the kernel table does not show
|
|
assert.equal(await asPane(), 401);
|
|
// The AI Proxy gateway takes the same token: refused from another uid.
|
|
hookAuth.__setPeerUid(() => UID + 7);
|
|
const gw = await fetch(`${BASE}/api/proxy-gw/x/v1/messages`, {
|
|
method: "POST",
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
assert.equal(gw.status, 401);
|
|
} finally {
|
|
hookAuth.__setPeerUid(null);
|
|
liveRuns = new Set();
|
|
db.prepare("DELETE FROM dashboard_runs WHERE id = ?").run(run);
|
|
tmux.__reset();
|
|
tmux.__setExecImpl(fakeTmux);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("an analyst's GET that names a path", () => {
|
|
it("stays in their own folder or workspaces, like any non-admin's request", async () => {
|
|
fs.mkdirSync(path.join(USERS, "ana", "work"), { recursive: true });
|
|
const q = encodeURIComponent(path.join(BOB, "private"));
|
|
for (const p of [
|
|
`/api/lanes/browse?path=${q}`,
|
|
`/api/lanes/branches?repo=${q}`,
|
|
`/api/run/files?cwd=${q}`,
|
|
]) {
|
|
const r = await call("ana", "GET", p);
|
|
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
|
|
assert.equal(r.body.error.code, "EFOLDER");
|
|
}
|
|
const own = await call(
|
|
"ana",
|
|
"GET",
|
|
`/api/lanes/browse?path=${encodeURIComponent(path.join(USERS, "ana"))}`
|
|
);
|
|
assert.equal(own.status, 200, JSON.stringify(own.body));
|
|
assert.equal((await call("boss", "GET", `/api/lanes/browse?path=${q}`)).status, 200);
|
|
});
|
|
});
|
|
|
|
describe("a dropped file", () => {
|
|
const drop = (who, laneId) =>
|
|
fetch(`${BASE}/api/run/drop-file?name=a.txt${laneId ? `&laneId=${laneId}` : ""}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/octet-stream", cookie: cookies[who] },
|
|
body: Buffer.from("hello"),
|
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
|
|
|
it("goes where the workspace's panes can read it, not to the requester's account", async () => {
|
|
fs.mkdirSync(path.join(MIA, ".agenthub", "handoff"), { recursive: true });
|
|
const miaLane = addLane(path.join(MIA, "drop"), "mia");
|
|
const byAdmin = await drop("boss", miaLane);
|
|
assert.equal(byAdmin.status, 200, JSON.stringify(byAdmin.body));
|
|
assert.ok(
|
|
byAdmin.body.path.startsWith(path.join(MIA, ".agenthub", "handoff", "drops") + path.sep),
|
|
byAdmin.body.path
|
|
);
|
|
// A host workspace's pane runs as the host: the host's own 0700 drops dir.
|
|
const hostLane = addLane(path.join(TMP, "hostdir", "d"), "boss");
|
|
const host = await drop("boss", hostLane);
|
|
assert.equal(host.status, 200, JSON.stringify(host.body));
|
|
assert.equal(path.dirname(host.body.path), path.join(DATA, "drops"));
|
|
// …and the admin's chat shows it back, though the GET names no workspace.
|
|
fs.mkdirSync(path.join(USERS, "boss", ".agenthub", "handoff"), { recursive: true });
|
|
const shown = await fetch(`${BASE}/api/run/drop/${path.basename(host.body.path)}`, {
|
|
headers: { cookie: cookies.boss },
|
|
});
|
|
assert.equal(shown.status, 200);
|
|
assert.equal(await shown.text(), "hello");
|
|
// Another member's workspace is not one to name.
|
|
assert.equal((await drop("bob", miaLane)).status, 404);
|
|
});
|
|
});
|
|
|
|
describe("an image a session read (the Chat pane's Files read preview)", () => {
|
|
const readBy = (sid, cwd, file) => {
|
|
db.prepare("INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)").run(sid, cwd);
|
|
db.prepare(
|
|
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PostToolUse', 'Read', ?)"
|
|
).run(sid, JSON.stringify({ tool_input: { file_path: file } }));
|
|
};
|
|
const get = (sid, file) =>
|
|
fetch(`${BASE}/api/sessions/${sid}/image?path=${encodeURIComponent(file)}`, {
|
|
headers: { cookie: cookies.boss },
|
|
});
|
|
|
|
it("is served for the host's own session and a member's file in that member's home", async () => {
|
|
const hostPng = path.join(TMP, "hostdir", "shot.png");
|
|
const miaPng = path.join(MIA, "proj", "shot.png");
|
|
fs.mkdirSync(path.dirname(hostPng), { recursive: true });
|
|
fs.writeFileSync(hostPng, "HOSTPNG");
|
|
fs.writeFileSync(miaPng, "MIAPNG");
|
|
readBy("img-host", path.join(TMP, "hostdir"), hostPng);
|
|
readBy("img-mia", path.join(MIA, "proj"), miaPng);
|
|
const host = await get("img-host", hostPng);
|
|
assert.equal(host.status, 200);
|
|
assert.equal(await host.text(), "HOSTPNG");
|
|
const mia = await get("img-mia", miaPng);
|
|
assert.equal(mia.status, 200);
|
|
assert.equal(await mia.text(), "MIAPNG");
|
|
});
|
|
|
|
it("is refused when a member's session names a file outside that member's home", async () => {
|
|
readBy("img-mia-out", path.join(MIA, "proj"), path.join(TMP, "hostdir", "shot.png"));
|
|
readBy("img-mia-bob", path.join(MIA, "proj"), path.join(BOB, "b.png"));
|
|
fs.writeFileSync(path.join(BOB, "b.png"), "BOBPNG");
|
|
for (const f of [path.join(TMP, "hostdir", "shot.png"), path.join(BOB, "b.png")]) {
|
|
const r = await get(f.includes("bob") ? "img-mia-bob" : "img-mia-out", f);
|
|
assert.equal(r.status, 404, f);
|
|
assert.ok(!/PNG/.test(await r.text()));
|
|
}
|
|
});
|
|
});
|