c507d37baf
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.
119 lines
4.0 KiB
JavaScript
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);
|
|
});
|
|
});
|