feat: add web executor model configuration
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { extractModelIds, discoverExecutorModels } from "./workflow-model-discovery.js";
|
||||
import { saveGlobalExecutorModels, loadGlobalWorkflowConfig } from "./workflow-config.js";
|
||||
|
||||
describe("workflow-model-discovery helpers", () => {
|
||||
it("extracts model ids from data[] shape", () => {
|
||||
expect(extractModelIds({
|
||||
data: [
|
||||
{ id: "claude-sonnet-4" },
|
||||
{ id: "claude-opus-4" },
|
||||
{ name: "ignored" },
|
||||
],
|
||||
})).toEqual(["claude-sonnet-4", "claude-opus-4"]);
|
||||
});
|
||||
|
||||
it("extracts model ids from models[] shape", () => {
|
||||
expect(extractModelIds({
|
||||
models: [
|
||||
{ id: "model-a" },
|
||||
"model-b",
|
||||
],
|
||||
})).toEqual(["model-a", "model-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveGlobalExecutorModels", () => {
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-global-config-"));
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("atomically updates only executor models and preserves unrelated fields", () => {
|
||||
const configDir = path.join(tmpHome, ".agent-workflow");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
|
||||
version: "1",
|
||||
defaultExecutor: "reasonix",
|
||||
maxCycles: 7,
|
||||
executors: {
|
||||
reasonix: { binary: "/usr/local/bin/reasonix", model: "old-model", profile: "fast" },
|
||||
claude: { binary: "claude" },
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
saveGlobalExecutorModels({
|
||||
reasonix: "new-reasonix",
|
||||
claude: "claude-sonnet-4",
|
||||
agy: null,
|
||||
});
|
||||
|
||||
const loaded = loadGlobalWorkflowConfig()!;
|
||||
expect(loaded.defaultExecutor).toBe("reasonix");
|
||||
expect(loaded.maxCycles).toBe(7);
|
||||
expect(loaded.executors?.reasonix).toEqual({
|
||||
binary: "/usr/local/bin/reasonix",
|
||||
model: "new-reasonix",
|
||||
profile: "fast",
|
||||
});
|
||||
expect(loaded.executors?.claude).toEqual({
|
||||
binary: "claude",
|
||||
model: "claude-sonnet-4",
|
||||
});
|
||||
expect(loaded.executors?.agy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("null clears a model override without dropping other executor fields", () => {
|
||||
const configDir = path.join(tmpHome, ".agent-workflow");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
|
||||
version: "1",
|
||||
executors: {
|
||||
agy: { binary: "agy", model: "to-remove", agent: "code-assistant" },
|
||||
},
|
||||
}));
|
||||
|
||||
saveGlobalExecutorModels({ agy: null });
|
||||
const loaded = loadGlobalWorkflowConfig()!;
|
||||
expect(loaded.executors?.agy).toEqual({ binary: "agy", agent: "code-assistant" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverExecutorModels isolation", () => {
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalFetch: typeof globalThis.fetch | undefined;
|
||||
const ANTHROPIC_VARS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
] as const;
|
||||
const savedVars: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-discovery-"));
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
originalFetch = globalThis.fetch;
|
||||
for (const key of ANTHROPIC_VARS) {
|
||||
savedVars[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
for (const key of ANTHROPIC_VARS) {
|
||||
if (savedVars[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedVars[key];
|
||||
}
|
||||
if (originalFetch) globalThis.fetch = originalFetch;
|
||||
else delete (globalThis as { fetch?: typeof fetch }).fetch;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function writeClaudeSettings(env: Record<string, string>): void {
|
||||
const claudeDir = path.join(tmpHome, ".claude");
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ env }));
|
||||
}
|
||||
|
||||
it("keeps Claude discovery isolated and never returns credentials", async () => {
|
||||
process.env.ANTHROPIC_API_KEY = "secret-key-value";
|
||||
process.env.ANTHROPIC_BASE_URL = "https://user:pass@api.example.com/v1?token=abc#frag";
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error("network down secret-key-value");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await discoverExecutorModels();
|
||||
expect(result.claude.status).toBe("error");
|
||||
expect(result.claude.error).toBeTruthy();
|
||||
expect(JSON.stringify(result)).not.toContain("secret-key-value");
|
||||
expect(JSON.stringify(result)).not.toContain("user:pass");
|
||||
expect(result.agy.status === "ok" || result.agy.status === "error").toBe(true);
|
||||
expect(result.reasonix.status === "ok" || result.reasonix.status === "error").toBe(true);
|
||||
});
|
||||
|
||||
it("resolves Claude settings from ~/.claude/settings.json when process env is unset", async () => {
|
||||
// Process env is fully cleared in beforeEach; settings.json is the only source.
|
||||
writeClaudeSettings({
|
||||
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
|
||||
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "gateway-sonnet",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "gateway-opus",
|
||||
});
|
||||
|
||||
let capturedUrl = "";
|
||||
let capturedAuth: string | undefined;
|
||||
let capturedApiKey: string | undefined;
|
||||
globalThis.fetch = vi.fn(async (input: any, init: any) => {
|
||||
capturedUrl = String(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
capturedAuth = headers.get("authorization") ?? undefined;
|
||||
capturedApiKey = headers.get("x-api-key") ?? undefined;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: [{ id: "gateway-model-1" }, { id: "gateway-model-2" }] }),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await discoverExecutorModels();
|
||||
expect(result.claude.status).toBe("ok");
|
||||
expect(capturedUrl.startsWith("https://gateway.internal.example")).toBe(true);
|
||||
// Auth token must use the Authorization: Bearer header, not x-api-key.
|
||||
expect(capturedAuth).toBe("Bearer token-from-settings");
|
||||
expect(capturedApiKey == null).toBe(true);
|
||||
expect(result.claude.models).toContain("gateway-model-1");
|
||||
// Configured default model candidates are surfaced.
|
||||
expect(result.claude.defaults).toContain("gateway-sonnet");
|
||||
expect(result.claude.defaults).toContain("gateway-opus");
|
||||
// The resolved token must never appear anywhere in the browser-visible result.
|
||||
expect(JSON.stringify(result)).not.toContain("token-from-settings");
|
||||
});
|
||||
|
||||
it("redacts a settings.json auth token from browser-visible errors", async () => {
|
||||
writeClaudeSettings({
|
||||
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
|
||||
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
|
||||
});
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error("upstream failed for token-from-settings");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await discoverExecutorModels();
|
||||
expect(result.claude.status).toBe("error");
|
||||
expect(result.claude.error).toBeTruthy();
|
||||
expect(JSON.stringify(result)).not.toContain("token-from-settings");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user