server/claude-account-api.test.ts
Rama main · réplica de lectura del repositorio original.
// Real HTTP boundary, disposable home/data, synthetic Claude accounts only.
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { SessionRegistry } from "./sessions.ts";
import { removeTempDir, waitForExit } from "./testing/cleanup.ts";
import { freePortBlock } from "./testing/ports.ts";
import { openSse } from "./testing/sse.ts";
const SERVER_DIR = dirname(fileURLToPath(import.meta.url));
const MODEL = "claude-sonnet-5";
let home: string;
let dataDir: string;
let cli: string;
let base: string;
let clientToken: string;
let child: ChildProcess;
let stderr = "";
async function api(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const response = await fetch(`${base}${path}`, {
method,
headers: { ...(body === undefined ? {} : { "content-type": "application/json" }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: response.status, body: await response.json() as any };
}
function saved() {
return JSON.parse(readFileSync(join(dataDir, "config.json"), "utf8"));
}
async function addAccount(displayName: string) {
const response = await api("POST", "/api/instances/claude-accounts", { displayName });
expect(response.status, JSON.stringify(response.body)).toBe(201);
const instance = response.body.instances.find((row: any) => row.instanceId === response.body.instanceId);
return { id: response.body.instanceId as string, dir: instance.claudeAccount.configDir as string, instance };
}
async function addBot(instanceId: string) {
const response = await api("POST", "/api/bots", {
name: "Fixture bot", modelSelection: { instanceId, model: MODEL }, requireAvailableModel: true,
});
expect(response.status, JSON.stringify(response.body)).toBe(201);
return response.body.bot;
}
async function addBotOn(instanceId: string, model: string) {
const response = await api("POST", "/api/bots", {
name: "Endpoint bot", modelSelection: { instanceId, model }, requireAvailableModel: true,
});
expect(response.status, JSON.stringify(response.body)).toBe(201);
return response.body.bot;
}
async function bot(id: string) {
return (await api("GET", "/api/bots?messages=30")).body.bots.find((row: any) => row.id === id);
}
async function idle(id: string) {
await expect.poll(async () => (await bot(id))?.busy, { timeout: 10_000 }).toBe(false);
}
beforeAll(async () => {
home = mkdtempSync(join(tmpdir(), "omb-claude-account-api-"));
dataDir = join(home, ".openmausbot");
mkdirSync(dataDir, { recursive: true });
cli = join(home, "fixture-claude.mjs");
// The official-style auth probe stays synthetic; turns reuse the repository
// fake CLI. A per-account marker makes only that account hang until stopped.
writeFileSync(cli, `#!/usr/bin/env node
import { existsSync, mkdirSync } from "node:fs";
import { basename, join } from "node:path";
const dir = process.env.CLAUDE_CONFIG_DIR || join(process.env.HOME, ".claude");
if (process.argv[2] === "auth") {
console.log(JSON.stringify({ loggedIn: true, email: basename(dir) + "@example.test", orgName: "Fixture team", accessToken: "fixture-must-not-leak" }));
process.exit(0);
}
if (process.argv[2] !== "--version") {
mkdirSync(dir, { recursive: true });
process.env.FAKE_CLAUDE_MODE = existsSync(join(dir, "hang")) ? "hang" : "happy";
process.env.FAKE_CLAUDE_DUMP = join(dir, "spawn.json");
process.env.FAKE_CLAUDE_REPLIES = JSON.stringify(["account:" + basename(dir)]);
}
await import(${JSON.stringify(pathToFileURL(join(SERVER_DIR, "testing", "fake-claude-cli.ts")).href)});
`, { mode: 0o755 });
writeFileSync(join(dataDir, "config.json"), JSON.stringify({
instances: {
ghost: { driver: "not-a-real-driver", displayName: "Preserved shadow" },
// The `claude` product fleet auto-adds these IDs unless configured.
// Pin shadows so this test never probes an installed provider CLI.
...Object.fromEntries(["cursor", "githubCopilot", "openaiCompat", "qwen", "hermes", "pi"].map(id => [id, { driver: "not-a-real-driver" }])),
"broken-account": {
driver: "claudeAgent", displayName: "Repairable shadow", config: { cli, configDir: "relative/saved-directory" },
},
codex: { driver: "codex", config: { cli: join(SERVER_DIR, "testing", "fake-codex-app-server.ts") } },
claude: {
driver: "claudeAgent", displayName: "Default fixture", config: { cli },
environment: { ANTHROPIC_AUTH_TOKEN: "fixture-base-secret", BASE_ONLY_SENTINEL: "base-only" },
},
},
}));
const sessions = new SessionRegistry({ file: join(dataDir, "sessions.json") });
const paired = sessions.exchange({ code: sessions.openPairing({ scopes: ["client"] }).code, label: "Fixture client", source: "fixture" });
if (!paired.ok) throw new Error("fixture session failed");
clientToken = paired.token;
const port = await freePortBlock([0, 1]);
base = `http://127.0.0.1:${port}`;
const offlinePrelude = `data:text/javascript,${encodeURIComponent('let refreshes = 0; globalThis.fetch = async (url) => String(url).startsWith("https://catalog-fixture.example/") ? Response.json({ data: [{ id: "model-a" }, { id: "model-b" }] }) : String(url).startsWith("https://growing-fixture.example/") ? Response.json({ data: [{ id: "model-a" }, ...(refreshes++ ? [{ id: "model-new" }] : [])] }) : new Response("offline fixture", { status: 503 });')}`;
child = spawn(process.execPath, ["--import", offlinePrelude, join(SERVER_DIR, "index.ts")], {
cwd: join(SERVER_DIR, ".."),
env: {
PATH: process.env.PATH,
...(process.env.PATHEXT ? { PATHEXT: process.env.PATHEXT } : {}),
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
HOME: home, USERPROFILE: home, OMB_DATA_DIR: dataDir,
OMB_PORT: String(port), OMB_WEBHOOK_PORT: String(port + 1), OMB_SSE_HEARTBEAT_MS: "50",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.resume();
child.stderr?.on("data", chunk => { stderr = (stderr + chunk).slice(-16_384); });
const deadline = Date.now() + 20_000;
for (;;) {
if (child.exitCode !== null) throw new Error(`fixture exited: ${stderr}`);
try { if ((await api("GET", "/api/health")).body.pid === child.pid) break; } catch {}
if (Date.now() > deadline) throw new Error(`fixture did not start: ${stderr}`);
await new Promise(resolve => setTimeout(resolve, 100));
}
}, 30_000);
afterAll(async () => {
await waitForExit(child, { signal: "SIGTERM" });
if (home) await removeTempDir(home);
if (child) expect(child.exitCode === 0 || (process.platform === "win32" && child.signalCode === "SIGTERM"), stderr).toBe(true);
});
describe("Claude account HTTP API", () => {
it.each(["claude", "codex"])("connects %s without a model and keeps independent bot choices", async engine => {
const created = await api("POST", `/api/instances/${engine}-endpoint`, { displayName: "Catalog fixture", baseUrl: "https://catalog-fixture.example/v1", token: "catalog-fixture-key" });
expect(created.status, JSON.stringify(created.body)).toBe(201);
const id = created.body.instanceId;
const instance = created.body.instances.find((row: any) => row.instanceId === id);
expect(instance.models.options).toEqual(expect.arrayContaining([expect.objectContaining({ id: "model-a", custom: true }), expect.objectContaining({ id: "model-b", custom: true })]));
const first = await addBotOn(id, "model-a");
const second = await addBotOn(id, "model-b");
expect((await bot(first.id)).modelSelection.model).toBe("model-a");
expect((await bot(second.id)).modelSelection.model).toBe("model-b");
if (engine === "claude") {
for (const [launched, model] of [[first, "model-a"], [second, "model-b"]] as const) {
expect((await api("POST", `/api/bots/${launched.id}/messages`, { text: "which model" })).status).toBe(202);
await idle(launched.id);
const spawn = JSON.parse(readFileSync(join(saved().instances[id].config.configDir, "spawn.json"), "utf8"));
expect(spawn.argv[spawn.argv.indexOf("--model") + 1]).toBe(model);
}
}
expect((await api("PATCH", `/api/instances/${id}/${engine}-endpoint`, { displayName: "Renamed", baseUrl: "https://catalog-fixture.example/v1" })).status).toBe(200);
const configs = saved().instances[id].config;
expect(engine === "codex" ? configs.endpoint.models : configs.endpointModels).toEqual(["model-a", "model-b"]);
});
it.each([
["claude", "https://api.anthropic.com"],
["codex", "https://api.openai.com/v1"],
])("connects official %s without choosing a provider-wide model", async (engine, baseUrl) => {
const result = await api("POST", `/api/instances/${engine}-endpoint`, { displayName: "Official fixture", baseUrl, token: "official-fixture-key" });
expect(result.status, JSON.stringify(result.body)).toBe(201);
const instance = result.body.instances.find((row: any) => row.instanceId === result.body.instanceId);
expect(instance.models.options.length).toBeGreaterThan(1);
expect(instance.models.options.every((option: any) => option.custom)).toBe(true);
expect(saved().instances[result.body.instanceId].environment).not.toHaveProperty("ANTHROPIC_MODEL");
});
it("does not persist an endpoint when gateway model discovery fails", async () => {
const before = Object.keys(saved().instances);
const result = await api("POST", "/api/instances/codex-endpoint", { displayName: "Failed fixture", baseUrl: "https://offline.example/v1", token: "failed-fixture-secret" });
expect(result.status).toBe(400);
expect(result.body.error).toContain("Could not discover gateway models");
expect(JSON.stringify(result.body)).not.toContain("failed-fixture-secret");
expect(Object.keys(saved().instances)).toEqual(before);
});
it("reads a gateway's model list again on Refresh models", async () => {
const created = await api("POST", "/api/instances/codex-endpoint", { displayName: "Growing fixture", baseUrl: "https://growing-fixture.example/v1", token: "growing-fixture-key" });
expect(created.status, JSON.stringify(created.body)).toBe(201);
const id = created.body.instanceId;
expect(saved().instances[id].config.endpoint.models).toEqual(["model-a"]);
const refreshed = await api("POST", `/api/instances/${id}/refresh-models`, {});
expect(refreshed.status, JSON.stringify(refreshed.body)).toBe(200);
expect(saved().instances[id].config.endpoint.models).toEqual(["model-a", "model-new"]);
expect(refreshed.body.instances.find((row: any) => row.instanceId === id).models.options.map((option: any) => option.id)).toContain("model-new");
expect((await api("DELETE", `/api/instances/${id}`)).status).toBe(200);
});
it("keeps a Codex endpoint's fixed model when its key changes on the official URL", async () => {
const created = await api("POST", "/api/instances/codex-endpoint", { displayName: "Fixed fixture", baseUrl: "https://api.openai.com/v1", token: "fixed-fixture-key", model: "fixed-model" });
expect(created.status, JSON.stringify(created.body)).toBe(201);
const id = created.body.instanceId;
expect((await api("PATCH", `/api/instances/${id}/codex-endpoint`, { token: "fixed-rotated-key" })).status).toBe(200);
expect(saved().instances[id].config.endpoint.models).toEqual(["fixed-model"]);
expect((await api("DELETE", `/api/instances/${id}`)).status).toBe(200);
});
it("rotates a legacy Claude endpoint's key without a model catalog", async () => {
const created = await api("POST", "/api/instances/claude-endpoint", { displayName: "Legacy fixture", baseUrl: "https://offline.example", token: "legacy-fixture-key", model: "legacy-model" });
expect(created.status, JSON.stringify(created.body)).toBe(201);
const id = created.body.instanceId;
const rotated = await api("PATCH", `/api/instances/${id}/claude-endpoint`, { token: "legacy-rotated-key" });
expect(rotated.status, JSON.stringify(rotated.body)).toBe(200);
expect(saved().instances[id].environment).toMatchObject({ ANTHROPIC_API_KEY: "legacy-rotated-key", ANTHROPIC_MODEL: "legacy-model" });
expect((await api("DELETE", `/api/instances/${id}`)).status).toBe(200);
});
it("creates, edits and removes an isolated Codex API endpoint without OAuth or key disclosure", async () => {
const fields = { displayName: "CLIProxyAPI", baseUrl: "https://gateway.example/v1", token: "codex-api-fixture-secret", model: "gateway-model" };
const created = await api("POST", "/api/instances/codex-endpoint", fields);
expect(created.status, JSON.stringify(created.body)).toBe(201);
const id = created.body.instanceId;
const instance = created.body.instances.find((row: any) => row.instanceId === id);
expect(instance.codexEndpoint).toEqual({ baseUrl: fields.baseUrl, models: ["gateway-model"], tokenSaved: true });
expect(instance.authentication).toBeUndefined();
expect(JSON.stringify(created.body)).not.toContain(fields.token);
expect(saved().instances[id].environment.CODEX_HOME).toContain(id);
expect(saved().instances[id].environment.OPENMAUSBOT_CODEX_API_KEY).toBe(fields.token);
expect((await api("POST", `/api/instances/${id}/auth/start`)).status).not.toBe(200);
expect((await api("PATCH", `/api/instances/${id}/codex-endpoint`, { baseUrl: "https://other.example/v1" })).status).toBe(400);
expect((await api("PATCH", `/api/instances/${id}/codex-endpoint`, { model: "another-model" })).status).toBe(200);
expect(saved().instances[id].config.endpoint.models).toEqual(["another-model"]);
expect((await api("PATCH", `/api/instances/${id}/codex-endpoint`, { token: "codex-api-rotated-secret" })).status).toBe(200);
expect(saved().instances[id].config.endpoint.models).toEqual(["another-model"]);
const bot = await addBotOn(id, "another-model");
expect((await api("DELETE", `/api/instances/${id}`)).status).toBe(409);
expect((await api("PATCH", `/api/bots/${bot.id}/model`, { instanceId: "claude", model: MODEL })).status).toBe(200);
expect((await api("DELETE", `/api/instances/${id}`)).status).toBe(200);
expect(saved().instances[id]).toBeUndefined();
for (const baseUrl of ["http://gateway.example/v1", "https://user:secret@gateway.example/v1", "invalid"]) {
expect((await api("POST", "/api/instances/codex-endpoint", { ...fields, baseUrl })).status).toBe(400);
}
expect((await api("POST", "/api/instances/codex-endpoint", fields, { authorization: `Bearer ${clientToken}` })).status).toBe(403);
});
it("creates beside an invalid saved directory and repairs that unused shadow without accepting invalid replacements", async () => {
const initial = (await api("GET", "/api/instances")).body.instances.find((row: any) => row.instanceId === "broken-account");
expect(initial.snapshot.state).toBe("unavailable");
expect(initial.install?.signInCommand).toBeUndefined();
await addAccount("Alongside repairable shadow");
const repairedDir = join(home, "repaired-account");
const repaired = await api("PATCH", "/api/instances/broken-account", { configDir: repairedDir });
expect(repaired.status, JSON.stringify(repaired.body)).toBe(200);
expect(repaired.body.instances.find((row: any) => row.instanceId === "broken-account")).toMatchObject({
snapshot: { state: "available", authenticated: true }, claudeAccount: { configDir: repairedDir },
});
expect((await api("PATCH", "/api/instances/broken-account", { configDir: "still/relative" })).status).toBe(400);
expect((await api("POST", "/api/instances/claude-accounts", { displayName: "Invalid new account", configDir: "still/relative" })).status).toBe(400);
expect(saved().instances["broken-account"].config.configDir).toBe(repairedDir);
});
it("creates isolated named accounts, renames without changing IDs, and removes only the saved entry", async () => {
const first = await addAccount("Work");
const second = await addAccount("Personal");
expect(first.id).not.toBe(second.id);
expect(first.dir).not.toBe(second.dir);
expect(first.dir).toBe(join(dataDir, "providers", first.id));
expect(first.instance).toMatchObject({
displayName: "Work", cli,
snapshot: { authenticated: true, account: { email: `${basename(first.dir)}@example.test`, organization: "Fixture team" } },
claudeAccount: { isDefault: false, signInCommand: expect.stringContaining(first.dir) },
});
expect(first.instance.install.signInCommand).toBe(first.instance.claudeAccount.signInCommand);
expect(first.instance.install.signInCommand).toContain("CLAUDE_CODE_OAUTH_TOKEN");
expect(first.instance.install.signInCommand).toContain("login");
const listing = await api("GET", "/api/instances");
expect(JSON.stringify(listing.body)).not.toMatch(/fixture-base-secret|fixture-must-not-leak|BASE_ONLY_SENTINEL/);
expect(saved().instances[first.id]).toMatchObject({ driver: "claudeAgent", config: { cli, configDir: first.dir } });
expect(saved().instances[first.id]).not.toHaveProperty("environment");
expect(saved().instances.claude.environment).toEqual({ ANTHROPIC_AUTH_TOKEN: "fixture-base-secret", BASE_ONLY_SENTINEL: "base-only" });
const renamed = await api("PATCH", `/api/instances/${first.id}`, { displayName: "Renamed work" });
expect(renamed.status).toBe(200);
expect(renamed.body.instances.find((row: any) => row.instanceId === first.id).displayName).toBe("Renamed work");
expect(saved().instances[first.id].config.configDir).toBe(first.dir);
mkdirSync(first.dir, { recursive: true });
const marker = join(first.dir, "user-owned-marker.txt");
writeFileSync(marker, "Keep the user's CLI account files");
expect((await api("DELETE", `/api/instances/${first.id}`)).status).toBe(200);
expect(saved().instances).not.toHaveProperty(first.id);
expect(saved().instances).toHaveProperty("ghost");
expect(saved().instances).toHaveProperty("claude");
expect(readFileSync(marker, "utf8")).toBe("Keep the user's CLI account files");
expect((await api("GET", "/api/instances")).body.instances.some((row: any) => row.instanceId === first.id)).toBe(false);
expect((await api("DELETE", "/api/instances/claude")).status).toBe(400);
expect((await api("DELETE", "/api/instances/ghost")).status).toBe(400);
});
it("keeps per-bot account selections separate and protects directories referenced by bots or past tasks", async () => {
const first = await addAccount("History account");
const second = await addAccount("Other bot account");
const firstBot = await addBot(first.id);
const secondBot = await addBot(second.id);
expect((await bot(firstBot.id)).modelSelection.instanceId).toBe(first.id);
expect((await bot(secondBot.id)).modelSelection.instanceId).toBe(second.id);
const changeDirectory = () => api("PATCH", `/api/instances/${first.id}`, { configDir: join(home, "different-history-account") });
expect((await changeDirectory()).status).toBe(409);
expect((await api("DELETE", `/api/instances/${first.id}`)).status).toBe(409);
expect((await api("POST", `/api/bots/${firstBot.id}/messages`, { text: "record fixture history" })).status).toBe(202);
await idle(firstBot.id);
expect((await bot(firstBot.id)).messages.some((message: any) => message.text === `account:${basename(first.dir)}`)).toBe(true);
expect((await api("PATCH", `/api/bots/${firstBot.id}/model`, { instanceId: second.id, model: MODEL })).status).toBe(200);
expect((await api("POST", `/api/bots/${firstBot.id}/tasks`, { title: "New empty task" })).status).toBe(201);
expect((await bot(firstBot.id)).modelSelection.instanceId).toBe(second.id);
expect((await changeDirectory()).status).toBe(409);
expect(saved().instances[first.id].config.configDir).toBe(first.dir);
expect((await bot(secondBot.id)).modelSelection.instanceId).toBe(second.id);
});
it("rejects busy-account edits while sibling changes preserve the running process and later SSE events", async () => {
const active = await addAccount("Running account");
const sibling = await addAccount("Idle sibling");
const runningBot = await addBot(active.id);
mkdirSync(active.dir, { recursive: true });
writeFileSync(join(active.dir, "hang"), "fixture only");
const stream = await openSse(`${base}/api/events`);
try {
await stream.until(frame => frame.kind === "hello");
expect((await api("POST", `/api/bots/${runningBot.id}/messages`, { text: "remain active" })).status).toBe(202);
await expect.poll(() => existsSync(join(active.dir, "spawn.json")), { timeout: 10_000 }).toBe(true);
const originalPid = JSON.parse(readFileSync(join(active.dir, "spawn.json"), "utf8")).pid;
expect((await bot(runningBot.id)).busy).toBe(true);
expect((await api("PATCH", `/api/instances/${active.id}`, { cli })).status).toBe(409);
expect((await api("PATCH", `/api/instances/${sibling.id}`, { displayName: "Changed sibling" })).status).toBe(200);
await addAccount("Added while sibling is busy");
expect((await api("DELETE", `/api/instances/${sibling.id}`)).status).toBe(200);
expect((await bot(runningBot.id)).busy).toBe(true);
expect(() => process.kill(originalPid, 0)).not.toThrow();
expect(JSON.parse(readFileSync(join(active.dir, "spawn.json"), "utf8")).pid).toBe(originalPid);
expect((await api("POST", `/api/bots/${runningBot.id}/interrupt`, {})).status).toBe(200);
await idle(runningBot.id);
unlinkSync(join(active.dir, "hang"));
expect((await api("POST", `/api/bots/${runningBot.id}/messages`, { text: "reply after interruption" })).status).toBe(202);
await stream.until(frame => frame.kind === "message" && frame.threadId === runningBot.threadId && frame.message?.text === `account:${basename(active.dir)}`);
await idle(runningBot.id);
expect((await bot(runningBot.id)).messages.some((message: any) => message.text === `account:${basename(active.dir)}`)).toBe(true);
} finally {
stream.close();
await api("POST", `/api/bots/${runningBot.id}/interrupt`, {});
}
});
it("creates a custom endpoint as its own engine, keeps its token server-side, and runs its model against its host", async () => {
// A settings.json the person's own Claude Code uses: an endpoint must not inherit any of it.
mkdirSync(join(home, ".claude"), { recursive: true });
writeFileSync(join(home, ".claude", "settings.json"), JSON.stringify({
env: { ANTHROPIC_BASE_URL: "https://user-settings.test", ANTHROPIC_AUTH_TOKEN: "user-settings-secret", ANTHROPIC_MODEL: "claude-from-settings" },
}));
const endpoint = {
displayName: "DeepSeek", baseUrl: "https://api.deepseek.test/anthropic/", token: "sk-endpoint-secret",
model: "deepseek-chat", opusModel: "deepseek-reasoner",
};
const created = await api("POST", "/api/instances/claude-endpoint", endpoint);
expect(created.status, JSON.stringify(created.body)).toBe(201);
const id = created.body.instanceId as string;
const row = created.body.instances.find((candidate: any) => candidate.instanceId === id);
expect(row).toMatchObject({
displayName: "DeepSeek",
driverKind: "claudeAgent",
access: "custom",
url: "https://api.deepseek.test/anthropic",
claudeEndpoint: { baseUrl: "https://api.deepseek.test/anthropic", models: ["deepseek-chat", "deepseek-reasoner"], tokenSaved: true },
});
expect(JSON.stringify(created.body)).not.toContain("sk-endpoint-secret");
expect(JSON.stringify((await api("GET", "/api/instances")).body)).not.toContain("sk-endpoint-secret");
expect(saved().instances[id]).toMatchObject({
driver: "claudeAgent", access: "custom",
config: { cli, requireApiKey: true, endpointModels: ["deepseek-chat", "deepseek-reasoner"] },
environment: {
ANTHROPIC_BASE_URL: "https://api.deepseek.test/anthropic",
ANTHROPIC_AUTH_TOKEN: "sk-endpoint-secret", ANTHROPIC_API_KEY: "sk-endpoint-secret",
ANTHROPIC_MODEL: "deepseek-chat", ANTHROPIC_DEFAULT_OPUS_MODEL: "deepseek-reasoner",
},
});
const endpointBot = await addBotOn(id, "deepseek-chat");
expect((await api("POST", `/api/bots/${endpointBot.id}/messages`, { text: "which host" })).status).toBe(202);
await idle(endpointBot.id);
const directory = saved().instances[id].config.configDir as string;
const spawn = JSON.parse(readFileSync(join(directory, "spawn.json"), "utf8"));
expect(spawn.env).toMatchObject({
ANTHROPIC_BASE_URL: "https://api.deepseek.test/anthropic",
ANTHROPIC_MODEL: "deepseek-chat",
ANTHROPIC_AUTH_TOKEN: "sk-endpoint-secret",
});
expect(spawn.env.ANTHROPIC_AUTH_TOKEN).not.toBe("user-settings-secret");
expect(spawn.env.ANTHROPIC_BASE_URL).not.toBe("https://user-settings.test");
const model = spawn.argv[spawn.argv.indexOf("--model") + 1];
expect(model).toBe("deepseek-chat");
expect((await bot(endpointBot.id)).messages.some((message: any) => message.text === `account:${basename(directory)}`)).toBe(true);
const edited = await api("PATCH", `/api/instances/${id}/claude-endpoint`, { model: "deepseek-reasoner", opusModel: "" });
expect(edited.status, JSON.stringify(edited.body)).toBe(200);
const afterEdit = saved().instances[id];
expect(afterEdit.environment).toMatchObject({
ANTHROPIC_AUTH_TOKEN: "sk-endpoint-secret",
ANTHROPIC_BASE_URL: "https://api.deepseek.test/anthropic",
ANTHROPIC_MODEL: "deepseek-reasoner",
});
expect(afterEdit.environment).not.toHaveProperty("ANTHROPIC_DEFAULT_OPUS_MODEL");
expect(afterEdit.config.endpointModels).toEqual(["deepseek-reasoner"]);
expect(edited.body.instances.find((candidate: any) => candidate.instanceId === id).claudeEndpoint.models).toEqual(["deepseek-reasoner"]);
expect((await api("PATCH", "/api/instances/claude/claude-endpoint", { model: "deepseek-chat" })).status).toBe(400);
expect((await api("PATCH", "/api/instances/missing/claude-endpoint", { model: "deepseek-chat" })).status).toBe(404);
for (const invalid of [{}, { displayName: "Only" }, { ...endpoint, baseUrl: "ftp://api.deepseek.test" }, { ...endpoint, model: "has space" }, { ...endpoint, token: "" }]) {
expect((await api("POST", "/api/instances/claude-endpoint", invalid)).status, JSON.stringify(invalid)).toBe(400);
}
mkdirSync(directory, { recursive: true });
const marker = join(directory, "user-owned-marker.txt");
writeFileSync(marker, "Keep the endpoint's own files");
expect((await api("PATCH", `/api/bots/${endpointBot.id}/model`, { instanceId: "claude", model: MODEL })).status).toBe(200);
expect((await api("DELETE", `/api/instances/${id}`)).status).toBe(200);
expect(saved().instances).not.toHaveProperty(id);
expect(readFileSync(marker, "utf8")).toBe("Keep the endpoint's own files");
});
it("enforces JSON, validation, duplicate-directory and admin-only mutation gates", async () => {
const target = await addAccount("Validation account");
for (const [method, path, body] of [
["POST", "/api/instances/claude-accounts", { displayName: "Blocked" }],
["PATCH", `/api/instances/${target.id}`, { displayName: "Blocked" }],
] as const) {
expect((await api(method, path, body, { "content-type": "text/plain" })).status).toBe(415);
const malformed = await fetch(`${base}${path}`, { method, headers: { "content-type": "application/json" }, body: "{" });
expect(malformed.status).toBe(400);
}
for (const body of [{}, { displayName: " " }, { displayName: "x".repeat(81) }, { displayName: "Bad\nname" }, { displayName: "Valid", environment: {} }]) {
expect((await api("POST", "/api/instances/claude-accounts", body)).status).toBe(400);
}
expect((await api("POST", "/api/instances/claude-accounts", { displayName: "Relative", configDir: "relative/path" })).status).toBe(400);
expect((await api("POST", "/api/instances/claude-accounts", { displayName: "Duplicate", configDir: target.dir })).status).toBe(409);
expect((await api("PATCH", `/api/instances/${target.id}`, { configDir: "relative/path" })).status).toBe(400);
expect((await api("PATCH", `/api/instances/${target.id}`, {})).status).toBe(400);
expect((await api("PATCH", `/api/instances/${target.id}`, { displayName: 7 })).status).toBe(400);
expect((await api("PATCH", "/api/instances/ghost", { displayName: "Not Claude" })).status).toBe(400);
expect((await api("PATCH", "/api/instances/missing", { cli })).status).toBe(404);
expect((await api("DELETE", "/api/instances/missing")).status).toBe(404);
for (const [method, path, body] of [
["POST", "/api/instances/claude-accounts", { displayName: "Client blocked" }],
["PATCH", `/api/instances/${target.id}`, { displayName: "Client blocked" }],
["DELETE", `/api/instances/${target.id}`, undefined],
] as const) {
expect((await api(method, path, body, { authorization: `Bearer ${clientToken}` })).status).toBe(403);
}
expect(saved().instances[target.id].displayName).toBe("Validation account");
});
});