Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e895c0e8eb | |||
| 0a86dfb8c5 | |||
| fc7b58f15b | |||
| c4025cd2f7 | |||
| ee1e07bb5e | |||
| 91af3dca86 | |||
| baa355bee4 |
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, and `lanes.router_profile`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -36,6 +36,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
|
||||
- **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep.
|
||||
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
|
||||
- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `<cwd>/.claude/settings.local.json` (`.local` because the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a **codex** lane gets `$CODEX_HOME/<name>.config.toml` — the layer `codex -p <name>` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`.
|
||||
- **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`.
|
||||
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
|
||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* the route answers before the actual `git worktree add` runs, so this
|
||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||
* auto-setup calls against a cwd that must actually exist on disk first.
|
||||
* An optional "Create/update readme file" checkbox starts the lane's terminal
|
||||
* run right after creation with `/sg-all:sg-md-writing` as its first message.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,7 +26,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool } from "../../lib/types";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
@@ -94,6 +96,11 @@ export function AddLaneModal({
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [base, setBase] = useState("");
|
||||
const [pipeline, setPipeline] = useState("default");
|
||||
const [writeDocs, setWriteDocs] = useState(false);
|
||||
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
|
||||
const [router, setRouter] = useState("native");
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||||
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<LaneTool>("claude");
|
||||
@@ -104,6 +111,7 @@ export function AddLaneModal({
|
||||
profile: "scaffolded" | "skipped" | "failed";
|
||||
agents: "ok" | "failed";
|
||||
mcp: "ok" | "failed";
|
||||
router: "ok" | "skipped" | "failed";
|
||||
} | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
@@ -114,6 +122,9 @@ export function AddLaneModal({
|
||||
setBranches(null);
|
||||
setBase("");
|
||||
setPipeline("default");
|
||||
setWriteDocs(false);
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
@@ -138,6 +149,26 @@ export function AddLaneModal({
|
||||
// below, and the lane still gets created.
|
||||
if (!cancelled) setPipelines([]);
|
||||
});
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setRouters(r.items);
|
||||
// A profile deleted in another tab while this modal sat open would
|
||||
// otherwise stay selected in state while the select showed no matching
|
||||
// option — and submit would send a name the server no longer knows.
|
||||
setRouter((current) =>
|
||||
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Quiet: with no list the radio simply offers Native, and the lane is
|
||||
// still created — a router is an optional extra, never a precondition.
|
||||
if (!cancelled) setRouters([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setRoutersLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -177,6 +208,18 @@ export function AddLaneModal({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mode, sourceRepo, t]);
|
||||
|
||||
// The checkbox below runs the docs skill by opening the lane's own terminal
|
||||
// run with the slash command as its first message — a skill only exists
|
||||
// inside a CLI session, so there is nothing server-side to call instead.
|
||||
// Claude-only: codex has no slash commands (same reason LaneConsolePane
|
||||
// prefills prose for a codex lane).
|
||||
const startDocsRun = (laneId: number, cwd: string) =>
|
||||
api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" });
|
||||
|
||||
// Native is the built-in profile, so this is one call either way — there is
|
||||
// no "no router" branch to keep in step with the real one.
|
||||
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
|
||||
|
||||
const submit = async () => {
|
||||
const repo = sourceRepo.trim();
|
||||
const name = title.trim();
|
||||
@@ -192,7 +235,29 @@ export function AddLaneModal({
|
||||
pipeline,
|
||||
tool,
|
||||
});
|
||||
// Report the lane the INSTANT it exists server-side. Everything after
|
||||
// this is post-creation setup that can fail on its own, and letting one
|
||||
// of those failures skip `onAdded` would leave a real lane invisible in
|
||||
// the list until some unrelated refetch happened to pick it up.
|
||||
// Worktree mode below already works this way.
|
||||
onAdded(result.lane);
|
||||
|
||||
// Router first: a session that booted on the vendor's API would keep
|
||||
// talking to it for its whole life.
|
||||
const [routerOutcome, docsOutcome] = await Promise.allSettled([
|
||||
applyRouter(result.lane.id),
|
||||
writeDocs && tool === "claude"
|
||||
? startDocsRun(result.lane.id, result.lane.cwd)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const failed = [routerOutcome, docsOutcome].find((o) => o.status === "rejected");
|
||||
if (failed && failed.status === "rejected") {
|
||||
// The lane exists and is already listed — name what did not finish
|
||||
// instead of closing on a half-set-up lane.
|
||||
setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -226,10 +291,11 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||||
api.lanes.profileInit(result.lane.id),
|
||||
api.lanes.agentsInstall(result.lane.id),
|
||||
api.lanes.mcpSync(result.lane.id),
|
||||
applyRouter(result.lane.id),
|
||||
]);
|
||||
setSetupResult({
|
||||
profile:
|
||||
@@ -240,14 +306,23 @@ export function AddLaneModal({
|
||||
: "failed",
|
||||
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
router:
|
||||
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
|
||||
});
|
||||
|
||||
if (writeDocs && tool === "claude") {
|
||||
await startDocsRun(result.lane.id, provisionedLane.cwd);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info("[add-lane] auto-setup result:", {
|
||||
profile:
|
||||
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||||
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||||
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||||
// Never the profile itself: it would put an endpoint and a lane id in
|
||||
// the browser console. (The key is never sent here at all.)
|
||||
router: routerOutcome.status,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,6 +465,107 @@ export function AddLaneModal({
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
const active = option.value === "native" ? router === "native" : router !== "native";
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border p-2.5 text-xs shadow-sm transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-lane-router"
|
||||
className="h-3.5 w-3.5 accent-accent"
|
||||
// Clicking "AI Router" before the list lands would resolve
|
||||
// back to Native and read as a dead control.
|
||||
disabled={option.value === "router" && !routersLoaded}
|
||||
checked={active}
|
||||
onChange={() =>
|
||||
// Switching to "AI Router" lands on the first saved
|
||||
// profile; with none saved the radio stays on native and
|
||||
// the select below explains why.
|
||||
setRouter(
|
||||
option.value === "native"
|
||||
? "native"
|
||||
: routers.find((r) => !r.builtin)?.name || "native"
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{router !== "native" && (
|
||||
<select
|
||||
aria-label={t("addLaneRouterProfileLabel")}
|
||||
value={router}
|
||||
onChange={(e) => setRouter(e.target.value)}
|
||||
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{routers
|
||||
.filter((r) => !r.builtin)
|
||||
.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{!routersLoaded
|
||||
? t("addLaneRouterLoading")
|
||||
: routers.some((r) => !r.builtin)
|
||||
? t("addLaneRouterHint")
|
||||
: t("addLaneRouterEmptyHint")}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
{tool === "claude" && (
|
||||
<label
|
||||
// Card that lights up when ticked, matching LaneStripCard's
|
||||
// selected/unselected treatment.
|
||||
className={`flex cursor-pointer items-start gap-2.5 rounded-lg border p-3 shadow-sm transition-colors ${
|
||||
writeDocs
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
// The wrapping label also holds the hint, so name the input
|
||||
// itself — otherwise its accessible name is label + hint.
|
||||
aria-label={t("addLaneWriteDocsLabel")}
|
||||
checked={writeDocs}
|
||||
onChange={(e) => setWriteDocs(e.target.checked)}
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-accent"
|
||||
/>
|
||||
<span>
|
||||
<span
|
||||
className={`block text-xs font-medium ${writeDocs ? "text-accent" : "text-fg-secondary"}`}
|
||||
>
|
||||
{t("addLaneWriteDocsLabel")}
|
||||
</span>
|
||||
<span className="mt-1 block text-[10px] text-fg-muted">
|
||||
{t("addLaneWriteDocsHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
@@ -445,6 +621,11 @@ export function AddLaneModal({
|
||||
},
|
||||
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
{
|
||||
label: t("addLaneSetupRouter"),
|
||||
ok: setupResult.router !== "failed",
|
||||
skipped: setupResult.router === "skipped",
|
||||
},
|
||||
] as const
|
||||
).map((row) => (
|
||||
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||||
|
||||
@@ -29,7 +29,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
profileInit: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -131,8 +134,49 @@ beforeEach(() => {
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ tool: "claude", file: null, profile: "native" });
|
||||
// Only the built-in profile by default, so the router radio stays on Native
|
||||
// and no test has to opt out of it.
|
||||
vi.mocked(api.aiRouters.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [nativeRouter()] });
|
||||
});
|
||||
|
||||
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
|
||||
function nativeRouter() {
|
||||
return {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved router profile, for the tests that need the radio to have a target. */
|
||||
function savedRouter(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...nativeRouter(),
|
||||
name: "9router",
|
||||
label: "9Router",
|
||||
builtin: false,
|
||||
hasKey: true,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddLaneModal — worktree mode (default)", () => {
|
||||
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||
renderModal();
|
||||
@@ -398,6 +442,173 @@ describe("AddLaneModal — folder browse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — create/update readme", () => {
|
||||
it("opens the new lane's terminal on the docs skill only when the box is ticked", async () => {
|
||||
vi.mocked(api.run.start)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByLabelText("Create/update readme file"));
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.run.start).toHaveBeenCalledWith({
|
||||
laneId: 9,
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("is hidden for a codex lane, which has no slash commands", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Codex" }));
|
||||
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — AI Router", () => {
|
||||
it("applies native without asking, so a lane always has a declared endpoint", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
|
||||
});
|
||||
|
||||
it("sends the picked profile once AI Router is chosen", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
|
||||
});
|
||||
|
||||
it("keeps the radio on Native when no profile is saved, and says why", async () => {
|
||||
// Choosing "AI Router" with nothing to point at would create a lane whose
|
||||
// declared endpoint does not exist.
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
|
||||
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates the lane anyway when the profile list cannot be fetched", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
|
||||
it("reports the adopted lane even when applying the router fails", async () => {
|
||||
// `ensure` already created it server-side; skipping onAdded would hide a
|
||||
// real lane from the list until some unrelated refetch found it.
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
// …and the failure is still surfaced, on a modal left open.
|
||||
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — a router profile that disappeared", () => {
|
||||
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
|
||||
// Deleted in another tab while this modal sat open: the select would show
|
||||
// no matching option while state still held the dead name.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
const { rerender } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
|
||||
|
||||
// Re-open with the profile gone from the server's answer.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
|
||||
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
|
||||
});
|
||||
|
||||
it("does not offer AI Router until the profile list has arrived", async () => {
|
||||
// Clicking it early resolved back to Native with no feedback, which reads
|
||||
// as a dead control.
|
||||
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
|
||||
vi.mocked(api.aiRouters.list).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveList = resolve;
|
||||
}) as never
|
||||
);
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
||||
resolveList({ items: [nativeRouter(), savedRouter()] });
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
||||
"addLaneWriteDocsHint": "Opens the lane's terminal on creation and runs the /sg-all:sg-md-writing skill there.",
|
||||
"addLaneWriteDocsLabel": "Create/update readme file",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
@@ -132,5 +134,13 @@
|
||||
"splitView.pickLane": "Pick a lane",
|
||||
"statusDead": "DEAD",
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"addLaneRouterLabel": "Model endpoint",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterProfileLabel": "Router profile",
|
||||
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
|
||||
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
|
||||
"addLaneSetupRouter": "Model endpoint",
|
||||
"addLaneRouterLoading": "Loading router profiles…"
|
||||
}
|
||||
|
||||
@@ -445,6 +445,45 @@
|
||||
"label": "Tools",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a lane.",
|
||||
"add": "Add profile",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
|
||||
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
"colModel": "Model",
|
||||
"colKey": "API key",
|
||||
"keySet": "Saved",
|
||||
"keyMissing": "None",
|
||||
"fieldLabel": "Name",
|
||||
"fieldLabelHint": "What you will see in the lane form. Free text.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
"opus": "Opus",
|
||||
"sonnet": "Sonnet",
|
||||
"haiku": "Haiku",
|
||||
"smallFast": "Small / fast",
|
||||
"subagent": "Subagent"
|
||||
},
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"removeConfirm": "Remove the profile \"{{label}}\"? Every lane using it is reset to the vendor's own API right away — on disk and in the dashboard.",
|
||||
"loading": "Loading profiles…",
|
||||
"lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
||||
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal của lane và chạy skill /sg-all:sg-md-writing tại đó.",
|
||||
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
@@ -132,5 +134,13 @@
|
||||
"splitView.pickLane": "Chọn lane",
|
||||
"statusDead": "ĐÃ CHẾT",
|
||||
"title": "Làn đường",
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"addLaneRouterLabel": "Endpoint model",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterProfileLabel": "Profile router",
|
||||
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
|
||||
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
|
||||
"addLaneSetupRouter": "Endpoint model",
|
||||
"addLaneRouterLoading": "Đang tải danh sách router…"
|
||||
}
|
||||
|
||||
@@ -445,6 +445,45 @@
|
||||
"label": "Công cụ",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex"
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo lane.",
|
||||
"add": "Thêm profile",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
|
||||
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
"colModel": "Model",
|
||||
"colKey": "API key",
|
||||
"keySet": "Đã lưu",
|
||||
"keyMissing": "Chưa có",
|
||||
"fieldLabel": "Tên",
|
||||
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo lane. Gõ tự do.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
|
||||
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
|
||||
"perTier": "Ghi đè theo từng bậc",
|
||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||
"tier": {
|
||||
"opus": "Opus",
|
||||
"sonnet": "Sonnet",
|
||||
"haiku": "Haiku",
|
||||
"smallFast": "Nhỏ / nhanh",
|
||||
"subagent": "Subagent"
|
||||
},
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi lane đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
|
||||
"loading": "Đang tải danh sách profile…",
|
||||
"lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1868,6 +1868,40 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── AI Router API ─────────────────────────────
|
||||
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* lane can be pointed at instead of the vendor's own API. Managed in the
|
||||
* **AI Router** tab of Settings; attached to a lane when the lane is created.
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
/** POST /api/ai-routers - save a new profile. */
|
||||
create: (body: AiRouterInput) =>
|
||||
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
|
||||
* keeps the stored one, which is what the edit form always does: it cannot
|
||||
* display a key, so it has none to send back.
|
||||
*/
|
||||
update: (name: string, body: Partial<AiRouterInput>) =>
|
||||
request<AiRouter & { lanes: AiRouterLaneSync }>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/ai-routers/:name - also resets every lane using it back to
|
||||
* native, on disk as well as in the DB.
|
||||
*/
|
||||
remove: (name: string) =>
|
||||
request<{ removed: string; lanes: AiRouterLaneSync }>(
|
||||
`/ai-routers/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
|
||||
// ────────────────────────────────── Run API ─────────────────────────────────
|
||||
/** Spawn/manage headless or conversational `claude` CLI child processes
|
||||
* launched from the dashboard's Run page, and stream their output. */
|
||||
@@ -2434,6 +2468,16 @@ export const api = {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
/**
|
||||
* POST /lanes/:id/router/apply - point this lane at an AI Router profile,
|
||||
* or back at `native`. Writes the lane's own config on disk; only the
|
||||
* profile NAME comes from here.
|
||||
*/
|
||||
routerApply: (id: number, profile: string) =>
|
||||
request<{ tool: string; file: string | null; profile: string }>(`/lanes/${id}/router/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profile }),
|
||||
}),
|
||||
mcpSync: (id: number) =>
|
||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
|
||||
method: "POST",
|
||||
@@ -2609,6 +2653,53 @@ export interface CcProfile extends CcAccountIdentity {
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** The per-tier model names an AI Router profile carries. Each maps to one env
|
||||
* var Claude Code reads; a router that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
export interface AiRouterModels {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
smallFast: string;
|
||||
subagent: string;
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** Empty string for `native`, an http(s) URL otherwise. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
hasKey: boolean;
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/** How an edit or a delete landed on the lanes already using that profile.
|
||||
* Attaching copies a profile, so the store alone is not the whole truth: a
|
||||
* lane listed in `failed` still carries the OLD key and endpoint. */
|
||||
export interface AiRouterLaneSync {
|
||||
updated: number;
|
||||
/** One `"<id> (<title>): <code>"` line per lane that could not be rewritten. */
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/** Body accepted when creating or updating a profile. */
|
||||
export interface AiRouterInput {
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
/** Omit on update to keep the stored key. */
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
* as summarized by the `ccConfig` list endpoints. The list endpoints return
|
||||
* a lightweight summary (frontmatter + a preview) rather than full contents;
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* @file AiRouterConfig.tsx
|
||||
* @description The **AI Router** tab of Settings — CRUD for the named router
|
||||
* endpoints (9Router, OpenRouter, a self-hosted gateway) a lane can be pointed
|
||||
* at instead of the vendor's own API.
|
||||
*
|
||||
* A lane CHOOSES its router in the Add-lane modal, not here. But choosing one
|
||||
* copies it into that lane's own config, so editing or deleting a profile here
|
||||
* does reach every lane already using it — the server re-applies it, or resets
|
||||
* those lanes to native, and reports any lane it could not touch. That report
|
||||
* is surfaced: a rotated key that silently missed two lanes would leave the
|
||||
* user believing a revoked key is out of use when it is not.
|
||||
*
|
||||
* The built-in `native` row is rendered read-only: it is the vendor's own API,
|
||||
* and it exists so that "go back to native" is the same operation as "use a
|
||||
* router" rather than a special case.
|
||||
*
|
||||
* An API key is write-only here by design — the server never returns one, so
|
||||
* the form shows whether a key is saved, never the key.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Pencil, Trash2, Router as RouterIcon, Lock } from "lucide-react";
|
||||
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
|
||||
apiKey: string;
|
||||
models: Partial<AiRouterModels>;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
label: "",
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
models: {},
|
||||
});
|
||||
|
||||
export function AiRouterConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<AiRouter[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Not an error — the profile itself saved. It is the lanes that did not all
|
||||
// follow, which the user must be told about rather than left to assume.
|
||||
const [laneWarning, setLaneWarning] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<AiRouter | null>(null);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Distinct from "no profiles": an empty table rendered before the first fetch
|
||||
// resolves tells the user their profiles are gone. CodexConfig draws the same
|
||||
// distinction for the same reason.
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// No dependencies, deliberately. `t` is NOT one: react-i18next returns a
|
||||
// stable `t`, but nothing in this component's contract guarantees that, and
|
||||
// the moment it changes identity per render this callback does too — which
|
||||
// re-fires the effect below, which calls load(), which renders again. That
|
||||
// loop also clears `error` on every pass, so a failed save would flash and
|
||||
// vanish. Keeping the message untranslated is the cheaper half of the trade.
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await api.aiRouters.list()).items);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const startAdd = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: AiRouter) => {
|
||||
setEditing(item);
|
||||
// No apiKey: the server never sent one. Blank means "keep what is stored".
|
||||
setForm({
|
||||
label: item.label,
|
||||
baseUrl: item.baseUrl,
|
||||
apiKey: "",
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
/** Report any lane the server could not re-point, or clear a stale report. */
|
||||
const reportLanes = (lanes?: { updated: number; failed: string[] }) => {
|
||||
setLaneWarning(
|
||||
lanes && lanes.failed.length
|
||||
? t("aiRouter.lanesFailed", { count: lanes.failed.length, lanes: lanes.failed.join(", ") })
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setLaneWarning(null);
|
||||
try {
|
||||
// Send only the tiers that differ from the headline model, so the server
|
||||
// keeps filling the rest from it — that is what makes one symbolic name
|
||||
// enough for a router that wants the same value everywhere.
|
||||
const models: Partial<AiRouterModels> = {};
|
||||
for (const tier of TIERS) {
|
||||
const value = (form.models[tier] || "").trim();
|
||||
if (value && value !== form.model.trim()) models[tier] = value;
|
||||
}
|
||||
const body: AiRouterInput = {
|
||||
label: form.label.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
models,
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
|
||||
if (editing) reportLanes((await api.aiRouters.update(editing.name, body)).lanes);
|
||||
else await api.aiRouters.create(body);
|
||||
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (item: AiRouter) => {
|
||||
if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return;
|
||||
setError(null);
|
||||
setLaneWarning(null);
|
||||
try {
|
||||
reportLanes((await api.aiRouters.remove(item.name)).lanes);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<RouterIcon className="h-4 w-4 text-fg-muted" />
|
||||
{t("aiRouter.title")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("aiRouter.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{laneWarning && (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
|
||||
{laneWarning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colLabel")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colBaseUrl")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colModel")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("aiRouter.colKey")}</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.builtin && (
|
||||
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
||||
{t("aiRouter.builtinHint")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">
|
||||
{item.baseUrl || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-secondary">{item.model}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">
|
||||
{item.builtin
|
||||
? "—"
|
||||
: item.hasKey
|
||||
? t("aiRouter.keySet")
|
||||
: t("aiRouter.keyMissing")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{!item.builtin && (
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("aiRouter.edit")} ${item.label}`}
|
||||
onClick={() => startEdit(item)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("aiRouter.remove")} ${item.label}`}
|
||||
onClick={() => remove(item)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(loading || items.length <= 1) && (
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-fg-muted">
|
||||
{loading ? t("aiRouter.loading") : t("aiRouter.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
||||
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg-primary">
|
||||
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field
|
||||
id="router-label"
|
||||
label={t("aiRouter.fieldLabel")}
|
||||
hint={t("aiRouter.fieldLabelHint")}
|
||||
value={form.label}
|
||||
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-base-url"
|
||||
label={t("aiRouter.fieldBaseUrl")}
|
||||
hint={t("aiRouter.fieldBaseUrlHint")}
|
||||
value={form.baseUrl}
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, baseUrl: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-api-key"
|
||||
label={t("aiRouter.fieldApiKey")}
|
||||
hint={
|
||||
editing && editing.hasKey
|
||||
? t("aiRouter.fieldApiKeyKeep")
|
||||
: t("aiRouter.fieldApiKeyHint")
|
||||
}
|
||||
value={form.apiKey}
|
||||
type="password"
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-model"
|
||||
label={t("aiRouter.fieldModel")}
|
||||
hint={t("aiRouter.fieldModelHint")}
|
||||
value={form.model}
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
|
||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||
{t("aiRouter.perTier")}
|
||||
</summary>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("aiRouter.perTierHint")}</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{TIERS.map((tier) => (
|
||||
<Field
|
||||
key={tier}
|
||||
id={`router-model-${tier}`}
|
||||
label={t(`aiRouter.tier.${tier}`)}
|
||||
value={form.models[tier] || ""}
|
||||
mono
|
||||
placeholder={form.model}
|
||||
onChange={(v) =>
|
||||
setForm((f) => ({ ...f, models: { ...f.models, [tier]: v } }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3"
|
||||
>
|
||||
{t("aiRouter.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{t("aiRouter.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
mono,
|
||||
type = "text",
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
mono?: boolean;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none ${
|
||||
mono ? "font-mono" : ""
|
||||
}`}
|
||||
/>
|
||||
{hint && <p className="mt-1 text-[10px] text-fg-muted">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex), with the active tab kept in the `?tool=` query param.
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at, with the active tab kept in the `?tool=` query param.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Boxes, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { Boxes, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
|
||||
interface ToolTab {
|
||||
id: string;
|
||||
@@ -31,6 +32,7 @@ const TOOL_TABS: ToolTab[] = [
|
||||
DASHBOARD_TAB,
|
||||
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
|
||||
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
|
||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @file Tests for the AI Router settings tab — listing profiles, creating one,
|
||||
* and the two rules that keep a key safe: the form never shows one, and an
|
||||
* edit that leaves the field blank must not send an empty key that would wipe
|
||||
* the stored one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AiRouterConfig } from "../AiRouterConfig";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
aiRouters: { list: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
|
||||
const nineRouter = {
|
||||
...native,
|
||||
name: "9router",
|
||||
label: "9Router",
|
||||
builtin: false,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
hasKey: true,
|
||||
model: "Highest",
|
||||
models: {
|
||||
opus: "Highest",
|
||||
sonnet: "Highest",
|
||||
haiku: "Highest",
|
||||
smallFast: "Highest",
|
||||
subagent: "Highest",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.aiRouters.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native] });
|
||||
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
|
||||
vi.mocked(api.aiRouters.update)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ...nineRouter, lanes: { updated: 0, failed: [] } });
|
||||
vi.mocked(api.aiRouters.remove)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ removed: "9router", lanes: { updated: 0, failed: [] } });
|
||||
});
|
||||
|
||||
describe("AiRouterConfig", () => {
|
||||
it("lists the built-in native profile with no edit or delete control", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull();
|
||||
expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull();
|
||||
});
|
||||
|
||||
it("saves a new profile with the typed endpoint, key and model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith({
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
// Every tier equals the headline model, so none is sent: the server
|
||||
// fills them, which is what makes one field enough.
|
||||
models: {},
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends only the tiers that differ from the headline model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
|
||||
await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ models: { opus: "PremiumCombo" } })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
|
||||
// The API cannot return a key, so an edit that sends `apiKey: ""` would
|
||||
// silently erase the one on disk.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
|
||||
expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue("");
|
||||
|
||||
await user.clear(screen.getByLabelText("aiRouter.fieldLabel"));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled());
|
||||
const call = vi.mocked(api.aiRouters.update).mock.calls[0];
|
||||
expect(call?.[1]).not.toHaveProperty("apiKey");
|
||||
});
|
||||
|
||||
it("warns when a rotated key did not reach every lane using the profile", async () => {
|
||||
// The whole point of pushing an edit out to the lanes is that a revoked key
|
||||
// stops being used. A lane that silently kept the old one must be named,
|
||||
// or the user believes a rotation is complete when it is not.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.aiRouters.update).mockResolvedValue({
|
||||
...nineRouter,
|
||||
lanes: { updated: 1, failed: ["7 (checkout): EBADJSON"] },
|
||||
});
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-rotated");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
expect(await screen.findByText("aiRouter.lanesFailed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a server error instead of closing the form", async () => {
|
||||
vi.mocked(api.aiRouters.create).mockRejectedValue(
|
||||
new Error("baseUrl must be http:// or https://")
|
||||
);
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
expect(await screen.findByText(/must be http/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9537,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.5.8 (rebuild client to match)
|
||||
UI build v1.6.0 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+32
@@ -1718,6 +1718,38 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
|
||||
|
||||
---
|
||||
|
||||
### AI Router profiles
|
||||
|
||||
```
|
||||
GET /api/ai-routers Every saved profile, the built-in `native` one first
|
||||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
|
||||
PATCH /api/ai-routers/:name Update the same fields, all optional
|
||||
DELETE /api/ai-routers/:name Delete one saved profile
|
||||
```
|
||||
|
||||
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the lanes" below.
|
||||
|
||||
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** tab of Settings (`/settings?tool=ai-router`), which stores profiles and attaches nothing; attaching happens once, when a lane is created.
|
||||
|
||||
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
|
||||
|
||||
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
||||
|
||||
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
|
||||
|
||||
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. Only the name comes from the request; what is written, and where, is derived from the lane row:
|
||||
|
||||
- A **claude** lane gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
|
||||
- A **codex** lane gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the lane's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
|
||||
|
||||
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
|
||||
|
||||
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together.
|
||||
|
||||
**An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket API
|
||||
|
||||
### Connection
|
||||
|
||||
@@ -43,6 +43,10 @@ The modal has a segmented toggle mirroring the CLI's two modes:
|
||||
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
|
||||
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
|
||||
|
||||
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. The UI offers the choice only at creation, and with no profile saved the radio stays on Native and says so. Unlike the lane's tool, though, this one is not immutable: `POST /api/lanes/:id/router/apply` re-points an existing lane, rewriting its config and its `router_profile` column together. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
|
||||
|
||||
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
|
||||
|
||||
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
|
||||
|
||||
## Lane tool: Claude or Codex
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# AI Router profiles — design
|
||||
|
||||
## Goal
|
||||
|
||||
Configure named AI Router endpoints (9Router, OpenRouter, …) once in Settings,
|
||||
then pick one when creating a lane so that lane's Claude Code or Codex session
|
||||
talks to the router instead of the vendor's own API — without hand-editing a
|
||||
settings file per project, and without a second copy of the whole settings file
|
||||
drifting out of sync with the first.
|
||||
|
||||
## What the reference setup showed
|
||||
|
||||
The user today keeps two whole files, `~/.claude/settings.json` (native) and
|
||||
`~/.claude/settings2.json` (9Router), and swaps them. Diffing the two:
|
||||
|
||||
- They differ in exactly one place that matters — the `env` block, 8 keys:
|
||||
`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`,
|
||||
`ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`,
|
||||
`ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`,
|
||||
`CLAUDE_CODE_SUBAGENT_MODEL`.
|
||||
- Every other difference (`hooks`, `enabledPlugins`, `statusLine`,
|
||||
`effortLevel`, …) is drift between two hand-maintained copies. Removing that
|
||||
drift is half the point of this feature.
|
||||
- "Native" is **not** the absence of the connection variables: the native file
|
||||
sets `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` to empty strings and
|
||||
carries real `claude-*` model names.
|
||||
- The 9Router file sets all six model slots to one symbolic value (`Highest`),
|
||||
which the router resolves. Model values are therefore free text, and "one
|
||||
value for every tier" is the common case, not the exception.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **The Settings tab only stores profiles.** It does not attach anything.
|
||||
Attaching happens when a lane is created.
|
||||
2. **Native is a built-in profile, not a code branch.** Its `baseUrl`/`apiKey`
|
||||
are empty and its six model slots hold the native `claude-*` names. It cannot
|
||||
be edited or deleted. Switching native ↔ router is therefore one write path
|
||||
with no special case.
|
||||
3. **A profile carries six model slots plus an "apply to all" convenience
|
||||
field**, mirroring exactly the env keys Claude Code reads.
|
||||
4. **The API key never leaves the server.** Profiles are stored one JSON file
|
||||
each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, file `0600`),
|
||||
following `cc-profiles.js`. `GET` returns metadata plus `hasKey: true`,
|
||||
never the key.
|
||||
5. **A lane's router is fixed at creation**, like `lanes.tool`: a new nullable
|
||||
`lanes.router_profile` column, deliberately absent from `PATCHABLE`.
|
||||
6. **AgentHub never reads `~/.9router/db.json`.** That is another application's
|
||||
private store; parsing it would couple this dashboard to a format its owner
|
||||
is free to change. The user pastes a base URL and a key, as they do today.
|
||||
|
||||
## Attaching, per tool
|
||||
|
||||
The two tools differ because the tools themselves differ, not by choice.
|
||||
|
||||
**Claude lane** — write the profile's eight env keys into
|
||||
`<lane.cwd>/.claude/settings.local.json`, merging into any `env` already there
|
||||
and leaving every other key in the file untouched. `settings.local.json` rather
|
||||
than `settings.json` because the value written includes an API key and that file
|
||||
is the one Claude Code's own convention keeps out of git.
|
||||
|
||||
**Codex lane** — Codex has no project-level config: `codex doctor` reports one
|
||||
loaded file, `~/.codex/config.toml`. It does have
|
||||
`-p, --profile <name>`, documented as *"Layer `$CODEX_HOME/<name>.config.toml`
|
||||
on top of the base user config"*. So each router profile is written once to
|
||||
`~/.codex/<name>.config.toml` (`model_provider` plus a `[model_providers.<id>]`
|
||||
table with `base_url`, `env_key`, `wire_api`), and the lane's spawn adds
|
||||
`-p <name>`. The base `~/.codex/config.toml` is never modified. Codex reads the
|
||||
key from the environment variable named by `env_key`, so the spawn also injects
|
||||
that variable into the tmux pane — `pty-run.js` already passes an `env` map to
|
||||
`tmux new-session -e`.
|
||||
|
||||
## Surface
|
||||
|
||||
- `server/lib/ai-routers.js` — store: list/get/create/update/remove, name
|
||||
validation, `0700`/`0600`, key never returned by the list shape.
|
||||
- `server/lib/router-apply.js` — the two writers above, taking a lane row and a
|
||||
profile. Target directories come from the lane row in the DB, never from a
|
||||
request body — the same narrowing every other lane write path uses.
|
||||
- `server/routes/ai-routers.js` → `/api/ai-routers`: `GET /`, `POST /`,
|
||||
`PATCH /:name`, `DELETE /:name`, reusing `cc-profiles.js`'s error-code→status
|
||||
table.
|
||||
- `POST /api/lanes/:id/router/apply` — a fourth member of the auto-setup set the
|
||||
Add-lane modal already fires (`profileInit`, `agentsInstall`, `mcpSync`).
|
||||
- `client/src/pages/AiRouterConfig.tsx` + one line in `SettingsHub.tsx`'s
|
||||
`TOOL_TABS`, placed after Codex.
|
||||
- `AddLaneModal`: a Native / AI Router radio, and a profile select shown only
|
||||
when AI Router is chosen.
|
||||
- DB: `ALTER TABLE lanes ADD COLUMN router_profile TEXT`, nullable.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Changing an existing lane's router (create-time only, exactly like `tool`),
|
||||
attaching at user/global scope, editing Codex's base `config.toml`, and reading
|
||||
any router vendor's own configuration store.
|
||||
|
||||
## Verification
|
||||
|
||||
Server tests against a temporary `CLAUDE_HOME`/`CODEX_HOME`: store CRUD and its
|
||||
permission bits, the key never appearing in a list response, the
|
||||
`settings.local.json` merge preserving unrelated keys, and the generated
|
||||
`<name>.config.toml`. Client tests: the tab renders and creates a profile, and
|
||||
the Add-lane radio sends the chosen profile only when AI Router is selected.
|
||||
|
||||
The load-bearing assumption — a project-level `settings.local.json` `env` block
|
||||
overrides the user-level one per key — is confirmed by the user from their own
|
||||
earlier testing.
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.5.8
|
||||
version: 1.6.0
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.5.8",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.5.8",
|
||||
"version": "1.6.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.5.8",
|
||||
"version": "1.6.0",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
|
||||
* native profile, and the guarantee that a stored API key never appears in a
|
||||
* response the browser can read.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-routers-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
|
||||
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
|
||||
|
||||
/** The smallest profile the API accepts. */
|
||||
function nineRouter(over = {}) {
|
||||
return {
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "Highest",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ai-routers — the built-in native profile", () => {
|
||||
it("is listed even before anything has been saved", () => {
|
||||
const { items } = routers.listRouters();
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].name, "native");
|
||||
assert.equal(items[0].builtin, true);
|
||||
});
|
||||
|
||||
it("leaves the two connection variables empty rather than absent", () => {
|
||||
// The reference native settings.json sets both to "" — an absent key would
|
||||
// let a user-level router base URL leak into a lane meant to run native.
|
||||
const native = routers.getRouter("native");
|
||||
assert.equal(native.baseUrl, "");
|
||||
assert.equal(native.apiKey, "");
|
||||
});
|
||||
|
||||
it("refuses to be edited or removed", () => {
|
||||
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
|
||||
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — create", () => {
|
||||
it("saves a profile and derives its name from the label", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
assert.equal(created.name, "9Router".toLowerCase());
|
||||
assert.equal(created.label, "9Router");
|
||||
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
|
||||
});
|
||||
|
||||
it("stores the file 0600 inside a 0700 directory", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
|
||||
const dir = fs.statSync(ROUTERS_DIR);
|
||||
assert.equal(file.mode & 0o777, 0o600);
|
||||
assert.equal(dir.mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
it("fills every model slot from `model` when no per-tier value is given", () => {
|
||||
// The 9Router reference sets all six slots to one symbolic value; typing it
|
||||
// once must be enough.
|
||||
routers.createRouter(nineRouter());
|
||||
const saved = routers.getRouter("9router");
|
||||
assert.equal(saved.models.opus, "Highest");
|
||||
assert.equal(saved.models.sonnet, "Highest");
|
||||
assert.equal(saved.models.haiku, "Highest");
|
||||
assert.equal(saved.models.smallFast, "Highest");
|
||||
assert.equal(saved.models.subagent, "Highest");
|
||||
});
|
||||
|
||||
it("keeps a per-tier value that was given explicitly", () => {
|
||||
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
|
||||
const saved = routers.getRouter("9router");
|
||||
assert.equal(saved.models.opus, "PremiumCombo");
|
||||
assert.equal(saved.models.sonnet, "Highest");
|
||||
});
|
||||
|
||||
it("gives a second profile with the same label a free name", () => {
|
||||
const a = routers.createRouter(nineRouter());
|
||||
const b = routers.createRouter(nineRouter());
|
||||
assert.notEqual(a.name, b.name);
|
||||
});
|
||||
|
||||
it("rejects a label that is empty or carries control characters", () => {
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
|
||||
code: "EBADLABEL",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a base URL that is not http(s)", () => {
|
||||
// A `file:` or `javascript:` base URL is never a router; it is either a
|
||||
// mistake or an attempt to point the CLI somewhere it should not go.
|
||||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
|
||||
code: "EBADURL",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to shadow the built-in native name", () => {
|
||||
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
|
||||
code: "EBUILTIN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — the key never reaches the browser", () => {
|
||||
it("is absent from the list, which reports only that one is set", () => {
|
||||
routers.createRouter(nineRouter());
|
||||
const { items } = routers.listRouters();
|
||||
const saved = items.find((i) => i.name === "9router");
|
||||
assert.equal(saved.hasKey, true);
|
||||
assert.equal(saved.apiKey, undefined);
|
||||
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
|
||||
});
|
||||
|
||||
it("is still absent when the profile has no key at all", () => {
|
||||
routers.createRouter(nineRouter({ apiKey: "" }));
|
||||
const { items } = routers.listRouters();
|
||||
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — update", () => {
|
||||
it("changes the label without changing the name a lane already references", () => {
|
||||
// A lane stores the name. Renaming the label must not orphan that lane.
|
||||
const created = routers.createRouter(nineRouter());
|
||||
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
|
||||
assert.equal(updated.name, created.name);
|
||||
assert.equal(updated.label, "9Router (nhà)");
|
||||
});
|
||||
|
||||
it("keeps the stored key when the update omits it", () => {
|
||||
// The edit form cannot show the key, so it cannot send it back either.
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.updateRouter(created.name, { label: "renamed" });
|
||||
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
|
||||
});
|
||||
|
||||
it("replaces the key when a new one is given", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
|
||||
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
|
||||
});
|
||||
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — remove", () => {
|
||||
it("deletes the file", () => {
|
||||
const created = routers.createRouter(nineRouter());
|
||||
routers.removeRouter(created.name);
|
||||
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
|
||||
assert.equal(routers.listRouters().items.length, 1); // native only
|
||||
});
|
||||
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
|
||||
it("lists the healthy profiles around it", () => {
|
||||
routers.createRouter(nineRouter());
|
||||
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
|
||||
const { items } = routers.listRouters();
|
||||
assert.ok(items.some((i) => i.name === "9router"));
|
||||
assert.ok(!items.some((i) => i.name === "broken"));
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe("pty-run", () => {
|
||||
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
|
||||
});
|
||||
|
||||
it("spawnRun types the argv into an existing session idling at a shell prompt", () => {
|
||||
it("spawnRun runs the argv in an existing session idling at a shell prompt", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
@@ -103,24 +103,24 @@ describe("pty-run", () => {
|
||||
});
|
||||
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
|
||||
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
|
||||
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
|
||||
assert.ok(literal, "expected a literal send-keys with the command line");
|
||||
assert.match(literal[4], /^AGENTHUB_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/);
|
||||
assert.ok(
|
||||
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
|
||||
"expected the command to be submitted"
|
||||
);
|
||||
const respawn = calls.find((c) => c[0] === "respawn-pane");
|
||||
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
|
||||
const argv = respawn.slice(respawn.indexOf("--") + 1);
|
||||
assert.equal(argv[0], "claude");
|
||||
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
|
||||
});
|
||||
|
||||
it("spawnRun single-quotes an initial prompt typed into an existing shell pane", () => {
|
||||
let literal = null;
|
||||
it("spawnRun passes an initial prompt as one argv element, never a shell line", () => {
|
||||
// No shell is involved any more, so a prompt full of shell metacharacters
|
||||
// is just a string — but it must still arrive whole and unmangled.
|
||||
let respawn = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "display-message") return "zsh\n";
|
||||
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
|
||||
if (args[0] === "respawn-pane") respawn = args;
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
|
||||
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
|
||||
assert.equal(respawn.at(-1), "don't; rm -rf /");
|
||||
});
|
||||
|
||||
it("spawnRun puts AGENTHUB_LANE_ID in the new session's environment", () => {
|
||||
@@ -142,17 +142,22 @@ describe("pty-run", () => {
|
||||
assert.ok(at < newSessionArgv.indexOf("--"));
|
||||
});
|
||||
|
||||
it("spawnRun prefixes AGENTHUB_LANE_ID when typing into an existing shell pane", () => {
|
||||
let literal = null;
|
||||
it("spawnRun passes AGENTHUB_LANE_ID through tmux -e when reusing a shell pane", () => {
|
||||
let respawn = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "display-message") return "bash\n";
|
||||
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
|
||||
if (args[0] === "respawn-pane") respawn = args;
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
|
||||
// An assignment prefix on the same line: tmux's -e only applies at session
|
||||
// creation, and a live shell takes its env from the command line.
|
||||
assert.match(literal, /^AGENTHUB_LANE_ID='12' 'codex'/);
|
||||
// Through argv, never as a shell assignment typed into the pane: this is
|
||||
// also the path an AI Router's API key takes, and a typed line would be
|
||||
// rendered to every attached browser and land in the shell history.
|
||||
const at = respawn.indexOf("-e");
|
||||
assert.ok(at > -1, "expected a tmux -e environment flag");
|
||||
assert.equal(respawn[at + 1], "AGENTHUB_LANE_ID=12");
|
||||
assert.ok(at < respawn.indexOf("--"));
|
||||
assert.equal(respawn.at(-1), "codex");
|
||||
});
|
||||
|
||||
it("spawnRun with resumeSessionId passes --resume in argv", () => {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* @file Tests for attaching an AI Router profile to a lane — the Claude Code
|
||||
* `settings.local.json` env merge and the Codex `<name>.config.toml` layer,
|
||||
* including the guarantees that neither write destroys anything already there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const {
|
||||
applyRouterToLane,
|
||||
reapplyToLanes,
|
||||
detachFromLanes,
|
||||
routerEnvForSpawn,
|
||||
routerArgvForSpawn,
|
||||
} = require("../lib/router-apply");
|
||||
|
||||
const LANE_CWD = path.join(SUITE_ROOT, "project");
|
||||
const SETTINGS = path.join(LANE_CWD, ".claude", "settings.local.json");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(process.env.CLAUDE_HOME, "agenthub-routers"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
fs.rmSync(LANE_CWD, { recursive: true, force: true });
|
||||
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
|
||||
fs.mkdirSync(LANE_CWD, { recursive: true });
|
||||
// The DB outlives each test; a profile name is reused, so lanes left behind
|
||||
// would be picked up by the next test's `reapplyToLanes` and inflate its count.
|
||||
for (const lane of lanesLib.listLanes()) lanesLib.deleteLane(lane.id);
|
||||
});
|
||||
|
||||
function saveNineRouter() {
|
||||
return routers.createRouter({
|
||||
label: "9Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "Highest",
|
||||
});
|
||||
}
|
||||
|
||||
const claudeLane = () => ({ id: 7, cwd: LANE_CWD, tool: "claude" });
|
||||
const codexLane = () => ({ id: 8, cwd: LANE_CWD, tool: "codex" });
|
||||
|
||||
describe("router-apply — Claude lane", () => {
|
||||
it("writes the eight env keys the reference settings file swaps", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
|
||||
assert.equal(env.ANTHROPIC_MODEL, "Highest");
|
||||
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Highest");
|
||||
assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL, "Highest");
|
||||
assert.equal(env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "Highest");
|
||||
assert.equal(env.ANTHROPIC_SMALL_FAST_MODEL, "Highest");
|
||||
assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, "Highest");
|
||||
});
|
||||
|
||||
it("keeps every unrelated key in the file it merges into", () => {
|
||||
// A lane's settings.local.json is the user's file; this feature owns eight
|
||||
// keys in it and nothing else.
|
||||
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SETTINGS,
|
||||
JSON.stringify({
|
||||
permissions: { defaultMode: "bypassPermissions" },
|
||||
env: { MY_OWN_VAR: "keep me", ANTHROPIC_MODEL: "to be replaced" },
|
||||
})
|
||||
);
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
|
||||
assert.equal(saved.permissions.defaultMode, "bypassPermissions");
|
||||
assert.equal(saved.env.MY_OWN_VAR, "keep me");
|
||||
assert.equal(saved.env.ANTHROPIC_MODEL, "Highest");
|
||||
});
|
||||
|
||||
it("writes the file 0600, because it now holds an API key", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
assert.equal(fs.statSync(SETTINGS).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
it("blanks the connection variables for the native profile instead of dropping them", () => {
|
||||
// Absent keys would let a user-level router base URL apply to a lane that
|
||||
// explicitly asked for native.
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_BASE_URL, "");
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "");
|
||||
});
|
||||
|
||||
it("removes the model names for the native profile rather than pinning them", () => {
|
||||
// The opposite of the connection pair above, and for the opposite reason:
|
||||
// a project-scope model name overrides the user's own global choice, so a
|
||||
// lane on native must leave that choice alone instead of writing whatever
|
||||
// `claude-*` names this codebase happened to ship with.
|
||||
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
|
||||
fs.writeFileSync(SETTINGS, JSON.stringify({ env: { MY_OWN_VAR: "keep me" } }));
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const env = JSON.parse(fs.readFileSync(SETTINGS, "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_MODEL, undefined);
|
||||
assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, undefined);
|
||||
assert.equal(env.CLAUDE_CODE_SUBAGENT_MODEL, undefined);
|
||||
assert.equal(env.MY_OWN_VAR, "keep me"); // clearing ours touches nothing else
|
||||
});
|
||||
|
||||
it("refuses a profile that does not exist rather than writing a broken file", () => {
|
||||
assert.throws(() => applyRouterToLane(claudeLane(), "ghost"), { code: "ENOTFOUND" });
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
|
||||
it("leaves a settings file that is not valid JSON alone", () => {
|
||||
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
|
||||
fs.writeFileSync(SETTINGS, "{not json");
|
||||
const { name } = saveNineRouter();
|
||||
assert.throws(() => applyRouterToLane(claudeLane(), name), { code: "EBADJSON" });
|
||||
assert.equal(fs.readFileSync(SETTINGS, "utf8"), "{not json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — the written file must survive a lane reset", () => {
|
||||
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
|
||||
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
|
||||
// Without -x that spares IGNORED files only — an untracked settings file
|
||||
// would be deleted, silently detaching the lane from its router. The same
|
||||
// entry also stops an API key from being committable.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
});
|
||||
|
||||
it("excludes it for a native lane too, not only a lane on a router", () => {
|
||||
// The blanked connection pair is load-bearing in its own right: losing it
|
||||
// to `git clean -fd` lets a USER-level router take over a lane that
|
||||
// explicitly asked for native.
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
});
|
||||
|
||||
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
|
||||
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
|
||||
// would be a path that cannot exist.
|
||||
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
|
||||
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
|
||||
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
|
||||
});
|
||||
|
||||
it("adds the entry once, however many times a router is applied", () => {
|
||||
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
applyRouterToLane(claudeLane(), "native");
|
||||
|
||||
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
|
||||
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
|
||||
});
|
||||
|
||||
it("still writes the settings file when the lane is not a git repo at all", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(claudeLane(), name);
|
||||
assert.ok(fs.existsSync(SETTINGS));
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — Codex lane", () => {
|
||||
it("writes a profile layer file, never the base config", () => {
|
||||
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(base, 'approval_policy = "never"\n');
|
||||
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
|
||||
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
assert.match(layer, /model_provider = "9router"/);
|
||||
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
|
||||
assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/);
|
||||
});
|
||||
|
||||
it("keeps the key out of the config file, naming an env var instead", () => {
|
||||
// Codex reads the key from the environment; writing it into config.toml
|
||||
// would put a secret in a file with no reason to hold one.
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
assert.ok(!layer.includes("sk-secret-value"));
|
||||
});
|
||||
|
||||
it("writes no layer file for the native profile", () => {
|
||||
// Native means "the base config as it already is" — a layer that overrode
|
||||
// nothing would still shadow a model the user set in the base.
|
||||
applyRouterToLane(codexLane(), "native");
|
||||
assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, "native.config.toml")), false);
|
||||
});
|
||||
|
||||
it("does not touch the lane's .claude directory", () => {
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
|
||||
it('never writes wire_api = "chat", which codex refuses to load at all', () => {
|
||||
// Verified against the installed codex-cli 0.149.0: `wire_api = "chat" is
|
||||
// no longer supported` is a hard config error, so a layer carrying it makes
|
||||
// the lane fail to start rather than fall back to anything.
|
||||
const { name } = saveNineRouter();
|
||||
applyRouterToLane(codexLane(), name);
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
assert.match(layer, /wire_api = "responses"/);
|
||||
assert.ok(!layer.includes('wire_api = "chat"'));
|
||||
});
|
||||
|
||||
it("names no env_key when the profile has no key to put in it", () => {
|
||||
// A local router needs no key — the reference 9Router provider has none.
|
||||
// Naming a variable that is never set makes codex refuse to start.
|
||||
const { name } = routers.createRouter({
|
||||
label: "Local gateway",
|
||||
baseUrl: "http://127.0.0.1:20128/v1",
|
||||
apiKey: "",
|
||||
model: "Highest",
|
||||
});
|
||||
applyRouterToLane(codexLane(), name);
|
||||
const layer = fs.readFileSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`), "utf8");
|
||||
assert.ok(!layer.includes("env_key"), layer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — an edit or a delete reaches the lanes already using it", () => {
|
||||
// Attaching COPIES the profile into the lane's own config, so a store-only
|
||||
// write is inert exactly where it matters: the key a lane authenticates with.
|
||||
|
||||
/** A real lane row in a directory of its own, already attached to `profile`. */
|
||||
function laneOn(profile, dirName) {
|
||||
const cwd = path.join(SUITE_ROOT, dirName);
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const lane = lanesLib.createLane({ title: dirName, cwd, router_profile: profile });
|
||||
applyRouterToLane(lane, profile);
|
||||
return { lane, settings: path.join(cwd, ".claude", "settings.local.json") };
|
||||
}
|
||||
|
||||
const envOf = (file) => JSON.parse(fs.readFileSync(file, "utf8")).env;
|
||||
|
||||
it("pushes a rotated key out to every claude lane on that profile", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const a = laneOn(name, "rotate-a");
|
||||
const b = laneOn(name, "rotate-b");
|
||||
assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
|
||||
|
||||
routers.updateRouter(name, { apiKey: "sk-rotated" });
|
||||
const result = reapplyToLanes(name);
|
||||
|
||||
assert.equal(result.updated, 2);
|
||||
assert.deepEqual(result.failed, []);
|
||||
assert.equal(envOf(a.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||
assert.equal(envOf(b.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||
});
|
||||
|
||||
it("leaves lanes on a different profile alone", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const other = routers.createRouter({
|
||||
label: "OpenRouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-other",
|
||||
model: "auto",
|
||||
});
|
||||
const mine = laneOn(name, "mixed-mine");
|
||||
const theirs = laneOn(other.name, "mixed-theirs");
|
||||
|
||||
routers.updateRouter(name, { apiKey: "sk-rotated" });
|
||||
reapplyToLanes(name);
|
||||
|
||||
assert.equal(envOf(mine.settings).ANTHROPIC_AUTH_TOKEN, "sk-rotated");
|
||||
assert.equal(envOf(theirs.settings).ANTHROPIC_AUTH_TOKEN, "sk-other");
|
||||
});
|
||||
|
||||
it("clears the deleted router out of the lane's settings and its DB row", () => {
|
||||
// Without this the lane keeps routing to an endpoint the user believes they
|
||||
// removed, with a key they believe they revoked.
|
||||
const { name } = saveNineRouter();
|
||||
const { lane, settings } = laneOn(name, "deleted-router");
|
||||
|
||||
routers.removeRouter(name);
|
||||
const result = detachFromLanes(name);
|
||||
|
||||
assert.equal(result.updated, 1);
|
||||
assert.equal(envOf(settings).ANTHROPIC_BASE_URL, "");
|
||||
assert.equal(envOf(settings).ANTHROPIC_AUTH_TOKEN, "");
|
||||
assert.equal(envOf(settings).ANTHROPIC_MODEL, undefined);
|
||||
assert.equal(lanesLib.getLane(lane.id).router_profile, null);
|
||||
});
|
||||
|
||||
it("deletes the codex layer file, so a later same-named profile cannot inherit it", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const layer = path.join(process.env.CODEX_HOME, `${name}.config.toml`);
|
||||
applyRouterToLane(codexLane(), name);
|
||||
assert.ok(fs.existsSync(layer));
|
||||
|
||||
routers.removeRouter(name);
|
||||
detachFromLanes(name);
|
||||
assert.equal(fs.existsSync(layer), false);
|
||||
});
|
||||
|
||||
it("reports a lane it could not update instead of failing the whole edit", () => {
|
||||
const { name } = saveNineRouter();
|
||||
const ok = laneOn(name, "partial-ok");
|
||||
const broken = laneOn(name, "partial-broken");
|
||||
fs.writeFileSync(broken.settings, "{ not json");
|
||||
|
||||
const result = reapplyToLanes(name);
|
||||
|
||||
assert.equal(result.updated, 1);
|
||||
assert.equal(result.failed.length, 1);
|
||||
assert.match(result.failed[0], /partial-broken/);
|
||||
assert.equal(envOf(ok.settings).ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
|
||||
});
|
||||
|
||||
it("has nothing to do for native, which is stored as no router at all", () => {
|
||||
laneOn("native", "native-lane");
|
||||
assert.deepEqual(reapplyToLanes("native"), { updated: 0, failed: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — what a spawn adds", () => {
|
||||
it("gives a codex lane the profile flag and the key in its environment", () => {
|
||||
const { name } = saveNineRouter();
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), ["-p", name]);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {
|
||||
AGENTHUB_ROUTER_KEY: "sk-secret-value",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds nothing for a claude lane, whose settings file already carries it", () => {
|
||||
const { name } = saveNineRouter();
|
||||
assert.deepEqual(routerArgvForSpawn({ ...claudeLane(), router_profile: name }), []);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...claudeLane(), router_profile: name }), {});
|
||||
});
|
||||
|
||||
it("adds nothing for a lane with no router, or one set to native", () => {
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: null }), []);
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: "native" }), []);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: "native" }), {});
|
||||
});
|
||||
|
||||
it("adds nothing when the profile a lane names has since been deleted", () => {
|
||||
// Deleting a profile must degrade the lane to its base config, never crash
|
||||
// the spawn that was about to start.
|
||||
const { name } = saveNineRouter();
|
||||
routers.removeRouter(name);
|
||||
assert.deepEqual(routerArgvForSpawn({ ...codexLane(), router_profile: name }), []);
|
||||
assert.deepEqual(routerEnvForSpawn({ ...codexLane(), router_profile: name }), {});
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,7 @@ function makePluginInstall(name, opts = {}) {
|
||||
source = { source: "url", url: "https://forge.example.com/Owner/Repo.git", ref: "main" },
|
||||
version = "1.5.1",
|
||||
knownMarketplaces = true,
|
||||
repository,
|
||||
} = opts;
|
||||
|
||||
const home = path.join(tmpDir, name, ".claude");
|
||||
@@ -74,7 +75,7 @@ function makePluginInstall(name, opts = {}) {
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(mktDir, ".claude-plugin", "marketplace.json"),
|
||||
JSON.stringify({ name: "sg", plugins: [{ name: "agenthub", source }] })
|
||||
JSON.stringify({ name: "sg", repository, plugins: [{ name: "agenthub", source }] })
|
||||
);
|
||||
if (knownMarketplaces) {
|
||||
fs.writeFileSync(
|
||||
@@ -128,10 +129,31 @@ describe("pluginMarketplaceUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the marketplace's repository for a relative source", () => {
|
||||
// This project's own entry is `"source": "./"` — the plugin IS the
|
||||
// marketplace repo. Without this the dashboard answered `no_remote` and
|
||||
// could never see a release on any plugin-cache install.
|
||||
makePluginInstall("relsrc", {
|
||||
source: "./",
|
||||
repository: "https://forge.example.com/Owner/Repo",
|
||||
});
|
||||
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), "https://forge.example.com/Owner/Repo");
|
||||
});
|
||||
|
||||
it("returns null for a relative source when the manifest declares no repository", () => {
|
||||
makePluginInstall("relnorepo", { source: "./plugins/agenthub" });
|
||||
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
|
||||
});
|
||||
|
||||
it("returns null for a github owner/repo shorthand rather than guessing a host", () => {
|
||||
// Inventing github.com here would produce an API URL that 404s and a
|
||||
// confidently wrong "up to date".
|
||||
makePluginInstall("ghshort", { source: { source: "github", repo: "Owner/Repo" } });
|
||||
// Not even when the marketplace declares a repository: a shorthand source
|
||||
// names a DIFFERENT repo, so borrowing the marketplace's is a wrong answer.
|
||||
makePluginInstall("ghshort", {
|
||||
source: { source: "github", repo: "Owner/Repo" },
|
||||
repository: "https://forge.example.com/Owner/Marketplace",
|
||||
});
|
||||
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
|
||||
});
|
||||
|
||||
|
||||
@@ -533,6 +533,16 @@ try {
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
|
||||
}
|
||||
// Which AI Router profile this lane runs against (see server/lib/ai-routers.js).
|
||||
// Nullable, and null means the same thing as 'native': the vendor's own API.
|
||||
// Set at creation only — like `tool`, it is absent from lanes.js's PATCHABLE,
|
||||
// because the router is already written into the lane's config on disk and a
|
||||
// PATCH would leave the row and that file disagreeing.
|
||||
try {
|
||||
db.prepare("SELECT router_profile FROM lanes LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE lanes ADD COLUMN router_profile TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: label a dashboard run with the lane it was started through, so the
|
||||
// Workspace page can show one lane's run history. Additive and nullable —
|
||||
|
||||
@@ -73,6 +73,7 @@ const importRouter = require("./routes/import");
|
||||
const updatesRouter = require("./routes/updates");
|
||||
const ccConfigRouter = require("./routes/cc-config");
|
||||
const codexConfigRouter = require("./routes/codex-config");
|
||||
const aiRoutersRouter = require("./routes/ai-routers");
|
||||
const runRouter = require("./routes/run");
|
||||
const alertsRouter = require("./routes/alerts");
|
||||
const webhooksRouter = require("./routes/webhooks");
|
||||
@@ -115,6 +116,7 @@ function createApp() {
|
||||
app.use("/api/updates", updatesRouter);
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
app.use("/api/ai-routers", aiRoutersRouter);
|
||||
app.use("/api/run", runRouter);
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @file ai-routers.js
|
||||
* @description Named AI Router profiles — an endpoint (9Router, OpenRouter, a
|
||||
* self-hosted gateway) plus the model names to use against it, saved once and
|
||||
* attached to a lane when that lane is created.
|
||||
*
|
||||
* A profile is exactly what the two CLIs need to talk to a router and nothing
|
||||
* more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring
|
||||
* the env keys Claude Code reads. Per-tier slots because a router may expose a
|
||||
* different id per tier — and the `model` field fills every one of them,
|
||||
* because the common case is a single symbolic name for everything.
|
||||
*
|
||||
* Profiles are stored one JSON file per profile under
|
||||
* `<CLAUDE_HOME>/agenthub-routers/` (dir 0700, files 0600), the same shape and
|
||||
* the same reasoning as `cc-profiles.js`: they hold a live API key, so **no
|
||||
* route ever returns key material** — `listRouters` reports `hasKey` and
|
||||
* nothing else about it.
|
||||
*
|
||||
* `native` is a built-in profile rather than a branch in the caller: it carries
|
||||
* empty connection fields and the vendor's own model names, so "switch back to
|
||||
* native" is the same single write path as "switch to a router". It cannot be
|
||||
* edited, removed, or shadowed by a saved profile.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
/**
|
||||
* A profile's `name` is its filename, so the charset is deliberately narrow.
|
||||
* What the user reads and edits is `label`, which accepts spaces and diacritics
|
||||
* precisely because it never touches a path. Same split as cc-profiles.js.
|
||||
*/
|
||||
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
|
||||
|
||||
const LABEL_MIN = 3;
|
||||
const LABEL_MAX = 60;
|
||||
|
||||
/** The per-tier model overrides Claude Code reads, in the order the UI shows them. */
|
||||
const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
const BUILTIN_NAME = "native";
|
||||
|
||||
/**
|
||||
* The vendor's own defaults, used when a lane is set back to native. Empty
|
||||
* connection fields rather than absent ones: the native reference settings.json
|
||||
* sets both to "", and an absent key would let a user-level router base URL
|
||||
* leak into a lane that asked for native.
|
||||
*/
|
||||
const NATIVE = Object.freeze({
|
||||
name: BUILTIN_NAME,
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "claude-sonnet-5",
|
||||
models: Object.freeze({
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
}),
|
||||
});
|
||||
|
||||
function err(code, message) {
|
||||
const e = new Error(message);
|
||||
e.code = code;
|
||||
return e;
|
||||
}
|
||||
|
||||
function routersDir() {
|
||||
return path.join(getClaudeHome(), "agenthub-routers");
|
||||
}
|
||||
|
||||
function routerFile(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||
}
|
||||
return path.join(routersDir(), `${name}.json`);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.agenthub-tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
/** Reduce any text to the narrow NAME_RE charset, for use as a filename. */
|
||||
function slugify(text) {
|
||||
const base = String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
return NAME_RE.test(base) ? base : "router";
|
||||
}
|
||||
|
||||
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
||||
function freeName(base) {
|
||||
let name = base;
|
||||
for (
|
||||
let n = 2;
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`));
|
||||
) {
|
||||
name = `${base}-${n}`;
|
||||
n += 1;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function normalizeLabel(label) {
|
||||
const clean = typeof label === "string" ? label.trim() : "";
|
||||
if (clean.length < LABEL_MIN || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||
throw err(
|
||||
"EBADLABEL",
|
||||
`label must be ${LABEL_MIN}-${LABEL_MAX} characters and no control characters`
|
||||
);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A router endpoint is an http(s) URL and nothing else. A `file:` or
|
||||
* `javascript:` base URL is never a router — it is a mistake, or an attempt to
|
||||
* point a CLI somewhere it should not go.
|
||||
*/
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
||||
if (!raw) throw err("EBADURL", "baseUrl is required");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw err("EBADURL", `not a valid URL: ${raw}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw err("EBADURL", "baseUrl must be http:// or https://");
|
||||
}
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** A model name is free text — a router names its own models — but not blank. */
|
||||
function normalizeModel(model) {
|
||||
const clean = typeof model === "string" ? model.trim() : "";
|
||||
if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||
throw err("EBADMODEL", "model must be 1-200 characters and no control characters");
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill every slot from `model`, keeping any the caller set explicitly. This
|
||||
* is what makes "one symbolic name for every tier" — the 9Router case — a
|
||||
* single field to type instead of six.
|
||||
*/
|
||||
function normalizeModels(model, models) {
|
||||
const given = models && typeof models === "object" ? models : {};
|
||||
const out = {};
|
||||
for (const slot of MODEL_SLOTS) {
|
||||
out[slot] = given[slot] === undefined ? model : normalizeModel(given[slot]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function assertNotBuiltin(name) {
|
||||
if (name === BUILTIN_NAME) {
|
||||
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one stored profile (key included), or null when it does not exist. */
|
||||
function readRouter(name) {
|
||||
const stored = readJson(routerFile(name));
|
||||
return stored && typeof stored === "object" ? stored : null;
|
||||
}
|
||||
|
||||
/** The browser-safe view: everything except the key, plus whether one is set. */
|
||||
function publicView(profile) {
|
||||
const { apiKey, ...rest } = profile;
|
||||
return { ...rest, hasKey: Boolean(apiKey) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every saved profile, with the built-in native one first.
|
||||
* @returns `{ items }` — key material is never included.
|
||||
*/
|
||||
function listRouters() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(routersDir())
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
/* nothing saved yet */
|
||||
}
|
||||
|
||||
const items = [publicView(NATIVE)];
|
||||
for (const name of names.sort()) {
|
||||
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readRouter(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
}
|
||||
if (!stored) continue;
|
||||
items.push(publicView({ ...stored, name, builtin: false }));
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile INCLUDING its key — for the server's own writers only. No route
|
||||
* may return this shape; `listRouters` is what the browser sees.
|
||||
*/
|
||||
function getRouter(name) {
|
||||
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
|
||||
const stored = readRouter(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
return { ...stored, name, builtin: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
|
||||
* models?:Record<string,string>}} input
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createRouter(input = {}) {
|
||||
const label = normalizeLabel(input.label);
|
||||
const slug = slugify(label);
|
||||
assertNotBuiltin(slug);
|
||||
|
||||
const model = normalizeModel(input.model);
|
||||
const profile = {
|
||||
label,
|
||||
baseUrl: normalizeBaseUrl(input.baseUrl),
|
||||
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
|
||||
model,
|
||||
models: normalizeModels(model, input.models),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const name = freeName(slug);
|
||||
writeJson(routerFile(name), profile);
|
||||
return publicView({ ...profile, name, builtin: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge changes into a saved profile. The `name` never changes — a lane stores
|
||||
* it, and renaming would orphan that lane. An omitted `apiKey` keeps the stored
|
||||
* one: the edit form cannot show a key, so it cannot send one back either.
|
||||
*/
|
||||
function updateRouter(name, changes = {}) {
|
||||
assertNotBuiltin(name);
|
||||
const stored = readRouter(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
|
||||
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
|
||||
const next = {
|
||||
...stored,
|
||||
label: changes.label === undefined ? stored.label : normalizeLabel(changes.label),
|
||||
baseUrl: changes.baseUrl === undefined ? stored.baseUrl : normalizeBaseUrl(changes.baseUrl),
|
||||
apiKey:
|
||||
typeof changes.apiKey === "string" && changes.apiKey.trim()
|
||||
? changes.apiKey.trim()
|
||||
: stored.apiKey,
|
||||
model,
|
||||
models:
|
||||
changes.model === undefined && changes.models === undefined
|
||||
? stored.models
|
||||
: normalizeModels(model, changes.models ?? stored.models),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeJson(routerFile(name), next);
|
||||
return publicView({ ...next, name, builtin: false });
|
||||
}
|
||||
|
||||
function removeRouter(name) {
|
||||
assertNotBuiltin(name);
|
||||
const file = routerFile(name);
|
||||
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
fs.rmSync(file);
|
||||
return { removed: name };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRouters,
|
||||
getRouter,
|
||||
createRouter,
|
||||
updateRouter,
|
||||
removeRouter,
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
NATIVE,
|
||||
};
|
||||
+42
-1
@@ -148,6 +148,7 @@ function createLane({
|
||||
base_branch = null,
|
||||
slug = null,
|
||||
tool = "claude",
|
||||
router_profile = null,
|
||||
} = {}) {
|
||||
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
|
||||
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
||||
@@ -157,7 +158,7 @@ function createLane({
|
||||
validateTool(tool);
|
||||
const info = db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run(
|
||||
title,
|
||||
@@ -169,6 +170,7 @@ function createLane({
|
||||
base_branch,
|
||||
slug,
|
||||
tool,
|
||||
router_profile || null,
|
||||
nowIso()
|
||||
);
|
||||
return getLane(info.lastInsertRowid);
|
||||
@@ -205,6 +207,43 @@ function updateLane(id, patch = {}) {
|
||||
return getLane(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which AI Router profile a lane runs against. Deliberately its own
|
||||
* function rather than a `PATCHABLE` field: the router is also written into the
|
||||
* lane's config on disk (`server/lib/router-apply.js`), so the column may only
|
||||
* move together with that write — a bare PATCH would leave the row claiming a
|
||||
* router the lane's settings file has never heard of.
|
||||
*
|
||||
* @param {number} id - The lane id.
|
||||
* @param {string|null} profile - A profile name, or null/"native" for the
|
||||
* vendor's own API.
|
||||
*/
|
||||
function setRouterProfile(id, profile) {
|
||||
const value = !profile || profile === "native" ? null : String(profile);
|
||||
db.prepare("UPDATE lanes SET router_profile = ?, updated_at = ? WHERE id = ?").run(
|
||||
value,
|
||||
nowIso(),
|
||||
id
|
||||
);
|
||||
return getLane(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every lane attached to one AI Router profile. Editing or deleting a profile
|
||||
* has to reach the lanes that copied it (`server/lib/router-apply.js`), and this
|
||||
* is how those lanes are found.
|
||||
*
|
||||
* @param {string} profile - A profile name. `native` matches nothing: it is
|
||||
* stored as NULL, and a lane on the vendor's own API has nothing to update.
|
||||
*/
|
||||
function lanesByRouter(profile) {
|
||||
if (!profile || profile === "native") return [];
|
||||
return db
|
||||
.prepare("SELECT * FROM lanes WHERE router_profile = ? ORDER BY id ASC")
|
||||
.all(String(profile))
|
||||
.map(hydrate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write provisioning-time facts that `PATCH /api/lanes/:id` must never reach:
|
||||
* `base_branch`, resolved after `git worktree add` succeeds, and the runtime
|
||||
@@ -752,6 +791,8 @@ module.exports = {
|
||||
updateLane,
|
||||
deleteLane,
|
||||
setStage,
|
||||
setRouterProfile,
|
||||
lanesByRouter,
|
||||
recordDetection,
|
||||
clearLane,
|
||||
recoverInterruptedProvisioning,
|
||||
|
||||
+18
-2
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
|
||||
const tmux = require("./tmux");
|
||||
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
||||
|
||||
let dashboardRuns = null;
|
||||
try {
|
||||
@@ -98,8 +99,13 @@ function buildCodexArgv({
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
}) {
|
||||
const argv = ["codex"];
|
||||
// Before the subcommand and before the prompt: `-p` layers
|
||||
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
|
||||
// lands after the positional prompt is not an option any more.
|
||||
argv.push(...routerArgvForSpawn({ tool: "codex", router_profile: routerProfile }));
|
||||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||||
else if (resumeLast) argv.push("resume", "--last");
|
||||
if (model) argv.push("-m", model);
|
||||
@@ -127,6 +133,8 @@ function buildArgv(args) {
|
||||
* @param {string} [args.resumeSessionId]
|
||||
* @param {boolean} [args.resumeLast]
|
||||
* @param {string} [args.initialPrompt]
|
||||
* @param {string} [args.routerProfile] AI Router profile the LANE runs against
|
||||
* (`lanes.router_profile`), never a request field.
|
||||
*/
|
||||
function spawnRun(args) {
|
||||
const {
|
||||
@@ -141,6 +149,7 @@ function spawnRun(args) {
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
} = args || {};
|
||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
@@ -208,6 +217,7 @@ function spawnRun(args) {
|
||||
resumeSessionId,
|
||||
resumeLast,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
});
|
||||
|
||||
const record = () => {
|
||||
@@ -241,7 +251,13 @@ function spawnRun(args) {
|
||||
// from inside it then resolves the lane exactly, instead of matching on cwd —
|
||||
// which is ambiguous now that one directory can back both a `claude` lane and
|
||||
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
|
||||
const env = { AGENTHUB_LANE_ID: String(laneId) };
|
||||
// A codex lane on an AI Router also needs the key its layer's `env_key`
|
||||
// names. A claude lane needs nothing here — its settings file already carries
|
||||
// the router — and a lane on the vendor's own API needs nothing either.
|
||||
const env = {
|
||||
AGENTHUB_LANE_ID: String(laneId),
|
||||
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
tmux.newSession({ name: id, cwd, argv, env });
|
||||
@@ -253,7 +269,7 @@ function spawnRun(args) {
|
||||
// `--resume` and an initial prompt would never be typed, while the API
|
||||
// still answered 200. Run the argv in the pane the user already sees
|
||||
// instead of erroring or opening a second session.
|
||||
tmux.sendCommand(id, argv, env);
|
||||
tmux.sendCommand(id, argv, env, cwd);
|
||||
record();
|
||||
}
|
||||
// Pane is running something (a live `claude`, an editor, a build): adopt
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @file router-apply.js
|
||||
* @description Attaches an AI Router profile (see `ai-routers.js`) to one lane.
|
||||
*
|
||||
* The two CLIs are configured in different places, and the difference is
|
||||
* theirs, not a choice made here:
|
||||
*
|
||||
* - **Claude Code** reads an `env` block out of the settings layers, so a
|
||||
* router is eight env keys merged into the lane's own
|
||||
* `.claude/settings.local.json`. `settings.local.json` and not
|
||||
* `settings.json` because the value written includes an API key, and the
|
||||
* `.local` file is the one Claude Code's own convention keeps out of git.
|
||||
*
|
||||
* - **Codex** has no project-level config at all — `codex doctor` reports a
|
||||
* single loaded file, `~/.codex/config.toml`. What it does have is
|
||||
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of that
|
||||
* base. So a profile becomes a layer file, the base is never touched, and
|
||||
* the lane's spawn passes the flag. Codex takes the key from the environment
|
||||
* variable named by `env_key`, so the key stays out of the file entirely and
|
||||
* reaches the pane through tmux's own `-e`, never as typed text.
|
||||
*
|
||||
* Target directories come from the lane row, never from a request body — the
|
||||
* same narrowing every other lane write path in this codebase uses.
|
||||
*
|
||||
* Attaching COPIES a profile, so the store is not the single source of truth
|
||||
* once a lane exists. `reapplyToLanes`/`detachFromLanes` are what keep an edit
|
||||
* or a delete in Settings from being silently inert on the lanes using it.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const routers = require("./ai-routers");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { lanesByRouter, setRouterProfile } = require("./lanes");
|
||||
|
||||
/**
|
||||
* The env var Codex is told to read the router key from. One fixed name rather
|
||||
* than one per profile: a Codex pane runs exactly one profile, so a second name
|
||||
* would only create the chance of the wrong one being set.
|
||||
*/
|
||||
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
|
||||
|
||||
/** The eight keys this feature owns in a lane's settings file, and nothing else. */
|
||||
function envForProfile(profile) {
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
|
||||
ANTHROPIC_MODEL: profile.model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models.haiku,
|
||||
ANTHROPIC_SMALL_FAST_MODEL: profile.models.smallFast,
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: profile.models.subagent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a profile, or resolve to null when it has since been deleted. Used on
|
||||
* the spawn path, where a missing profile must degrade the lane to its base
|
||||
* configuration rather than refuse to start it.
|
||||
*/
|
||||
function readOrNull(name) {
|
||||
if (!name || name === routers.BUILTIN_NAME) return null;
|
||||
try {
|
||||
return routers.getRouter(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's own git directory. For a normal clone that is `<cwd>/.git`; for a
|
||||
* LINKED WORKTREE — which is what every managed lane is — `.git` is a pointer
|
||||
* FILE, and joining `info/exclude` onto it names a path that cannot exist.
|
||||
*
|
||||
* @returns {string|null} absolute git dir, or null when this is not a repo
|
||||
*/
|
||||
function gitDirOf(laneDir) {
|
||||
const dotGit = path.join(laneDir, ".git");
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(dotGit);
|
||||
} catch {
|
||||
return null; // not a git repo — a perfectly normal adopted directory
|
||||
}
|
||||
if (stat.isDirectory()) return dotGit;
|
||||
const match = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
|
||||
return match ? path.resolve(laneDir, match[1].trim()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently keep `.claude/settings.local.json` out of git, for two reasons
|
||||
* that both matter:
|
||||
*
|
||||
* - `agenthub lanes reset` runs `git clean -fd` (`server/lib/worktree.js`).
|
||||
* Without `-x` that spares IGNORED files only, so an untracked settings file
|
||||
* is deleted and the lane silently loses its router.
|
||||
* - The file holds an API key. Untracked means `git add -A` can commit it.
|
||||
*
|
||||
* Best effort: a lane that is not a repo, or a git dir that cannot be written,
|
||||
* must not fail an otherwise-successful attach.
|
||||
*/
|
||||
function excludeSettingsFromGit(laneDir) {
|
||||
const line = ".claude/settings.local.json";
|
||||
try {
|
||||
const gitDir = gitDirOf(laneDir);
|
||||
if (!gitDir) return;
|
||||
const excludePath = path.join(gitDir, "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
if (existing.split("\n").includes(line)) return;
|
||||
fs.writeFileSync(
|
||||
excludePath,
|
||||
existing && !existing.endsWith("\n") ? `${existing}\n${line}\n` : `${existing}${line}\n`
|
||||
);
|
||||
} catch {
|
||||
/* best effort — see above */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the eight keys into the lane's settings, leaving every other key —
|
||||
* including every other env var — exactly as it was.
|
||||
*
|
||||
* `patchSettings` does this already and does it better than a local merge
|
||||
* would: per-key deep merge, a size ceiling, `__proto__`-style segments
|
||||
* refused, an atomic write, a timestamped backup, and a file that is not valid
|
||||
* JSON reported (`EBADJSON`) instead of overwritten. It resolves
|
||||
* `project-local` to exactly `<cwd>/.claude/settings.local.json`. The
|
||||
* caller-chosen-cwd rule it is written against is a ROUTE rule; `lane.cwd`
|
||||
* comes from the DB row, which is the shape that rule allows.
|
||||
*
|
||||
* What it does not do is set a mode, because nothing else it writes holds a
|
||||
* secret. This one does, so the file and the backup it just made of the
|
||||
* previous one are both tightened afterwards.
|
||||
*/
|
||||
function applyToClaudeLane(lane, profile) {
|
||||
// Native is not simply "no keys". The two halves go opposite ways:
|
||||
//
|
||||
// - The CONNECTION pair is BLANKED, not removed. An absent key at project
|
||||
// scope means the user-level one applies, so dropping these would let a
|
||||
// user-level router take over a lane that explicitly asked for native.
|
||||
// - The MODEL names are REMOVED. Writing them would pin every lane — including
|
||||
// one created by a user who never opened this tab — to whatever names this
|
||||
// file shipped with, overriding the user-level choice that is the actual
|
||||
// source of truth for native.
|
||||
const env = envForProfile(profile);
|
||||
const set = {};
|
||||
let unset = [];
|
||||
if (profile.builtin) {
|
||||
const connection = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||
for (const key of connection) set[`env.${key}`] = "";
|
||||
unset = Object.keys(env)
|
||||
.filter((key) => !connection.includes(key))
|
||||
.map((key) => `env.${key}`);
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(env)) set[`env.${key}`] = value;
|
||||
}
|
||||
|
||||
const result = patchSettings({ scope: "project-local", cwd: lane.cwd, set, unset });
|
||||
// Both branches, not just the router one. A native lane's blanked connection
|
||||
// pair is load-bearing too — `git clean -fd` deleting it would let a
|
||||
// user-level router quietly take over a lane that asked for native.
|
||||
excludeSettingsFromGit(lane.cwd);
|
||||
for (const file of [result.path, result.backupPath]) {
|
||||
if (!file) continue;
|
||||
try {
|
||||
fs.chmodSync(file, 0o600);
|
||||
} catch {
|
||||
// Best effort: a write that landed must not be reported as a failure
|
||||
// because the mode could not be tightened afterwards.
|
||||
}
|
||||
}
|
||||
return { tool: "claude", file: result.path, profile: profile.name };
|
||||
}
|
||||
|
||||
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
||||
function tomlString(value) {
|
||||
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on
|
||||
* top of the base config. The base file is never read or written here.
|
||||
*/
|
||||
function applyToCodexLane(lane, profile) {
|
||||
const file = path.join(getCodexHome(), `${profile.name}.config.toml`);
|
||||
const body =
|
||||
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
|
||||
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
||||
`model = ${tomlString(profile.model)}\n` +
|
||||
`model_provider = ${tomlString(profile.name)}\n\n` +
|
||||
`[model_providers.${profile.name}]\n` +
|
||||
`name = ${tomlString(profile.label)}\n` +
|
||||
`base_url = ${tomlString(profile.baseUrl)}\n` +
|
||||
// `env_key` only when there is a key to name. A local router (the 9Router
|
||||
// reference config is exactly this) needs none, and naming a variable that
|
||||
// is never set makes Codex refuse to start.
|
||||
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
|
||||
// no longer supported`, a hard config error before the first request. The
|
||||
// user's own working 9Router provider uses "responses" too.
|
||||
`wire_api = "responses"\n`;
|
||||
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, body, { mode: 0o600 });
|
||||
return { tool: "codex", file, profile: profile.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `profileName` to `lane`. Called once, when the lane is created.
|
||||
*
|
||||
* @param {{id:number, cwd:string, tool:string}} lane the lane row
|
||||
* @param {string} profileName a saved profile's name, or `"native"`
|
||||
*/
|
||||
function applyRouterToLane(lane, profileName) {
|
||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
||||
if (lane.tool === "codex") {
|
||||
// Native is the base config as it already stands. A layer that overrode
|
||||
// nothing would still shadow whatever model the user set in the base.
|
||||
if (profile.builtin) return { tool: "codex", file: null, profile: profile.name };
|
||||
return applyToCodexLane(lane, profile);
|
||||
}
|
||||
return applyToClaudeLane(lane, profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra argv for a lane's spawn. Codex needs `-p <name>`; a Claude lane needs
|
||||
* nothing, its settings file already carries the router.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function routerArgvForSpawn(lane) {
|
||||
if (!lane || lane.tool !== "codex") return [];
|
||||
const profile = readOrNull(lane.router_profile);
|
||||
return profile ? ["-p", profile.name] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra environment for a lane's spawn — the key Codex's `env_key` names.
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
function routerEnvForSpawn(lane) {
|
||||
if (!lane || lane.tool !== "codex") return {};
|
||||
const profile = readOrNull(lane.router_profile);
|
||||
return profile && profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the attach for every lane already on `profileName`.
|
||||
*
|
||||
* Attaching COPIES the profile — a claude lane's key and base URL live in its
|
||||
* own settings file from then on. So an edit that stopped here would be inert
|
||||
* where it matters most: rotating a leaked key in Settings would leave every
|
||||
* claude lane authenticating with the revoked one, forever, with no in-product
|
||||
* way to notice or fix it.
|
||||
*
|
||||
* Per-lane best effort: a lane whose directory is gone, or whose settings file
|
||||
* a human has since broken, must not fail the edit for every other lane.
|
||||
*
|
||||
* @returns {{updated:number, failed:string[]}}
|
||||
*/
|
||||
function reapplyToLanes(profileName) {
|
||||
const failed = [];
|
||||
let updated = 0;
|
||||
for (const lane of lanesByRouter(profileName)) {
|
||||
try {
|
||||
applyRouterToLane(lane, profileName);
|
||||
updated += 1;
|
||||
} catch (e) {
|
||||
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach `profileName` from every lane using it, and delete the Codex layer
|
||||
* file it wrote.
|
||||
*
|
||||
* Deleting a profile has to reach the lanes for the same reason an edit does,
|
||||
* and more urgently: a claude lane keeps the deleted router's key and base URL
|
||||
* in its settings file and would go on routing to a dead endpoint with a key
|
||||
* the user believes they have removed. Each lane is reset to native — which now
|
||||
* UNSETS those keys — and its `router_profile` cleared.
|
||||
*
|
||||
* The layer file matters too: left behind, a later profile that slugifies to
|
||||
* the same name silently inherits this one's `base_url`.
|
||||
*/
|
||||
function detachFromLanes(profileName) {
|
||||
const failed = [];
|
||||
let updated = 0;
|
||||
for (const lane of lanesByRouter(profileName)) {
|
||||
try {
|
||||
applyRouterToLane(lane, routers.BUILTIN_NAME);
|
||||
setRouterProfile(lane.id, null);
|
||||
updated += 1;
|
||||
} catch (e) {
|
||||
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.rmSync(path.join(getCodexHome(), `${profileName}.config.toml`), { force: true });
|
||||
} catch {
|
||||
/* best effort — an orphan layer is a leak, not a failure to report */
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyRouterToLane,
|
||||
reapplyToLanes,
|
||||
detachFromLanes,
|
||||
routerArgvForSpawn,
|
||||
routerEnvForSpawn,
|
||||
CODEX_KEY_ENV,
|
||||
};
|
||||
+26
-22
@@ -6,10 +6,11 @@
|
||||
* `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes shell`). Never builds a
|
||||
* shell string — every call is `execFileSync("tmux", [...argv])` with an
|
||||
* explicit argument array (matches this repo's rule for git in worktree.js).
|
||||
* The one place a command line is composed is `sendCommand`, which types into
|
||||
* an existing pane's shell: there the shell IS the consumer, so every argument
|
||||
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
|
||||
* `sendText` is the same primitive for the opposite case — a pane running an
|
||||
* No call composes a shell command line at all, `sendCommand` included: it
|
||||
* takes over an idle pane with `respawn-pane -- <argv>` rather than typing a
|
||||
* quoted line into that pane's shell, which keeps an API key passed in `env`
|
||||
* out of the pane's rendered text, its scrollback and the user's shell history.
|
||||
* `sendText` is the one primitive that does send keystrokes — a pane running an
|
||||
* interactive chat program that reads stdin as a message, not a shell.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -88,27 +89,30 @@ function paneCommand(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** POSIX single-quote escaping — the pane is a shell, so argv must be quoted. */
|
||||
function shellQuote(arg) {
|
||||
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type `argv` into an existing session's pane and press Enter. Only ever
|
||||
* called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends
|
||||
* the string literally so no character is read as a tmux key name.
|
||||
* Run `argv` in an existing session's pane. Only ever called when the pane sits
|
||||
* at a shell prompt (see `paneCommand`), so what `-k` kills is an idle shell.
|
||||
*
|
||||
* `env` rides along as a shell assignment prefix on the SAME line — tmux's own
|
||||
* `-e` applies only at session creation, and a shell that is already running
|
||||
* takes its environment from the command line. The name is never quoted (bash
|
||||
* only recognizes an assignment when the `=` is unquoted); the value is.
|
||||
* `respawn-pane` and not `send-keys`, because `env` may carry a secret — an AI
|
||||
* Router's API key, which Codex reads from the variable its `env_key` names.
|
||||
* Typing it would put a `KEY='sk-live-…' codex …` line into the pane's rendered
|
||||
* text, which the dashboard streams to every attached browser, into the tmux
|
||||
* scrollback, and into the user's shell history. `-e` hands the value to the
|
||||
* tmux server through argv instead, exactly as `newSession` already does, and
|
||||
* the pane never renders it. Running argv directly also means no shell to quote
|
||||
* for, so the whole class of quoting bugs goes with it.
|
||||
*/
|
||||
function sendCommand(name, argv, env) {
|
||||
const prefix = Object.entries(env || {})
|
||||
.map(([key, value]) => `${key}=${shellQuote(value)} `)
|
||||
.join("");
|
||||
execImpl(["send-keys", "-t", name, "-l", prefix + argv.map(shellQuote).join(" ")]);
|
||||
execImpl(["send-keys", "-t", name, "Enter"]);
|
||||
function sendCommand(name, argv, env, cwd) {
|
||||
execImpl([
|
||||
"respawn-pane",
|
||||
"-k",
|
||||
"-t",
|
||||
name,
|
||||
...(cwd ? ["-c", cwd] : []),
|
||||
...envFlags(env),
|
||||
"--",
|
||||
...argv,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
* rather than reporting a false "up to date".
|
||||
*
|
||||
* **Two independent paths, chosen by install kind.** A checkout derives the API
|
||||
* URL from its `origin` remote. A plugin-cache install does NOT: when a
|
||||
* marketplace entry declares `"source": "url"` (this project's own does) Claude
|
||||
* Code unpacks an archive, so the cache directory has no `.git` and there is no
|
||||
* URL from its `origin` remote. A plugin-cache install does NOT: Claude Code
|
||||
* unpacks an archive, so the cache directory has no `.git` and there is no
|
||||
* remote to read. That path is answered entirely without git — the repository
|
||||
* URL comes from the marketplace manifest on disk (`pluginMarketplaceUrl`) and
|
||||
* URL comes from the marketplace manifest on disk (`pluginMarketplaceUrl`,
|
||||
* which for this project's own relative `"source": "./"` entry resolves to the
|
||||
* manifest's top-level `repository`) and
|
||||
* the package.json version is the only comparison signal. Getting this wrong is
|
||||
* what shipped in 1.5.1: the `.git` probe ran first, returned "not a git clone",
|
||||
* and skipped the release layer, so `update_available` stayed false however many
|
||||
@@ -346,11 +347,24 @@ function pluginMarketplaceUrl(marketplace, plugin) {
|
||||
const entry = (manifest?.plugins || []).find((p) => p && p.name === plugin);
|
||||
if (!entry) return null;
|
||||
|
||||
// `source` is either a string (a bare URL or owner/repo) or an object with
|
||||
// its own `source` discriminator plus `url`/`repo`.
|
||||
// `source` is either a string (a bare URL, a relative path, or owner/repo)
|
||||
// or an object with its own `source` discriminator plus `url`/`repo`.
|
||||
const src = entry.source;
|
||||
if (typeof src === "string") return src.includes("://") ? src : null;
|
||||
if (typeof src === "string" && src.includes("://")) return src;
|
||||
if (src && typeof src.url === "string" && src.url) return src.url;
|
||||
|
||||
// A RELATIVE source ("./", "./plugins/x") means the plugin ships inside the
|
||||
// marketplace repository itself — this project's own entry is exactly that
|
||||
// ("source": "./") — so the manifest's declared `repository` is where its
|
||||
// releases are published. Without this fallback every plugin-cache install
|
||||
// of this very plugin answered `no_remote` and could never see a release.
|
||||
const relative = typeof src === "string" && /^\.{0,2}\//.test(src);
|
||||
if (relative) {
|
||||
const repo = manifest?.repository;
|
||||
const url = typeof repo === "string" ? repo : repo?.url;
|
||||
if (typeof url === "string" && url.includes("://")) return url;
|
||||
}
|
||||
|
||||
// A `github`-style `repo: "owner/repo"` shorthand carries no host; this
|
||||
// build is distributed from its own forge, so rather than guessing
|
||||
// github.com the caller reports "remote_not_http" and prints the manual
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file ai-routers.js (routes)
|
||||
* @description CRUD for AI Router profiles — the endpoints behind the
|
||||
* **AI Router** tab of Settings. CHOOSING a profile for a lane is not here: it
|
||||
* happens once, when the lane is created (`POST /api/lanes/:id/router/apply`),
|
||||
* because the write target is the lane's own directory.
|
||||
*
|
||||
* An edit or a delete here does reach the lanes that already chose it, though.
|
||||
* Attaching COPIES the profile into the lane's own config, so a store-only
|
||||
* write would be inert exactly where it matters most — a rotated key would
|
||||
* never reach the lanes still authenticating with the revoked one.
|
||||
*
|
||||
* No response on this router carries key material. `GET /` returns the store's
|
||||
* public view (`hasKey: true|false`), and there is deliberately no
|
||||
* `GET /:name` — a route that returned one profile in full would be the one
|
||||
* way an API key could leave this process.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const routers = require("../lib/ai-routers");
|
||||
const { reapplyToLanes, detachFromLanes } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ERR_TO_STATUS = {
|
||||
EBADNAME: 400,
|
||||
EBADLABEL: 400,
|
||||
EBADURL: 400,
|
||||
EBADMODEL: 400,
|
||||
EBADJSON: 409,
|
||||
EBUILTIN: 409,
|
||||
ENOTFOUND: 404,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
const status = ERR_TO_STATUS[e.code] || 500;
|
||||
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
// GET /api/ai-routers — every profile, built-in `native` first. Never a key.
|
||||
router.get("/", (_req, res) => {
|
||||
try {
|
||||
res.json(routers.listRouters());
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-routers — body: { label, baseUrl, apiKey?, model, models? }
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
res.status(201).json(routers.createRouter(req.body || {}));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/ai-routers/:name — same fields, all optional. An omitted `apiKey`
|
||||
// keeps the stored one; the edit form cannot show a key, so it cannot echo one.
|
||||
//
|
||||
// Attaching COPIES a profile into the lane's own config, so an edit that only
|
||||
// wrote the store here would be inert: rotating a leaked key would leave every
|
||||
// claude lane on the revoked one. `reapplyToLanes` pushes the change out, and
|
||||
// reports per-lane failures instead of failing the edit.
|
||||
router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const saved = routers.updateRouter(req.params.name, req.body || {});
|
||||
res.json({ ...saved, lanes: reapplyToLanes(saved.name) });
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/ai-routers/:name — every lane using it is reset to native, in its
|
||||
// config on disk as well as in the DB. Leaving the lanes alone would keep the
|
||||
// deleted router's key and base URL live in each claude lane's settings file.
|
||||
router.delete("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const removed = routers.removeRouter(req.params.name);
|
||||
res.json({ ...removed, lanes: detachFromLanes(req.params.name) });
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -25,6 +25,7 @@ const laneFeatures = require("../lib/lane-features");
|
||||
const proofLib = require("../lib/proof");
|
||||
const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/pty-run");
|
||||
const { applyRouterToLane } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
@@ -618,6 +619,32 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:id/router/apply — point this lane at one AI Router profile (or back at
|
||||
* `native`). Body: `{ profile: "<name>" }`.
|
||||
*
|
||||
* The profile name is the only thing taken from the request: what gets written,
|
||||
* and where, is derived from the lane row (`server/lib/router-apply.js`). The
|
||||
* lane's `router_profile` column is set in the same call so a later spawn knows
|
||||
* which `-p` layer a codex pane needs.
|
||||
*/
|
||||
router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
const profile = (req.body || {}).profile;
|
||||
if (typeof profile !== "string" || !profile) {
|
||||
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
|
||||
}
|
||||
try {
|
||||
const result = applyRouterToLane(lane, profile);
|
||||
lanesLib.setRouterProfile(lane.id, profile);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
|
||||
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect a Node.js project at this lane's OWN directory and scaffold
|
||||
* .agenthub/profile/ if one is found — the HTTP equivalent of
|
||||
@@ -1311,6 +1338,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// asked for the other CLI would otherwise get a pane that doesn't
|
||||
// match the lane's badge, its history, or its binary probe.
|
||||
tool: current.tool || "claude",
|
||||
// Same reasoning as `tool`: the router is the lane's fact, written
|
||||
// into its config when the lane was created.
|
||||
routerProfile: current.router_profile || null,
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
effort: body.effort,
|
||||
|
||||
@@ -242,6 +242,8 @@ router.post("/", (req, res) => {
|
||||
laneId,
|
||||
cwd,
|
||||
tool: lane?.tool || "claude",
|
||||
// The lane's own router, exactly like its tool — never a body field.
|
||||
routerProfile: lane?.router_profile || null,
|
||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
|
||||
Reference in New Issue
Block a user