feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+10 -10
View File
@@ -59,7 +59,7 @@ describe("/api/ai-tools", () => {
assert.equal(agy.path, path.join(BIN, "agy"));
assert.equal(agy.resume, true);
assert.equal(agy.bypass, true);
assert.equal(body.tools.find((t) => t.id === "gemini").installed, false);
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
assert.equal("args" in agy, false);
});
@@ -76,7 +76,7 @@ describe("/api/ai-tools", () => {
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
const bad = await request("PUT", "/api/ai-tools/gemini", { default: true });
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADDEFAULT");
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
@@ -112,10 +112,10 @@ describe("/api/ai-tools", () => {
});
it("refresh re-detects", async () => {
fs.writeFileSync(path.join(BIN, "gemini"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "gemini"), 0o755);
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
const r = await request("POST", "/api/ai-tools/refresh");
assert.equal(r.body.tools.find((t) => t.id === "gemini").installed, true);
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
});
it("/api/run/binary resolves through the catalog", async () => {
@@ -132,9 +132,9 @@ describe("lanes on catalog tools", () => {
fs.mkdirSync(d, { recursive: true });
return d;
};
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "gemini");
assert.equal(r.body.lane.tool, "opencode");
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
@@ -212,8 +212,8 @@ describe("install / update / icons", () => {
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
assert.equal(notInstalled.status, 409);
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
// gemini was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
// opencode was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
@@ -241,7 +241,7 @@ describe("install / update / icons", () => {
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
const v = await request("GET", "/api/ai-tools/versions");
assert.equal(v.status, 200);
assert.equal(v.body.gemini.npmManaged, false);
assert.equal(v.body.opencode.npmManaged, false);
});
it("update-all answers ENOTHING when nothing has an update", async () => {
+13
View File
@@ -82,6 +82,19 @@ describe("commit-message", () => {
assert.doesNotMatch(input, /staged line/);
});
it("with paths, reads exactly those files' working-tree changes, untracked included", async () => {
write("a.txt", "one\nticked edit\n");
write("new.txt", "brand new\n");
write("other.txt", "not ticked\n");
write(".claude/settings.local.json", '{"env":{"KEY":"sk-ticked"}}\n');
const input = await buildInput(REPO, ["a.txt", "new.txt", ".claude/settings.local.json"]);
assert.match(input, /ticked edit/);
assert.match(input, /brand new/);
assert.doesNotMatch(input, /not ticked|staged line|sk-ticked|settings\.local/);
await assert.rejects(buildInput(REPO, ["nope.txt"]), { code: "EBADPATH" });
await assert.rejects(buildInput(REPO, []), { code: "ENOSTAGED" });
});
it("reports a failing or silent CLI as EAI", async () => {
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
code: "EAI",
@@ -0,0 +1,301 @@
/**
* @file The Changes panel's JetBrains-style commit against a REAL repo:
* `commit`/`amend` with `paths` take exactly the ticked files' working-tree
* content (an untracked one included) and leave every other change — staged
* or not — where it was; a failed commit puts a ticked untracked file back
* out of the index. And `ignore` (Add to .gitignore): an anchored, escaped
* line in the workspace's own .gitignore, only for an untracked path, never
* twice, never through a symlink. Then the hunk-level commit (`hunks`: only
* the ticked change blocks, through a temporary index, the real index synced
* afterwards and the working tree untouched), `blameFile` (Annotate) and a
* file's history with its parents rewritten so the graph joins up.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction } = require("../lib/git-ops");
const { blameFile, listCommits } = require("../lib/git-diff");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commit-paths-"));
const REPO = path.join(ROOT, "repo");
const g = (...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
};
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
const status = () => g("status", "--porcelain=v1", "--untracked-files=all");
const inHead = () => g("ls-tree", "-r", "--name-only", "HEAD").split("\n").filter(Boolean);
beforeEach(() => {
fs.rmSync(REPO, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
write("a.txt", "a\n");
write("b.txt", "b\n");
write("c.txt", "c\n");
g("add", "-A");
g("commit", "-qm", "init");
});
describe("commit with paths", () => {
it("commits only the ticked files, untracked included, and leaves the rest", async () => {
write("a.txt", "a2\n"); // ticked, unstaged
write("b.txt", "b2\n");
g("add", "b.txt"); // staged but NOT ticked: must stay staged, uncommitted
write("c.txt", "c2\n"); // not ticked
write("new.txt", "n\n"); // ticked, untracked
await runGitAction(REPO, "commit", { message: "pick", paths: ["a.txt", "new.txt"] });
assert.equal(g("log", "-1", "--format=%s").trim(), "pick");
assert.deepEqual(
g("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean).sort(),
["a.txt", "new.txt"]
);
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
assert.match(status(), /^M {2}b\.txt$/m);
assert.match(status(), /^ M c\.txt$/m);
});
it("commits a partly staged file's working-tree content, a deletion too", async () => {
write("a.txt", "staged\n");
g("add", "a.txt");
write("a.txt", "staged\nand more\n");
fs.rmSync(path.join(REPO, "b.txt"));
await runGitAction(REPO, "commit", { message: "m", paths: ["a.txt", "b.txt"] });
assert.equal(g("show", "HEAD:a.txt"), "staged\nand more\n");
assert.ok(!inHead().includes("b.txt"));
assert.equal(status(), "");
});
it("refuses an empty list and a path git does not report changed", async () => {
write("a.txt", "a2\n");
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: [] }), {
code: "EBADPATH",
});
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["b.txt"] }), {
code: "EBADPATH",
});
});
it("puts a ticked untracked file back out of the index when the commit fails", async () => {
write("new.txt", "n\n");
const hook = path.join(REPO, ".git", "hooks", "pre-commit");
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["new.txt"] }));
assert.match(status(), /^\?\? new\.txt$/m);
});
});
describe("amend with paths", () => {
it("an empty list rewords only, leaving the staged change out", async () => {
write("a.txt", "a2\n");
g("add", "a.txt");
const head = g("rev-parse", "HEAD").trim();
await runGitAction(REPO, "amend", { message: "reworded", sha: head, paths: [] });
assert.equal(g("log", "-1", "--format=%s").trim(), "reworded");
assert.equal(g("show", "HEAD:a.txt"), "a\n");
assert.match(status(), /^M {2}a\.txt$/m);
});
it("folds the ticked files in, and refuses an empty list with no message", async () => {
write("new.txt", "n\n");
const head = g("rev-parse", "HEAD").trim();
await assert.rejects(runGitAction(REPO, "amend", { sha: head, paths: [] }), {
code: "ENOCHANGES",
});
await runGitAction(REPO, "amend", { sha: head, paths: ["new.txt"] });
assert.equal(g("log", "-1", "--format=%s").trim(), "init");
assert.ok(inHead().includes("new.txt"));
assert.equal(g("rev-list", "--count", "HEAD").trim(), "1");
});
});
describe("ignore", () => {
it("appends an anchored, escaped line once, keeping the file's last line", async () => {
write(".gitignore", "node_modules");
write("debug*.log", "x\n");
write("note.md", "x\n");
await runGitAction(REPO, "ignore", { paths: ["debug*.log", "note.md"] });
assert.equal(read(".gitignore"), "node_modules\n/debug\\*.log\n/note.md\n");
assert.doesNotMatch(status(), /debug|note/);
});
it("creates the file when there is none, in a subdirectory too", async () => {
fs.mkdirSync(path.join(REPO, "dir"));
write("dir/x y.tmp", "x\n");
await runGitAction(REPO, "ignore", { path: "dir/x y.tmp" });
assert.equal(read(".gitignore"), "/dir/x y.tmp\n");
assert.doesNotMatch(status(), /x y\.tmp/);
});
it("refuses a tracked file and a .gitignore that is a symlink", async () => {
write("a.txt", "a2\n");
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["a.txt"] }), { code: "ETRACKED" });
write("new.txt", "n\n");
const outside = path.join(ROOT, "outside-ignore");
fs.writeFileSync(outside, "");
fs.symlinkSync(outside, path.join(REPO, ".gitignore"));
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["new.txt"] }), {
code: "EBADPATH",
});
assert.equal(fs.readFileSync(outside, "utf8"), "");
});
});
/** The `@@` lines of `file`'s HEAD → working tree diff at -U3, in order. */
const headers = (file) =>
g("diff", "-U3", "HEAD", "--", file)
.split("\n")
.filter((l) => l.startsWith("@@ "));
describe("commit with hunks", () => {
beforeEach(() => {
write("long.txt", `${Array.from({ length: 30 }, (_, i) => i + 1).join("\n")}\n`);
g("add", "long.txt");
g("commit", "-qm", "long");
});
const edit = (pairs) => {
let text = read("long.txt");
for (const [from, to] of pairs) text = text.replace(new RegExp(`^${from}$`, "m"), to);
write("long.txt", text);
};
it("commits only the ticked hunk, leaving the rest in the working tree, unstaged", async () => {
edit([
["3", "three"],
["25", "twentyfive"],
]);
const [first] = headers("long.txt");
await runGitAction(REPO, "commit", {
message: "part",
context: 3,
hunks: { "long.txt": [{ hunk: first, blocks: [0] }] },
});
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^three$/m);
assert.match(committed, /^25$/m);
assert.match(read("long.txt"), /^twentyfive$/m);
// The index followed the commit: the rest is an unstaged change only.
assert.match(status(), /^ M long\.txt$/m);
});
it("takes one block of a hunk that holds two, with whole files beside it", async () => {
edit([
["3", "three"],
["5", "five"],
]);
write("a.txt", "a2\n");
const [only] = headers("long.txt");
await runGitAction(REPO, "commit", {
message: "block",
context: 3,
paths: ["a.txt"],
hunks: { "long.txt": [{ hunk: only, blocks: [1] }] },
});
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^3$/m);
assert.match(committed, /^five$/m);
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
assert.match(status(), /^ M long\.txt$/m);
assert.doesNotMatch(status(), /a\.txt/);
});
it("refuses a hunk no longer in the diff, an untracked file, and leaves the index alone", async () => {
edit([["3", "three"]]);
write("new.txt", "n\n");
g("add", "b.txt");
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "long.txt": [{ hunk: "@@ -1,1 +1,1 @@", blocks: [0] }] },
}),
{ code: "EHUNKGONE" }
);
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "new.txt": [{ hunk: "@@ -0,0 +1 @@", blocks: [0] }] },
}),
{ code: "EUNTRACKED" }
);
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "long.txt": [{ hunk: headers("long.txt")[0], blocks: ["x"] }] },
}),
{ code: "EBADPATH" }
);
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
assert.match(status(), /^ M long\.txt$/m);
});
it("amends with only the ticked hunk", async () => {
edit([
["3", "three"],
["25", "twentyfive"],
]);
const head = g("rev-parse", "HEAD").trim();
const [, second] = headers("long.txt");
await runGitAction(REPO, "amend", {
sha: head,
context: 3,
paths: [],
hunks: { "long.txt": [{ hunk: second, blocks: [0] }] },
});
assert.equal(g("log", "-1", "--format=%s").trim(), "long");
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^twentyfive$/m);
assert.match(committed, /^3$/m);
});
});
describe("blameFile", () => {
it("names each line's commit, and none for a line not committed yet", async () => {
write("a.txt", "a\nnew line\n");
const blame = await blameFile(REPO, "a.txt");
assert.equal(blame.lines.length, 2);
const first = blame.commits[blame.lines[0]];
assert.equal(first.summary, "init");
assert.equal(first.author, "t");
assert.equal(blame.lines[1], null);
});
it("refuses a path outside the lane and a secret file", async () => {
await assert.rejects(blameFile(REPO, "../x"), { code: "EBADPATH" });
fs.mkdirSync(path.join(REPO, ".claude"));
write(".claude/settings.local.json", "{}\n");
g("add", "-f", ".claude/settings.local.json");
g("commit", "-qm", "secret");
await assert.rejects(blameFile(REPO, ".claude/settings.local.json"), { code: "ESECRET" });
});
});
describe("a file's history", () => {
it("rewrites parents to the file's own commits, so the graph joins up", async () => {
write("a.txt", "a2\n");
g("commit", "-qam", "a2");
write("b.txt", "b2\n");
g("commit", "-qam", "b only");
write("a.txt", "a3\n");
g("commit", "-qam", "a3");
const commits = await listCommits(REPO, { file: "a.txt" });
assert.deepEqual(
commits.map((c) => c.subject),
["a3", "a2", "init"]
);
assert.deepEqual(commits[0].parents, [commits[1].sha]);
});
});
+9
View File
@@ -141,3 +141,12 @@ describe("install-hooks host-only guard (#193)", () => {
assert.equal(isInsideContainer(), false);
});
});
describe("HOOK_TYPES", () => {
it("is the event list the plugin ships, so the hook status checks every one", () => {
const exported = require("../../scripts/install-hooks").HOOK_TYPES;
const plugin = require("../../.claude-plugin/plugin.json");
assert.deepEqual([...exported].sort(), Object.keys(plugin.hooks).sort());
assert.deepEqual([...exported].sort(), [...HOOK_TYPES].sort());
});
});
+10
View File
@@ -146,6 +146,16 @@ describe("importing a .codex-pet bundle", () => {
assert.equal(r.status, 201);
});
it("accepts a v2 sheet (1536×2288, the 16 gaze poses in rows 9–10)", async () => {
const r = await request("POST", "/api/pets/import", {
body: bundle({
"pet.json": { spriteVersionNumber: 2 },
"spritesheet.webp": webp(1536, 2288),
}),
});
assert.equal(r.status, 201);
});
it("refuses a bundle missing a file, a sheet of the wrong size, and a non-zip", async () => {
const missing = await request("POST", "/api/pets/import", { body: bundle({ "pet.json": {} }) });
assert.equal(missing.status, 400);
+31
View File
@@ -205,6 +205,37 @@ describe("fast path", () => {
});
});
describe("fast path after a Quit (issue #65)", () => {
beforeEach(resetRuntime);
it("starts nothing while the stopped flag is set, and starts again once it is gone", () => {
const pkg = require("../../package.json");
const { markQuit, clearQuit } = require("../lib/quit");
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME; // the real unit may name another root: stale
try {
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
boot.writeState(
{
pluginVersion: pkg.version,
depsHash: boot.depsHash(),
pluginRoot: path.resolve(__dirname, "..", ".."),
},
RT
);
// No server is live: without the flag the worker would start one.
assert.equal(boot.fastPathOk(RT), false);
markQuit(RT);
assert.equal(boot.fastPathOk(RT), true);
clearQuit(RT);
assert.equal(boot.fastPathOk(RT), false);
} finally {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
}
});
});
describe("bootstrap lock", () => {
beforeEach(resetRuntime);
+71 -1
View File
@@ -1,7 +1,8 @@
/**
* @file Unit tests for the enhanced pricing calculator and the shared token-usage
* normalizer: 5m/1h cache-write split, server-tool surcharges, and the per-bucket
* pricing modifiers (fast mode, US data residency, Batch API).
* pricing modifiers (fast mode, US data residency, Batch API), and the
* long-context subsets priced at a rule's long_* rates.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -363,3 +364,72 @@ describe("DEFAULT_PRICING — Opus 5.5", () => {
assert.equal(r.total_cost.toFixed(2), "2.72");
});
});
describe("long-context tier (prompt > 100k tokens)", () => {
// Haiku 5.5: every rate x5 above a 100k-token prompt.
const HAIKU = [
{
model_pattern: "claude-haiku-5-5%",
display_name: "Claude Haiku 5.5",
input_per_mtok: 0.1,
output_per_mtok: 0.5,
cache_read_per_mtok: 0.01,
cache_write_per_mtok: 0.125,
cache_write_1h_per_mtok: 0.2,
long_input_per_mtok: 0.5,
long_output_per_mtok: 2.5,
},
];
const haiku = (extra) => bucket({ model: "claude-haiku-5-5", ...extra });
it("counts a request into the long subsets by its whole prompt: input + cache read + cache write", () => {
const short = extractUsageFields({ input_tokens: 100_000, output_tokens: 5 });
assert.equal(short.longInput, 0);
const long = extractUsageFields({
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 90_000,
cache_creation_input_tokens: 10_000,
cache_creation: { ephemeral_5m_input_tokens: 4_000, ephemeral_1h_input_tokens: 6_000 },
});
assert.deepEqual(
[
long.longInput,
long.longOutput,
long.longCacheRead,
long.longCacheWrite,
long.longCacheWrite1h,
],
[10, 5, 90_000, 10_000, 6_000]
);
});
it("prices the long subset at the long rates, cache rates scaled by the input ratio", () => {
const all = {
input_tokens: M,
output_tokens: M,
cache_read_tokens: M,
cache_write_tokens: 2 * M,
cache_write_1h_tokens: M,
};
const long = Object.fromEntries(Object.entries(all).map(([k, v]) => [`long_${k}`, v]));
// 0.5 + 2.5 + 0.05 + 0.625 + 1.0
assert.equal(calculateCost([haiku({ ...all, ...long })], HAIKU).total_cost, 4.675);
});
it("prices only the long part of a bucket at the long rates", () => {
const r = calculateCost([haiku({ input_tokens: 2 * M, long_input_tokens: M })], HAIKU);
assert.equal(r.total_cost, 0.6);
assert.equal(r.breakdown[0].long_input_tokens, M);
});
it("never prices more long tokens than the bucket holds", () => {
const r = calculateCost([haiku({ input_tokens: M, long_input_tokens: 3 * M })], HAIKU);
assert.equal(r.total_cost, 0.5);
});
it("prices a long subset at the standard rates when the rule has no long rates", () => {
const r = calculateCost([bucket({ input_tokens: M, long_input_tokens: M })], RULES);
assert.equal(r.total_cost, 5);
});
});
+33 -1
View File
@@ -4,7 +4,8 @@
* before it is used, the sync's own rows follow a rate change while a user's
* row and a deleted pattern are never touched, a bad list or a failed fetch
* changes nothing, and an OpenAI model (a Codex session's) is priced from its
* `openai` entry too.
* `openai` entry too, and a Claude entry's above-100k rates fill the long_*
* columns.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -428,3 +429,34 @@ describe("pricing sync", () => {
}
});
});
describe("long-context rates", () => {
it("takes Anthropic's above-100k input/output rates into the long columns", () => {
const e = {
...LIST["claude-opus-9-9"],
input_cost_per_token_above_100k_tokens: 0.00002,
output_cost_per_token_above_100k_tokens: 0.0001,
};
const r = toRow("claude-opus-9-9", e);
assert.equal(r.longIn, 20);
assert.equal(r.longOut, 100);
assert.equal(toRow("claude-opus-9-9", LIST["claude-opus-9-9"]).longIn, 0);
});
it("writes them, and fills them into a sync row that predates them", async () => {
const e = {
...LIST["claude-opus-9-9"],
input_cost_per_token_above_100k_tokens: 0.00002,
output_cost_per_token_above_100k_tokens: 0.0001,
};
useModel("claude-opus-9-9");
await syncPricing(db, {
fetchList: async () => ({ "claude-opus-9-9": LIST["claude-opus-9-9"] }),
});
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 0);
_resetCache();
await syncPricing(db, { fetchList: async () => ({ "claude-opus-9-9": e }) });
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 20);
assert.equal(row("claude-opus-9-9%").long_output_per_mtok, 100);
});
});
+3 -7
View File
@@ -651,7 +651,7 @@ describe("pty-run", () => {
let savedPath;
before(() => {
fs.mkdirSync(BIN, { recursive: true });
for (const name of ["agy", "gemini", "grok", "goose", "command-code"]) {
for (const name of ["agy", "grok", "goose", "command-code"]) {
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755);
}
@@ -734,15 +734,11 @@ describe("pty-run", () => {
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
)
);
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed.
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("gemini", { bypass: false }), [
at("gemini"),
"--prompt-interactive",
" -x y",
]);
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
});
it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => {
+131
View File
@@ -0,0 +1,131 @@
/**
* @file Tests Quit AgentHub (issue #65, server/lib/quit.js and
* POST /api/settings/quit): the stopped flag, stopping through the service
* manager when a unit of ours runs the server (a SIGTERM would be restarted),
* and the route killing every lane pane before it stops. tmux runs only in the
* isolated server `npm run test:server` sets up.
* @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 fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-quit-"));
process.env.DASHBOARD_DATA_DIR = TMP;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const quit = require("../lib/quit");
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
describe("stopped flag", () => {
it("is set by markQuit and cleared by clearQuit, in the data dir's runtime", () => {
assert.equal(quit.flagPath(), path.join(TMP, "runtime", "stopped"));
assert.equal(quit.isQuit(), false);
quit.markQuit();
assert.equal(quit.isQuit(), true);
quit.clearQuit();
assert.equal(quit.isQuit(), false);
quit.clearQuit(); // already gone: fine
});
});
describe("stopServer", () => {
const fakeSvc = (kind, active) => ({
kind,
systemctlAvailable: () => true,
unitIsOurs: () => true,
unitIsActive: () => active,
});
const spawned = [];
const spawn = (cmd, args) => {
spawned.push([cmd, ...args].join(" "));
return { unref() {} };
};
it("SIGTERMs itself when no unit of ours is running", () => {
let killed = 0;
quit.stopServer({ svc: fakeSvc("systemd", false), spawn, kill: () => killed++ });
assert.equal(killed, 1);
assert.deepEqual(spawned, []);
});
it("stops a running unit through systemd, never by a signal Restart=always undoes", () => {
let killed = 0;
quit.stopServer({ svc: fakeSvc("systemd", true), spawn, kill: () => killed++ });
assert.equal(killed, 0);
assert.deepEqual(spawned.splice(0), ["systemctl --user stop --no-block agenthub.service"]);
});
it("boots a running LaunchAgent out", { skip: process.platform === "win32" }, () => {
quit.stopServer({ svc: fakeSvc("launchd", true), spawn, kill: () => {} });
assert.match(spawned.splice(0)[0], /^launchctl bootout gui\/\d+\/io\.smartgift\.agenthub$/);
});
});
// Only inside the runner's private tmux server: never the developer's own.
const isolatedTmux = !!process.env.TMUX_TMPDIR && !process.env.TMUX;
let hasTmux = false;
try {
execFileSync("tmux", ["-V"], { stdio: "ignore" });
hasTmux = true;
} catch {
/* not installed */
}
describe(
"POST /api/settings/quit",
{ skip: !(isolatedTmux && hasTmux) && "needs the runner's isolated tmux" },
() => {
let server;
let base;
let stops = 0;
const realStop = quit.stopServer;
before(async () => {
quit.stopServer = () => stops++; // this process is the test runner
const { createApp, startServer } = require("../index");
server = await startServer(createApp(), 0);
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
quit.stopServer = realStop;
server?.close();
require("../db").db.close();
});
it("closes every lane pane, sets the flag, then stops the server", async () => {
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001", "sleep 60"]);
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001-2", "sleep 60"]);
execFileSync("tmux", ["new-session", "-d", "-s", "not-ours", "sleep 60"]);
try {
const body = await new Promise((resolve, reject) => {
const req = http.request(`${base}/api/settings/quit`, { method: "POST" }, (res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve(JSON.parse(text)));
});
req.on("error", reject);
req.end();
});
assert.deepEqual(body, { ok: true, killed: 2 });
assert.equal(quit.isQuit(), true);
const left = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
encoding: "utf8",
});
assert.deepEqual(left.trim().split("\n"), ["not-ours"]);
await new Promise((r) => setImmediate(r));
assert.equal(stops, 1);
} finally {
execFileSync("tmux", ["kill-session", "-t", "not-ours"]);
}
});
}
);
+375 -3
View File
@@ -4,7 +4,11 @@
* reset since Codex last wrote reading as empty, and the Codex login's
* account + expiry read from `auth.json`; and a saved Claude profile asked
* with its own token only while it is valid, never refreshed, and never shown
* with the usage of a token that belongs to a different account.
* with the usage of a token that belongs to a different account; each
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
* while valid, never refreshed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -12,6 +16,19 @@ const { test } = require("node:test");
const assert = require("node:assert");
const { codexWindow, windowLabel } = require("../lib/quota");
// No test may read (or send) the machine's real Cursor login.
process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
require("node:path").join(require("node:os").tmpdir(), "quota-xdg-")
);
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
process.env.AGENTHUB_AGY_USAGE = "0";
// Nor its real Grok, Kimi or OpenCode logins.
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
process.env[k] = require("node:fs").mkdtempSync(
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
);
}
test("windowLabel names the plan windows", () => {
assert.strictEqual(windowLabel(300), "5h");
assert.strictEqual(windowLabel(10080), "wk");
@@ -47,11 +64,18 @@ test("codexAccount reads the email and access-token expiry, never a token", () =
fs.writeFileSync(
path.join(home, "auth.json"),
JSON.stringify({
tokens: { id_token: jwt({ email: "a@b.c" }), access_token: jwt({ exp: 1_800_000_000 }) },
tokens: {
id_token: jwt({
email: "a@b.c",
"https://api.openai.com/auth": { chatgpt_plan_type: "plus" },
}),
access_token: jwt({ exp: 1_800_000_000 }),
},
})
);
assert.deepStrictEqual(codexAccount(), {
account: "a@b.c",
plan: "plus",
expiresAt: new Date(1_800_000_000_000).toISOString(),
});
} finally {
@@ -177,7 +201,13 @@ test("the live row is the account its token belongs to, not the one .claude.json
// Thắng's identity, Victor's token.
fs.writeFileSync(
path.join(home, ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
JSON.stringify({
claudeAiOauth: {
accessToken: "live-v",
expiresAt: Date.now() + 3600_000,
subscriptionType: "max",
},
})
);
fs.writeFileSync(
`${home}.json`,
@@ -200,6 +230,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
assert.strictEqual(live.label, "Victor");
assert.strictEqual(live.account, "victor@x.y");
assert.strictEqual(live.ownerUuid, undefined);
assert.strictEqual(live.plan, "max");
assert.deepStrictEqual(
claude.filter((t) => !t.active).map((t) => t.label),
["Thắng"]
@@ -212,3 +243,344 @@ test("the live row is the account its token belongs to, not the one .claude.json
fs.rmSync(root, { recursive: true, force: true });
}
});
test("Cursor: the CLI's own login asks cursor.com for its plan + cycle usage, never past expiry", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-cursor-"));
const auth = path.join(root, "config", "cursor", "auth.json");
fs.mkdirSync(path.dirname(auth), { recursive: true });
fs.mkdirSync(path.join(root, "cursor"));
fs.writeFileSync(
path.join(root, "cursor", "cli-config.json"),
JSON.stringify({ authInfo: { email: "c@d.e", authId: "auth0|user_1" } })
);
const prev = {
xdg: process.env.XDG_CONFIG_HOME,
dir: process.env.AGENTHUB_CURSOR_DIR,
fetch: global.fetch,
};
process.env.XDG_CONFIG_HOME = path.join(root, "config");
process.env.AGENTHUB_CURSOR_DIR = path.join(root, "cursor");
const asked = [];
global.fetch = async (url, opts) => {
if (!url.includes("cursor.com")) return { ok: false, status: 401, json: async () => ({}) };
asked.push(`${url} ${opts.headers.Cookie}`);
return {
ok: true,
status: 200,
json: async () => ({
billingCycleEnd: "2099-01-01T00:00:00.000Z",
membershipType: "pro",
individualUsage: { plan: { totalPercentUsed: 9.5 } },
}),
};
};
const cursor = async () => (await quotas()).tools.find((t) => t.id === "cursor");
try {
__resetCache();
assert.strictEqual(await cursor(), undefined); // not logged in: no row
const token = jwt({ sub: "auth0|user_1", exp: Math.floor(Date.now() / 1000) + 3600 });
fs.writeFileSync(auth, JSON.stringify({ accessToken: token, refreshToken: "r" }));
__resetCache();
assert.deepStrictEqual(await cursor(), {
id: "cursor",
active: true,
account: "c@d.e",
plan: "pro",
expiresAt: new Date(
JSON.parse(Buffer.from(token.split(".")[1], "base64url")).exp * 1000
).toISOString(),
windows: [{ label: "mo", usedPercent: 9.5, resetsAt: "2099-01-01T00:00:00.000Z" }],
});
assert.deepStrictEqual(asked, [
`https://cursor.com/api/usage-summary WorkosCursorSessionToken=user_1::${token}`,
]);
fs.writeFileSync(auth, JSON.stringify({ accessToken: jwt({ sub: "auth0|user_1", exp: 1 }) }));
__resetCache();
assert.strictEqual((await cursor()).error, "ETOKENEXPIRED");
assert.strictEqual(asked.length, 1);
} finally {
for (const [k, v] of [
["XDG_CONFIG_HOME", prev.xdg],
["AGENTHUB_CURSOR_DIR", prev.dir],
]) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(root, { recursive: true, force: true });
}
});
test("Antigravity: `agy --print /usage` in an empty dir, one row per pool window, never on an old agy", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const aiTools = require("../lib/ai-tools");
const { quotas, __resetCache } = require("../lib/quota");
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "quota-agy-"));
const log = path.join(bin, "calls");
const version = path.join(bin, "version");
fs.writeFileSync(version, "1.2.17");
const bucket = (id, window, rf) => ({
id,
window,
remaining_fraction: rf,
reset_time: "2099-01-01T00:00:00Z",
});
fs.writeFileSync(
path.join(bin, "out.json"),
JSON.stringify({
status: "SUCCESS",
num_turns: 0,
command: {
name: "usage",
data: {
groups: [
{
name: "Gemini Models",
buckets: [bucket("g-w", "weekly", 0.75), bucket("g-5", "5h", 1)],
},
{ name: "Claude and GPT models", buckets: [bucket("c-5", "5h", 0.5)] },
],
},
},
})
);
fs.writeFileSync(
path.join(bin, "agy"),
`#!/bin/sh\necho "$PWD $*" >> '${log}'\n[ "$1" = --version ] && { cat '${version}'; exit 0; }\ncat '${path.join(bin, "out.json")}'\n`,
{ mode: 0o755 }
);
const prevPath = process.env.PATH;
process.env.PATH = `${bin}:${prevPath}`;
delete process.env.AGENTHUB_AGY_USAGE;
const agy = async () => (await quotas()).tools.find((t) => t.id === "antigravity");
try {
aiTools.refresh();
__resetCache();
const row = await agy();
assert.strictEqual(row.active, true);
assert.deepStrictEqual(row.windows, [
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: "2099-01-01T00:00:00.000Z" },
{
group: "Gemini Models",
label: "wk",
usedPercent: 25,
resetsAt: "2099-01-01T00:00:00.000Z",
},
{
group: "Claude and GPT models",
label: "5h",
usedPercent: 50,
resetsAt: "2099-01-01T00:00:00.000Z",
},
]);
const calls = fs.readFileSync(log, "utf8").trim().split("\n");
assert.strictEqual(calls[0].split(" ").slice(1).join(" "), "--version");
const [cwd, ...args] = calls[1].split(" ");
assert.deepStrictEqual(args, ["--print", "/usage", "--output-format", "json"]);
assert.notStrictEqual(cwd, process.cwd());
assert.strictEqual(fs.existsSync(cwd), false); // the private dir is gone again
// An agy older than 1.1.11 may bill /usage as a model request: never asked.
fs.writeFileSync(version, "1.1.10");
fs.writeFileSync(log, "");
__resetCache();
assert.strictEqual(await agy(), undefined);
assert.match(fs.readFileSync(log, "utf8"), /^\S+ --version\n$/);
} finally {
process.env.PATH = prevPath;
process.env.AGENTHUB_AGY_USAGE = "0";
aiTools.refresh();
__resetCache();
fs.rmSync(bin, { recursive: true, force: true });
}
});
/** A fetch stub answering per host, recording every `url auth` it was asked. */
function stubFetch(answers) {
const asked = [];
const prev = global.fetch;
global.fetch = async (url, opts = {}) => {
asked.push(`${url} ${opts.headers?.Authorization || ""}`.trim());
const hit = Object.entries(answers).find(([k]) => url.startsWith(k));
if (!hit) return { ok: false, status: 404, json: async () => ({}) };
const [status, body] = hit[1];
return { ok: status < 300, status, json: async () => body };
};
return { asked, restore: () => (global.fetch = prev) };
}
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const auth = path.join(process.env.GROK_HOME, "auth.json");
const f = stubFetch({
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
200,
{
config: {
creditUsagePercent: 12,
currentPeriod: { end: "2099-01-08T00:00:00Z" },
subscriptionTier: "SuperGrok",
monthlyLimit: { val: "200" },
used: { val: "50" },
billingPeriodEnd: "2099-02-01T00:00:00Z",
},
},
],
});
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
try {
__resetCache();
assert.strictEqual(await grok(), undefined);
const later = new Date(Date.now() + 3600_000).toISOString();
fs.writeFileSync(
auth,
JSON.stringify({
"https://other.example": { key: "tok-other", expires_at: later },
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
})
);
__resetCache();
assert.deepStrictEqual(await grok(), {
id: "grok",
active: true,
account: "g@x.ai",
plan: "SuperGrok",
expiresAt: later,
windows: [
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("grok.com")),
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
);
fs.writeFileSync(
auth,
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
);
__resetCache();
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
} finally {
f.restore();
fs.rmSync(auth, { force: true });
__resetCache();
}
});
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "kimi-code.json");
const f = stubFetch({
"https://api.kimi.com/coding/v1/usages": [
200,
{
usages: {
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
},
},
],
});
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
try {
__resetCache();
assert.strictEqual(await kimi(), undefined);
const exp = Math.floor(Date.now() / 1000) + 600;
fs.writeFileSync(
file,
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
);
__resetCache();
const row = await kimi();
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
assert.deepStrictEqual(
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
[
["5h", 30],
["wk", 20],
["mo", 40],
]
);
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("kimi")),
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
);
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
__resetCache();
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "auth.json");
const f = stubFetch({
"https://opencode.ai/zen/go/v1/usage": [
200,
{
usage: {
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
},
},
],
});
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
try {
__resetCache();
// A login with no Go key (Copilot through opencode, say) is no row.
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
assert.strictEqual(await go(), undefined);
fs.writeFileSync(
file,
JSON.stringify({
"opencode-go": { type: "api", key: "sk-go" },
other: { type: "api", key: "x" },
})
);
__resetCache();
assert.deepStrictEqual(await go(), {
id: "opencode",
active: true,
plan: "Go",
windows: [
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("opencode")),
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
@@ -0,0 +1,260 @@
/**
* @file server-mode-user-admin.test.js
* @description Managing accounts from the dashboard (Users page): only an
* admin may create a user, change a role, disable/enable, reset a password or
* give a workspace, every change needs the admin's OWN password again
* (EREAUTH, throttled), an admin can never demote or disable themselves, no
* answer carries a password hash, each change lands in the audit log, and in
* local mode — no login at all — the routes refuse.
* @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-user-admin-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const ids = {};
const BOSS_PASS = "admin-pass-1";
async function call(method, p, { cookie, body, origin } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
...(origin ? { origin } : {}),
},
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 }),
});
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
ids.lane = db
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/w/ownerless', 'claude')")
.run().lastInsertRowid;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("managing accounts from the dashboard", () => {
it("lets an admin create a user, with their own password again", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", "/api/users", {
cookie: boss,
body: {
username: "neo",
password: "neo-pass-123",
role: "member",
currentPassword: BOSS_PASS,
},
});
assert.equal(r.status, 201);
assert.deepEqual(
{ username: r.body.user.username, role: r.body.user.role },
{ username: "neo", role: "member" }
);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.ok(await login("neo", "neo-pass-123"), "the new user can log in");
const dup = await call("POST", "/api/users", {
cookie: boss,
body: { username: "neo", password: "neo-pass-123", currentPassword: BOSS_PASS },
});
assert.equal(dup.status, 409);
assert.equal(dup.body.error.code, "EEXISTS");
const short = await call("POST", "/api/users", {
cookie: boss,
body: { username: "zed", password: "short", currentPassword: BOSS_PASS },
});
assert.equal(short.status, 400);
assert.equal(short.body.error.code, "EBADPASSWORD");
});
it("refuses without the admin's password, or with a wrong one", async () => {
const boss = await login("boss", BOSS_PASS);
for (const currentPassword of [undefined, "not-the-password"]) {
const r = await call("POST", "/api/users", {
cookie: boss,
body: { username: "eve", password: "eve-pass-123", currentPassword },
});
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EREAUTH");
}
assert.equal(auth.findUser("eve"), undefined);
});
it("is an admin's only: an analyst is read-only, a member never reaches it", async () => {
const body = { username: "eve", password: "eve-pass-123", currentPassword: "analyst-pass-1" };
const ana = await call("POST", "/api/users", {
cookie: await login("ana", "analyst-pass-1"),
body,
});
assert.equal(ana.status, 403);
const mia = await call("POST", "/api/users", {
cookie: await login("mia", "member-pass-1"),
body: { ...body, currentPassword: "member-pass-1" },
});
assert.equal(mia.status, 403);
assert.equal(auth.findUser("eve"), undefined);
});
it("refuses a write from another origin", async () => {
const r = await call("POST", "/api/users", {
cookie: await login("boss", BOSS_PASS),
origin: "http://evil.example",
body: { username: "eve", password: "eve-pass-123", currentPassword: BOSS_PASS },
});
assert.equal(r.status, 403);
assert.equal(auth.findUser("eve"), undefined);
});
it("changes a role, disables and enables, and resets a password", async () => {
const boss = await login("boss", BOSS_PASS);
const neo = auth.findUser("neo");
const role = await call("PATCH", `/api/users/${neo.id}`, {
cookie: boss,
body: { role: "analyst", currentPassword: BOSS_PASS },
});
assert.equal(role.status, 200);
assert.equal(auth.findUser("neo").role, "analyst");
const neoLogin = await login("neo", "neo-pass-123");
const off = await call("PATCH", `/api/users/${neo.id}`, {
cookie: boss,
body: { disabled: true, currentPassword: BOSS_PASS },
});
assert.equal(off.status, 200);
assert.equal(off.body.user.disabled, true);
assert.equal((await call("GET", "/api/sessions", { cookie: neoLogin })).status, 401);
assert.equal(await login("neo", "neo-pass-123"), null, "a disabled user cannot log in");
await call("PATCH", `/api/users/${neo.id}`, {
cookie: boss,
body: { disabled: false, currentPassword: BOSS_PASS },
});
const pw = await call("POST", `/api/users/${neo.id}/password`, {
cookie: boss,
body: { password: "neo-new-pass-9", currentPassword: BOSS_PASS },
});
assert.equal(pw.status, 200);
assert.equal(await login("neo", "neo-pass-123"), null);
assert.ok(await login("neo", "neo-new-pass-9"));
const bad = await call("PATCH", `/api/users/${neo.id}`, {
cookie: boss,
body: { role: "root", currentPassword: BOSS_PASS },
});
assert.equal(bad.status, 400);
const none = await call("PATCH", "/api/users/9999", {
cookie: boss,
body: { role: "member", currentPassword: BOSS_PASS },
});
assert.equal(none.status, 404);
});
it("never lets an admin demote or disable themselves", async () => {
const boss = await login("boss", BOSS_PASS);
for (const body of [{ role: "member" }, { disabled: true }]) {
const r = await call("PATCH", `/api/users/${ids.boss}`, {
cookie: boss,
body: { ...body, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ESELF");
}
assert.equal(auth.findUser("boss").role, "admin");
});
it("gives a workspace to a user", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", `/api/users/${ids.mia}/workspaces`, {
cookie: boss,
body: { laneId: ids.lane, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 200);
assert.equal(
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.lane).owner_id,
ids.mia
);
const none = await call("POST", `/api/users/${ids.mia}/workspaces`, {
cookie: boss,
body: { laneId: 9999, currentPassword: BOSS_PASS },
});
assert.equal(none.status, 404);
});
it("writes every change to the audit log", async () => {
const boss = await login("boss", BOSS_PASS);
const entries = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
const mine = entries.filter((e) => e.reader === "boss" && e.method !== "GET");
const paths = mine.map((e) => e.path).join("\n");
assert.match(paths, /create neo/);
assert.match(paths, /role analyst/);
assert.match(paths, /disable/);
assert.match(paths, /password reset/);
assert.match(paths, new RegExp(`workspace ${ids.lane}`));
assert.ok(!paths.includes("neo-new-pass-9"), "never the password itself");
});
it("throttles wrong confirmation passwords", async () => {
const boss = await login("boss", BOSS_PASS);
let last;
for (let i = 0; i < 12; i++) {
last = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: { role: "analyst", currentPassword: "guess-" + i },
});
}
assert.equal(last.status, 429);
assert.equal(last.body.error.code, "ETOOMANY");
assert.equal(auth.findUser("mia").role, "member");
});
});
describe("in local mode", () => {
it("refuses every account change — there is no login to check", async () => {
delete process.env.AGENTHUB_MODE;
try {
const r = await call("POST", "/api/users", {
body: { username: "eve", password: "eve-pass-123", currentPassword: "x" },
});
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "ELOCALMODE");
assert.equal(auth.findUser("eve"), undefined);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
});
+9
View File
@@ -76,6 +76,15 @@ describe("systemd-service — unit content", linuxOnly, () => {
assert.doesNotMatch(body, /^Restart=on-failure$/m);
});
it("stops only the server process, so an update never takes the lane panes with it", () => {
// tmux forks its server from whichever process first asks for a session —
// the dashboard, after a boot with autostart on — and that server sits in
// this unit's cgroup. The default KillMode=control-group would kill it,
// and every running agent, on each update's restart.
svc.writeUserService(UNIT);
assert.match(unitFile(), /^KillMode=process$/m);
});
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
svc.writeUserService(UNIT);
assert.match(unitFile(), /agenthub-managed-unit/);
+47
View File
@@ -57,6 +57,53 @@ describe("tmux wrapper", () => {
assert.equal(tmux.liveSessions(), null);
});
describe(
"the tmux server outlives the dashboard (Linux)",
{ skip: process.platform !== "linux" },
() => {
// Fake tmux: no server until a new-session, then every call succeeds.
const fake =
(calls, { running = false } = {}) =>
(args) => {
calls.push(["tmux", ...args].join(" "));
if (args[0] === "display-message" && !running)
throw Object.assign(new Error("no server"), { status: 1 });
if (args[0] === "has-session" && !running)
throw Object.assign(new Error("no session"), { status: 1 });
return args[0] === "display-message" ? "4242\n" : "";
};
it("starts the FIRST session in its own systemd scope, not the dashboard's cgroup", () => {
const calls = [];
tmux.__setExecImpl(fake(calls));
tmux.__setScopeImpl((args) => calls.push(["systemd-run", "tmux", ...args].join(" ")));
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
assert.ok(
calls.includes("systemd-run tmux new-session -d -s agenthub-lane-1 -c /w -- claude")
);
assert.ok(!calls.some((c) => c.startsWith("tmux new-session")));
});
it("adds a session to a running server directly", () => {
const calls = [];
tmux.__setExecImpl(fake(calls, { running: true }));
tmux.__setScopeImpl(() => assert.fail("a running server needs no scope"));
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
});
it("starts it plainly when no systemd user manager answers", () => {
const calls = [];
tmux.__setExecImpl(fake(calls));
tmux.__setScopeImpl(() => {
throw Object.assign(new Error("Failed to connect to bus"), { status: 1 });
});
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
});
}
);
it("newSession names a command that exited at once instead of tmux's set-option error", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "new-session") return "";
+37 -1
View File
@@ -1,5 +1,6 @@
/**
* @file Tests for replaceTokenUsage's compaction baseline semantics.
* @file Tests for replaceTokenUsage's compaction baseline semantics, and for
* the long-context subsets written beside it (high-water mark, exact reset).
*
* The effective total for a bucket is `live + baseline`. It must behave as a
* monotonic HIGH-WATER MARK: never decrease (so a compaction that shrinks the
@@ -121,3 +122,38 @@ describe("replaceTokenUsage — high-water-mark baseline", () => {
assert.equal(rawRow("hw-recover").baseline_cache_read, 0);
});
});
describe("long-context subsets", () => {
const { writeSessionTokens } = require("../../scripts/import-history");
const { emptyBucket, accumulateBucket, extractUsageFields } = require("../lib/token-usage");
const longRow = (sid) =>
db
.prepare("SELECT input_tokens, long_input_tokens FROM token_usage WHERE session_id = ?")
.get(sid);
const bucketOf = (...usages) => {
const b = emptyBucket("claude-haiku-5-5", "standard", "global", "standard");
for (const u of usages) accumulateBucket(b, extractUsageFields(u));
return { k: b };
};
before(() => {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status) VALUES ('long-1', 'x', 'active')"
).run();
});
it("stores the long part as a subset of the same row and keeps its high-water mark", () => {
const short = { input_tokens: 1_000 };
const long = { input_tokens: 150_000 };
writeSessionTokens(dbModule, "long-1", bucketOf(short, long));
assert.deepEqual(longRow("long-1"), { input_tokens: 151_000, long_input_tokens: 150_000 });
// A smaller-scope writer never takes the subset back.
writeSessionTokens(dbModule, "long-1", bucketOf(short));
assert.equal(longRow("long-1").long_input_tokens, 150_000);
});
it("lets the exact reconcile path clear a stale subset", () => {
writeSessionTokens(dbModule, "long-1", bucketOf({ input_tokens: 1_000 }), { exact: true });
assert.deepEqual(longRow("long-1"), { input_tokens: 1_000, long_input_tokens: 0 });
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* @file Tests the status-area icon's keeper (server/lib/tray.js): on by
* default, never started once the user hid it, one icon at a time, a stale
* heartbeat never trusted (its pid may be another program's by now), an icon
* from another install root replaced only at server start, and no retry loop
* for an icon that keeps dying. No real icon is spawned.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tray-"));
process.env.DASHBOARD_DATA_DIR = TMP;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DISPLAY = process.env.DISPLAY || ":99"; // guiEnv() on Linux
const tray = require("../lib/tray");
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
function fakeSpawn() {
const calls = [];
const spawn = (cmd, args, opts) => {
calls.push({ cmd, args, opts });
const child = new EventEmitter();
child.unref = () => {};
return child;
};
return { spawn, calls };
}
describe("tray keeper", () => {
beforeEach(() => {
fs.rmSync(path.join(TMP, "runtime"), { recursive: true, force: true });
});
it("is on by default and starts scripts/tray.js detached", () => {
assert.equal(tray.isEnabled(), true);
const { spawn, calls } = fakeSpawn();
assert.equal(tray.ensureTray({ spawn }), "started");
assert.equal(calls.length, 1);
assert.match(calls[0].args[0], /scripts[\\/]tray\.js$/);
assert.equal(calls[0].opts.detached, true);
});
it("starts nothing once the user hid it", () => {
fs.mkdirSync(path.dirname(tray.offPath()), { recursive: true });
fs.writeFileSync(tray.offPath(), "x");
const { spawn, calls } = fakeSpawn();
assert.equal(tray.ensureTray({ spawn }), "skipped");
assert.equal(calls.length, 0);
});
it("leaves a live icon alone, and replaces one from another root only when asked", () => {
tray.writePid(process.pid, "/some/other/root");
const { spawn, calls } = fakeSpawn();
assert.equal(tray.ensureTray({ spawn }), "running");
assert.equal(calls.length, 0);
tray.writePid(process.pid); // this root: nothing to replace
assert.equal(tray.ensureTray({ spawn, replace: true }), "running");
assert.equal(calls.length, 0);
});
it("does not trust a heartbeat that went stale", () => {
tray.writePid(process.pid);
const old = new Date(Date.now() - 10 * tray.HEARTBEAT_MS);
fs.utimesSync(tray.pidPath(), old, old);
assert.equal(tray.liveTray(), null);
});
it("writes and clears the off flag", () => {
assert.equal(tray.setEnabled(false).enabled, false);
assert.equal(fs.existsSync(tray.offPath()), true);
tray.writePid(999_999_999); // no such process: nothing to signal, nothing to trust
const { spawn, calls } = fakeSpawn();
assert.equal(tray.setEnabled(true, { spawn }).enabled, true);
assert.equal(fs.existsSync(tray.offPath()), false);
assert.equal(calls.length, 1, "turning it on shows the icon at once");
});
it("gives up on an icon that keeps dying at once", () => {
const spawned = [];
const spawn = () => {
const child = new EventEmitter();
child.unref = () => {};
spawned.push(child);
return child;
};
for (let i = 0; i < 5; i++) {
if (tray.ensureTray({ spawn }) === "started") spawned.at(-1).emit("exit", 1);
}
assert.ok(spawned.length <= 4, `retried ${spawned.length} times`);
assert.equal(tray.ensureTray({ spawn }), "skipped");
});
});