Files
AgentHub/server/__tests__/settings-codex-home.test.js
T
nntrivi2001 c507d37baf feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons
AI Router is a session's choice, not a workspace's:
- an AI Router select beside the model picker in the workspace home and a
  pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only
  Native; last pick remembered in agenthub.session.router)
- `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets
  --settings=<data dir>/router-settings/<name>.json (0600, outranks project
  and user settings, key never in argv), Codex -p <name> + key via tmux -e
- dashboard_runs.router; a resume naming none goes back to it
- profile PATCH rewrites the session files, DELETE removes them
- gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace
  router radio, the claude-/codex-home `routers` answers; clearLaneRouters
  takes the old keys out of each workspace's settings.local.json at start

Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked
on the rail) instead of in the brand's corner.

Icons: shipped SVG marks for claude, codex, openclaw and hermes (a
black/white pair swapped by theme) in client/public/ai-tools/, used by
AiToolIcon before the server's favicon.
2026-10-02 14:19:02 +07:00

119 lines
4.0 KiB
JavaScript

/**
* @file Tests GET/PUT /api/settings/codex-home and setCodexHome: the shared
* home-dir guard, persistence to the settings file, the `env_owned` flag, and
* a home change from another site refused. Everything lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-home-"));
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.CODEX_HOME = path.join(ROOT, "codex-a");
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const express = require("express");
const { envOwned } = require("../lib/settings-env");
const settingsRouter = require("../routes/settings");
let server;
let base;
function request(method, url, body, extra = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
const req = http.request(
`${base}${url}`,
{
method,
headers: {
...(data ? { "content-type": "application/json", "content-length": data.length } : {}),
...extra,
},
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}
);
req.on("error", reject);
if (data) req.write(data);
req.end();
});
}
before(async () => {
const app = express();
app.use(express.json());
app.use("/api/settings", settingsRouter);
server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("/api/settings/codex-home", () => {
it("GET answers the current home and whether the environment owns it", async () => {
const res = await request("GET", "/api/settings/codex-home");
assert.equal(res.status, 200);
assert.equal(res.body.codex_home, process.env.CODEX_HOME);
assert.equal(res.body.env_owned, envOwned.has("CODEX_HOME"));
});
it("refuses a path that contains the user's home, changing nothing", async () => {
const before = process.env.CODEX_HOME;
const res = await request("PUT", "/api/settings/codex-home", { path: "/" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "EBADHOME");
assert.equal(process.env.CODEX_HOME, before);
assert.ok(!fs.existsSync(process.env.AGENTHUB_SETTINGS_FILE));
});
it("refuses a missing path and a non-string body", async () => {
for (const body of [{ path: path.join(ROOT, "nope") }, { path: 7 }, {}]) {
const res = await request("PUT", "/api/settings/codex-home", body);
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_PATH");
}
});
it("switches and persists", async () => {
const next = path.join(ROOT, "codex-b");
fs.mkdirSync(next);
const res = await request("PUT", "/api/settings/codex-home", { path: next });
assert.equal(res.status, 200);
assert.equal(res.body.codex_home, next);
assert.equal(process.env.CODEX_HOME, next);
assert.match(
fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"),
new RegExp(`^CODEX_HOME=${next}$`, "m")
);
});
it("refuses a home change asked by another site", async () => {
const next = path.join(ROOT, "codex-evil");
fs.mkdirSync(next);
const res = await request(
"PUT",
"/api/settings/codex-home",
{ path: next },
{ Origin: "https://evil.example" }
);
assert.equal(res.status, 403);
assert.notEqual(process.env.CODEX_HOME, next);
});
});