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, extractReasonixModels, 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"]); }); it("extracts concrete Reasonix provider/model ids", () => { expect(extractReasonixModels({ providers: [ { name: "custom-opencode-ai", models: ["deepseek-v4-flash", "deepseek-v4-pro"], }, { name: "anthropic", models: [] }, ], })).toEqual([ "custom-opencode-ai/deepseek-v4-flash", "custom-opencode-ai/deepseek-v4-pro", "anthropic", ]); }); it("supports object model entries and legacy top-level models", () => { expect(extractReasonixModels({ providers: [{ name: "gateway", models: [{ id: "model-a" }, { name: "model-b" }] }], })).toEqual(["gateway/model-a", "gateway/model-b"]); expect(extractReasonixModels({ models: [{ provider: "gateway", id: "model-c" }, "provider-only"], })).toEqual(["gateway/model-c", "provider-only"]); }); }); 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 = {}; 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]; } const binDir = path.join(tmpHome, "bin"); fs.mkdirSync(binDir, { recursive: true }); const agyBin = path.join(binDir, "agy"); const reasonixBin = path.join(binDir, "reasonix"); fs.writeFileSync(agyBin, "#!/bin/sh\nprintf '%s\\n' 'Test Agy Model'\n", { mode: 0o755 }); fs.writeFileSync(reasonixBin, `#!/bin/sh printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}' `, { mode: 0o755 }); 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: agyBin }, reasonix: { binary: reasonixBin }, }, })); }); 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): 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).toMatchObject({ status: "ok", models: ["Test Agy Model"] }); expect(result.reasonix).toMatchObject({ status: "ok", models: ["test-provider/test-model"], }); }); 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"); }); });