// Migrate Hermes tests cover secrets plugin behavior. import fs from "node:fs/promises"; import path from "node:path"; import { loadAuthProfileStoreWithoutExternalProfiles, resolveAuthStorePathForDisplay, saveAuthProfileStore, type AuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; import { resolvePreferredOpenClawTmpDir, tempWorkspace, type TempWorkspace, } from "openclaw/plugin-sdk/temp-path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HERMES_REASON_AUTH_PROFILE_EXISTS, HERMES_REASON_SECRET_NO_LONGER_PRESENT, } from "./items.js"; import { buildHermesMigrationProvider } from "./provider.js"; import { makeConfigRuntime, makeContext as makeProviderContext, writeFile, } from "./test/provider-helpers.js"; let testWorkspace: TempWorkspace; async function expectMissingPath(filePath: string): Promise { try { await fs.access(filePath); } catch (error) { expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); return; } throw new Error(`expected missing path: ${filePath}`); } function authProfileTarget(agentDir: string, profileId: string): string { return `${resolveAuthStorePathForDisplay(agentDir)}#${profileId}`; } function readAuthProfileStore(agentDir: string): AuthProfileStore { return loadAuthProfileStoreWithoutExternalProfiles(agentDir); } function writeAuthProfileStore(agentDir: string, store: AuthProfileStore): void { saveAuthProfileStore(store, agentDir, { filterExternalAuthProfiles: false, syncExternalCli: false, }); } function fakeJwt(payload: Record): string { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); return `${header}.${body}.signature`; } const HERMES_ACCESS_FIELD = ["access", "token"].join("_"); const HERMES_REFRESH_FIELD = ["refresh", "token"].join("_"); async function makeHermesSecretFixture(sourceName = "hermes") { const root = testWorkspace.dir; const source = path.join(root, sourceName); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); const agentDir = path.join(stateDir, "agents", "main", "agent"); const config = { agents: { defaults: { workspace: workspaceDir } } } as OpenClawConfig; const runtime = makeConfigRuntime(config); const provider = buildHermesMigrationProvider(); const secretContext = (overrides: Partial[0]> = {}) => makeProviderContext({ source, stateDir, workspaceDir, includeSecrets: true, ...overrides }); return { root, source, workspaceDir, stateDir, reportDir, agentDir, config, runtime, provider, secretContext, }; } describe("Hermes migration secret items", () => { beforeEach(async () => { testWorkspace = await tempWorkspace({ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-migrate-hermes-", }); }); afterEach(async () => { vi.unstubAllEnvs(); await testWorkspace.cleanup(); }); it("uses configured agentDir for secret planning and imports without runtime helpers", async () => { const { root, source, workspaceDir, stateDir, secretContext, provider } = await makeHermesSecretFixture(); const customAgentDir = path.join(root, "custom-agent"); await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); const config = { agents: { defaults: { workspace: workspaceDir, }, list: [ { id: "custom", default: true, agentDir: customAgentDir, }, ], }, } as OpenClawConfig; const plan = await provider.plan( secretContext({ config, }), ); expect(plan.metadata?.agentDir).toBe(customAgentDir); expect(plan.items).toEqual([ { id: "secret:openai", kind: "secret", action: "create", source: path.join(source, ".env"), target: authProfileTarget(customAgentDir, "openai:hermes-import"), status: "planned", sensitive: true, details: { envVar: "OPENAI_API_KEY", provider: "openai", profileId: "openai:hermes-import", }, }, ]); const result = await provider.apply( secretContext({ config, overwrite: true, reportDir: path.join(root, "report"), }), ); expect(result.summary.errors).toBe(0); const authStore = readAuthProfileStore(customAgentDir); expect(authStore.profiles?.["openai:hermes-import"]).toEqual({ type: "api_key", provider: "openai", key: "sk-hermes", displayName: "Hermes import", }); await expectMissingPath(path.join(stateDir, "agents", "custom", "agent", "auth-profiles.json")); }); it("parses current Hermes dotenv syntax and legacy Kimi credentials", async () => { const { source, secretContext } = await makeHermesSecretFixture(); const kimiEnv = ["KIMI", "CODING", "API", "KEY"].join("_"); const openaiEnv = ["OPENAI", "API", "KEY"].join("_"); await writeFile( path.join(source, ".env"), `\uFEFFexport ${kimiEnv} = placeholder\nexport ${openaiEnv}='redacted'\n`, ); const plan = await buildHermesMigrationProvider().plan(secretContext()); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "secret", details: expect.objectContaining({ provider: "moonshot" }), }), expect.objectContaining({ kind: "secret", details: expect.objectContaining({ provider: "openai" }), }), ]), ); }); it("imports the current Hermes MiniMax China credential", async () => { const { source, secretContext } = await makeHermesSecretFixture(); const envVar = ["MINIMAX", "CN", "API", "KEY"].join("_"); await writeFile(path.join(source, ".env"), `${envVar}=placeholder\n`); const plan = await buildHermesMigrationProvider().plan(secretContext()); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "secret", details: expect.objectContaining({ envVar, provider: "minimax" }), }), ]), ); }); it("imports the selected provider credential without an endpoint override", async () => { const { source, secretContext } = await makeHermesSecretFixture(); const envVar = ["STEPFUN", "API", "KEY"].join("_"); await writeFile( path.join(source, "config.yaml"), "model:\n provider: stepfun\n default: step-3.5-flash\n", ); await writeFile(path.join(source, ".env"), `${envVar}=placeholder\n`); const plan = await buildHermesMigrationProvider().plan(secretContext()); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "secret", details: expect.objectContaining({ envVar, provider: "stepfun" }), }), ]), ); }); it("keeps legacy Moonshot model routing and credentials aligned", async () => { const { source, secretContext } = await makeHermesSecretFixture(); const envVar = ["MOONSHOT", "API", "KEY"].join("_"); await writeFile( path.join(source, "config.yaml"), "model:\n provider: moonshot\n default: kimi-k2.5\n", ); await writeFile(path.join(source, ".env"), `${envVar}=placeholder\n`); const plan = await buildHermesMigrationProvider().plan(secretContext()); expect(plan.items.find((item) => item.id === "config:default-model")?.details?.model).toBe( "moonshot/kimi-k2.5", ); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "secret", details: expect.objectContaining({ envVar, provider: "moonshot" }), }), ]), ); }); it.each([ ["sk-kimi-placeholder", "kimi"], ["legacy-moonshot-placeholder", "moonshot"], ])("aligns KIMI_API_KEY with its effective %s route", async (apiKey, expectedProvider) => { const { source, secretContext } = await makeHermesSecretFixture(expectedProvider); const envVar = ["KIMI", "API", "KEY"].join("_"); await writeFile( path.join(source, "config.yaml"), "model:\n provider: kimi-coding\n default: kimi-k2.5\n", ); await writeFile(path.join(source, ".env"), `${envVar}=${apiKey}\n`); const plan = await buildHermesMigrationProvider().plan(secretContext()); expect(plan.items.find((item) => item.id === "config:default-model")?.details?.model).toBe( `${expectedProvider}/kimi-k2.5`, ); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "secret", details: expect.objectContaining({ envVar, provider: expectedProvider }), }), ]), ); }); it("imports a configured provider key_env as matching OpenClaw provider auth", async () => { const { source, stateDir, secretContext, config, runtime } = await makeHermesSecretFixture(); const value = ["custom", "provider", "placeholder"].join("-"); const envVar = ["ACME", "TOKEN"].join("_"); await writeFile( path.join(source, "config.yaml"), [ "model:", " provider: acme", " default: acme-chat", "providers:", " acme:", " api: https://api.acme.example/v1", ` key_env: ${envVar}`, " models: [acme-chat]", "", ].join("\n"), ); await writeFile(path.join(source, ".env"), `${envVar}=${value}\n`); const result = await buildHermesMigrationProvider({ runtime }).apply( secretContext({ config, runtime, overwrite: true, }), ); expect(result.summary.errors).toBe(0); expect(result.items).toEqual( expect.arrayContaining([ expect.objectContaining({ details: expect.objectContaining({ envVar, provider: "acme" }), status: "migrated", }), ]), ); const store = readAuthProfileStore(path.join(stateDir, "agents", "main", "agent")); const profile = store.profiles["acme:hermes-import"]; expect(profile).toEqual(expect.objectContaining({ provider: "acme", type: "api_key" })); if (!profile || profile.type !== "api_key") { throw new Error("expected imported API key profile"); } expect(profile.key).toBe(value); expect(config.models?.providers?.acme?.apiKey).toBeUndefined(); expect(config.auth?.profiles?.["acme:hermes-import"]).toEqual( expect.objectContaining({ mode: "api_key", provider: "acme" }), ); }); it("binds the host-gated OpenAI key fallback to a model-scoped endpoint", async () => { const { source, secretContext } = await makeHermesSecretFixture(); const envVar = ["OPENAI", "API", "KEY"].join("_"); await writeFile( path.join(source, "config.yaml"), [ "model:", " provider: custom", " default: gpt-5.6", " base_url: https://api.openai.com/v1", "", ].join("\n"), ); await writeFile(path.join(source, ".env"), `${envVar}=placeholder\n`); const plan = await buildHermesMigrationProvider().plan(secretContext()); const secretItems = plan.items.filter((item) => item.kind === "secret"); expect(secretItems).toHaveLength(1); expect(secretItems[0]?.details).toEqual( expect.objectContaining({ envVar, provider: "custom" }), ); }); it("keeps an env-backed custom endpoint and its OpenAI key on one provider", async () => { const { source, secretContext } = await makeHermesSecretFixture(); const keyEnv = ["OPENAI", "API", "KEY"].join("_"); const baseUrlEnv = ["OPENAI", "BASE", "URL"].join("_"); await writeFile( path.join(source, "config.yaml"), ["model:", " provider: custom", " default: private-model", ""].join("\n"), ); await writeFile( path.join(source, ".env"), `${keyEnv}=placeholder\n${baseUrlEnv}=https://private.example.test/v1\n`, ); const plan = await buildHermesMigrationProvider().plan(secretContext()); const providers = Object.assign( {}, ...plan.items .filter((item) => item.id.startsWith("config:model-provider:")) .map((item) => item.details?.value), ) as Record; expect(providers?.custom?.baseUrl).toBe("https://private.example.test/v1"); const secretItems = plan.items.filter((item) => item.kind === "secret"); expect(secretItems).toHaveLength(1); expect(secretItems[0]?.details).toEqual( expect.objectContaining({ envVar: keyEnv, provider: "custom" }), ); }); it("imports current Hermes singleton and pooled OpenAI OAuth accounts", async () => { const { source, stateDir, secretContext, config, runtime } = await makeHermesSecretFixture(); const accountOne = fakeJwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct_one" }, "https://api.openai.com/profile": { email: "one@example.test" }, }); const accountTwo = fakeJwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct_two" }, "https://api.openai.com/profile": { email: "two@example.test" }, }); await writeFile( path.join(source, "auth.json"), JSON.stringify({ providers: { "openai-codex": { tokens: { [HERMES_ACCESS_FIELD]: accountOne, [HERMES_REFRESH_FIELD]: "refresh-one", }, last_refresh: "2026-07-13T10:00:00Z", }, }, credential_pool: { "openai-codex": [ { [HERMES_ACCESS_FIELD]: accountOne, [HERMES_REFRESH_FIELD]: "refresh-one", last_refresh: "2026-07-13T09:00:00Z", }, { [HERMES_ACCESS_FIELD]: accountTwo, [HERMES_REFRESH_FIELD]: "refresh-two", last_refresh: "2026-07-13T08:00:00Z", }, ], }, }), ); const provider = buildHermesMigrationProvider({ runtime }); const result = await provider.apply( secretContext({ config, runtime, overwrite: true, }), ); const authItems = result.items.filter((item) => item.kind === "auth"); expect(authItems).toHaveLength(2); expect(authItems.every((item) => item.status === "migrated")).toBe(true); const store = readAuthProfileStore(path.join(stateDir, "agents", "main", "agent")); expect(store.profiles["openai:account-acct_one"]).toEqual( expect.objectContaining({ provider: "openai", refresh: "refresh-one" }), ); expect(store.profiles["openai:account-acct_two"]).toEqual( expect.objectContaining({ provider: "openai", refresh: "refresh-two" }), ); }); it("imports manual Hermes API-key pool entries and skips borrowed references", async () => { const { source, stateDir, secretContext, config, runtime } = await makeHermesSecretFixture(); const firstValue = "openrouter-one"; const secondValue = "openrouter-two"; const borrowedValue = "borrowed-value"; const geminiValue = "gemini-value"; await writeFile( path.join(source, "auth.json"), JSON.stringify({ credential_pool: { openrouter: [ { id: "key-one", auth_type: "api_key", source: "manual", [HERMES_ACCESS_FIELD]: firstValue, }, { id: "key-two", auth_type: "api_key", source: "manual", [HERMES_ACCESS_FIELD]: secondValue, }, { id: "borrowed", auth_type: "api_key", source: "env:OPENROUTER_API_KEY", [HERMES_ACCESS_FIELD]: borrowedValue, }, ], gemini: [ { id: "google-key", auth_type: "api_key", source: "manual", [HERMES_ACCESS_FIELD]: geminiValue, }, ], }, }), ); const result = await buildHermesMigrationProvider({ runtime }).apply( secretContext({ config, runtime, overwrite: true, }), ); const secretItems = result.items.filter( (item) => item.kind === "secret" && item.details?.sourceKind === "hermes-auth-json", ); expect(secretItems).toHaveLength(3); const store = readAuthProfileStore(path.join(stateDir, "agents", "main", "agent")); expect(store.profiles["openrouter:hermes-key-one"]).toEqual( expect.objectContaining({ type: "api_key", key: firstValue }), ); expect(store.profiles["openrouter:hermes-key-two"]).toEqual( expect.objectContaining({ type: "api_key", key: secondValue }), ); expect(store.profiles["openrouter:hermes-borrowed"]).toBeUndefined(); expect(store.profiles["google:hermes-google-key"]).toEqual( expect.objectContaining({ type: "api_key", key: geminiValue }), ); }); it("uses per-provider global API-key pool fallback for an active profile", async () => { const { root, secretContext, config, runtime } = await makeHermesSecretFixture(); const hermesRoot = path.join(root, ".hermes"); const source = path.join(hermesRoot, "profiles", "coder"); const stateDir = path.join(root, "state"); const globalOpenRouterValue = ["global", "openrouter", "placeholder"].join("-"); const globalGeminiValue = ["global", "gemini", "placeholder"].join("-"); const profileOpenRouterValue = ["profile", "openrouter", "placeholder"].join("-"); await writeFile(path.join(hermesRoot, "active_profile"), "coder\n"); await writeFile(path.join(source, "config.yaml"), "{}\n"); await writeFile( path.join(hermesRoot, "auth.json"), JSON.stringify({ credential_pool: { openrouter: [ { id: "global-openrouter", auth_type: "api_key", source: "manual", [HERMES_ACCESS_FIELD]: globalOpenRouterValue, }, ], gemini: [ { id: "global-gemini", auth_type: "api_key", source: "manual", [HERMES_ACCESS_FIELD]: globalGeminiValue, }, ], }, }), ); await writeFile( path.join(source, "auth.json"), JSON.stringify({ credential_pool: { openrouter: [ { id: "profile-openrouter", auth_type: "api_key", source: "manual", [HERMES_ACCESS_FIELD]: profileOpenRouterValue, }, ], }, }), ); vi.stubEnv("HOME", root); vi.stubEnv("HERMES_HOME", ""); const result = await buildHermesMigrationProvider({ runtime }).apply( secretContext({ source: "", config, runtime, overwrite: true, }), ); expect(result.items).toEqual( expect.arrayContaining([ expect.objectContaining({ source: path.join(source, "auth.json"), details: expect.objectContaining({ provider: "openrouter" }), }), expect.objectContaining({ source: path.join(hermesRoot, "auth.json"), details: expect.objectContaining({ provider: "google" }), }), ]), ); const store = readAuthProfileStore(path.join(stateDir, "agents", "main", "agent")); expect(store.profiles["openrouter:hermes-profile-openrouter"]).toEqual( expect.objectContaining({ key: profileOpenRouterValue }), ); expect(store.profiles["openrouter:hermes-global-openrouter"]).toBeUndefined(); expect(store.profiles["google:hermes-global-gemini"]).toEqual( expect.objectContaining({ key: globalGeminiValue }), ); }); it("reports API key import when config update fails after profile write", async () => { const { source, workspaceDir, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(); await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); const config = { agents: { defaults: { workspace: workspaceDir, }, }, } as OpenClawConfig; const runtime = { config: { current: () => config, mutateConfigFile: async () => { throw new Error("config write failed"); }, }, } as unknown as MigrationProviderContext["runtime"]; const ctx = secretContext({ config, reportDir, runtime, }); const plan = await provider.plan(ctx); const result = await provider.apply(ctx, plan); const item = result.items.find((entry) => entry.id === "secret:openai"); expect(item).toEqual( expect.objectContaining({ status: "migrated", details: expect.objectContaining({ configUpdated: false, }), }), ); const authStore = readAuthProfileStore(agentDir); expect(authStore.profiles?.["openai:hermes-import"]).toEqual( expect.objectContaining({ type: "api_key", provider: "openai", key: "sk-hermes", }), ); }); it("keeps secret conflict checks read-only during planning", async () => { const { source, secretContext, agentDir, provider } = await makeHermesSecretFixture(); await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); const existingStore: AuthProfileStore = { version: 1, profiles: { "openai:existing": { type: "api_key", provider: "openai", key: "existing-main-key", }, }, }; writeAuthProfileStore(agentDir, existingStore); const beforePlanStore = readAuthProfileStore(agentDir); await provider.plan(secretContext()); expect(readAuthProfileStore(agentDir)).toEqual(beforePlanStore); // Canonical profiles stay in SQLite; planning must not recreate the retired JSON sidecar. await expectMissingPath(path.join(agentDir, "auth-profiles.json")); }); it("reports late-created auth profiles as conflicts without overwriting", async () => { const { source, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(); await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); const ctx = secretContext({ reportDir, }); const plan = await provider.plan(ctx); writeAuthProfileStore(agentDir, { version: 1, profiles: { "openai:hermes-import": { type: "api_key", provider: "openai", key: "sk-late", }, }, }); const result = await provider.apply(ctx, plan); expect(result.items).toEqual([ { id: "secret:openai", kind: "secret", action: "create", source: path.join(source, ".env"), target: authProfileTarget(agentDir, "openai:hermes-import"), status: "conflict", sensitive: true, reason: HERMES_REASON_AUTH_PROFILE_EXISTS, details: { envVar: "OPENAI_API_KEY", provider: "openai", profileId: "openai:hermes-import", }, }, ]); expect(result.summary.conflicts).toBe(1); const authStore = readAuthProfileStore(agentDir); expect(authStore.profiles?.["openai:hermes-import"]).toEqual( expect.objectContaining({ type: "api_key", provider: "openai", key: "sk-late", }), ); }); it("reports API key config auth profile conflicts during planning", async () => { const { source, workspaceDir, secretContext, agentDir, provider } = await makeHermesSecretFixture(); await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); const config = { agents: { defaults: { workspace: workspaceDir, }, }, auth: { profiles: { "openai:hermes-import": { provider: "anthropic", mode: "api_key", }, }, }, } as OpenClawConfig; const ctx = secretContext({ config, }); const plan = await provider.plan(ctx); expect(plan.items).toEqual([ expect.objectContaining({ id: "secret:openai", status: "conflict", reason: HERMES_REASON_AUTH_PROFILE_EXISTS, }), ]); const result = await provider.apply(ctx, plan); expect(result.summary.conflicts).toBe(1); await expectMissingPath(path.join(agentDir, "auth-profiles.json")); }); it("reports late-created API key config auth profile conflicts before writing", async () => { const { source, workspaceDir, secretContext, agentDir, provider } = await makeHermesSecretFixture(); await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); const config = { agents: { defaults: { workspace: workspaceDir, }, }, } as OpenClawConfig; const ctx = secretContext({ config, runtime: makeConfigRuntime(config), }); const plan = await provider.plan(ctx); config.auth = { profiles: { "openai:hermes-import": { provider: "anthropic", mode: "api_key", }, }, }; const result = await provider.apply(ctx, plan); expect(result.items).toEqual([ expect.objectContaining({ id: "secret:openai", status: "conflict", reason: HERMES_REASON_AUTH_PROFILE_EXISTS, }), ]); expect(result.summary.conflicts).toBe(1); await expectMissingPath(path.join(agentDir, "auth-profiles.json")); }); it("imports supported Hermes provider env credentials including OpenCode and GitHub Copilot", async () => { const { source, workspaceDir, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(); await writeFile( path.join(source, ".env"), ["OPENCODE_ZEN_API_KEY=opencode-key", "COPILOT_GITHUB_TOKEN=gho-copilot-token", ""].join( "\n", ), ); const config = { agents: { defaults: { workspace: workspaceDir, }, }, } as OpenClawConfig; const ctx = secretContext({ config, reportDir, runtime: makeConfigRuntime(config), }); const plan = await provider.plan(ctx); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "secret:opencode", status: "planned", details: expect.objectContaining({ envVar: "OPENCODE_ZEN_API_KEY", provider: "opencode", profileId: "opencode:hermes-import", }), }), expect.objectContaining({ id: "secret:opencode-go", status: "planned", details: expect.objectContaining({ envVar: "OPENCODE_ZEN_API_KEY", provider: "opencode-go", profileId: "opencode-go:hermes-import", }), }), expect.objectContaining({ id: "secret:github-copilot", status: "planned", details: expect.objectContaining({ envVar: "COPILOT_GITHUB_TOKEN", mode: "token", provider: "github-copilot", profileId: "github-copilot:github", }), }), ]), ); const result = await provider.apply(ctx, plan); expect(result.summary.errors).toBe(0); const authStore = readAuthProfileStore(agentDir); expect(authStore.profiles?.["opencode:hermes-import"]).toEqual( expect.objectContaining({ type: "api_key", provider: "opencode", key: "opencode-key", }), ); expect(authStore.profiles?.["opencode-go:hermes-import"]).toEqual( expect.objectContaining({ type: "api_key", provider: "opencode-go", key: "opencode-key", }), ); expect(authStore.profiles?.["github-copilot:github"]).toEqual( expect.objectContaining({ type: "token", provider: "github-copilot", token: "gho-copilot-token", }), ); expect(config.auth?.profiles?.["github-copilot:github"]).toEqual( expect.objectContaining({ provider: "github-copilot", mode: "token", }), ); }); it("does not import web-search-only Perplexity env credentials as model auth profiles", async () => { const { source, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(); await writeFile(path.join(source, ".env"), "PERPLEXITY_API_KEY=pplx-hermes\n"); const ctx = secretContext({ reportDir, }); const plan = await provider.plan(ctx); expect(plan.items.some((item) => item.id === "secret:perplexity")).toBe(false); const result = await provider.apply(ctx, plan); expect(result.summary.errors).toBe(0); await expectMissingPath(path.join(agentDir, "auth-profiles.json")); }); it("imports supported OpenCode auth store credentials next to the Hermes home", async () => { const { root, source, workspaceDir, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(".hermes"); await writeFile(path.join(source, "config.yaml"), "model: opencode/kimi-k2.5\n"); await writeFile( path.join(root, ".local", "share", "opencode", "auth.json"), JSON.stringify({ "github-copilot": { type: "oauth", refresh: "gho-opencode-copilot-token", access: "copilot-api-token", expires: Date.now() + 3600_000, }, opencode: { type: "api", key: "opencode-zen-key", }, "opencode-go": { type: "api", key: "opencode-go-key", }, }), ); const config = { agents: { defaults: { workspace: workspaceDir, }, }, } as OpenClawConfig; const ctx = secretContext({ config, reportDir, runtime: makeConfigRuntime(config), }); const plan = await provider.plan(ctx); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "secret:opencode:opencode-auth-json", status: "planned", source: path.join(root, ".local", "share", "opencode", "auth.json"), details: expect.objectContaining({ provider: "opencode", sourceKind: "opencode-auth-json", sourceProvider: "opencode", secretField: "key", }), }), expect.objectContaining({ id: "secret:opencode-go:opencode-auth-json", status: "planned", details: expect.objectContaining({ provider: "opencode-go", sourceKind: "opencode-auth-json", sourceProvider: "opencode-go", secretField: "key", }), }), expect.objectContaining({ id: "secret:github-copilot:opencode-auth-json", status: "planned", details: expect.objectContaining({ mode: "token", provider: "github-copilot", sourceKind: "opencode-auth-json", sourceProvider: "github-copilot", secretField: "refresh", }), }), ]), ); const result = await provider.apply(ctx, plan); expect(result.summary.errors).toBe(0); const authStore = readAuthProfileStore(agentDir); expect(authStore.profiles?.["opencode:hermes-import"]).toEqual( expect.objectContaining({ type: "api_key", provider: "opencode", key: "opencode-zen-key", }), ); expect(authStore.profiles?.["opencode-go:hermes-import"]).toEqual( expect.objectContaining({ type: "api_key", provider: "opencode-go", key: "opencode-go-key", }), ); expect(authStore.profiles?.["github-copilot:github"]).toEqual( expect.objectContaining({ type: "token", provider: "github-copilot", token: "gho-opencode-copilot-token", }), ); }); it("skips OpenCode GitHub Copilot enterprise credentials until endpoint routing is supported", async () => { const { root, source, workspaceDir, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(".hermes"); await writeFile(path.join(source, "config.yaml"), "model: github-copilot/gpt-5.4\n"); await writeFile( path.join(root, ".local", "share", "opencode", "auth.json"), JSON.stringify({ "github-copilot": { type: "oauth", refresh: "gho-enterprise-copilot-token", access: "enterprise-copilot-api-token", enterpriseUrl: "https://api.enterprise.githubcopilot.example", expires: Date.now() + 3600_000, }, }), ); const config = { agents: { defaults: { workspace: workspaceDir, }, }, } as OpenClawConfig; const ctx = secretContext({ config, reportDir, runtime: makeConfigRuntime(config), }); const plan = await provider.plan(ctx); expect(plan.items.some((item) => item.id === "secret:github-copilot:opencode-auth-json")).toBe( false, ); const result = await provider.apply(ctx, plan); expect(result.summary.errors).toBe(0); await expectMissingPath(path.join(agentDir, "auth-profiles.json")); }); it("prefers OpenCode auth from XDG_DATA_HOME when it belongs to the migrated home", async () => { const { root, source, workspaceDir, secretContext, reportDir, agentDir } = await makeHermesSecretFixture(".hermes"); const xdgDataHome = path.join(root, "xdg-data"); const previousXdgDataHome = process.env.XDG_DATA_HOME; await writeFile(path.join(source, "config.yaml"), "model: opencode/kimi-k2.5\n"); await writeFile( path.join(root, ".local", "share", "opencode", "auth.json"), JSON.stringify({ opencode: { type: "api", key: "sibling-opencode-key", }, }), ); await writeFile( path.join(xdgDataHome, "opencode", "auth.json"), JSON.stringify({ opencode: { type: "api", key: "xdg-opencode-key", }, }), ); const config = { agents: { defaults: { workspace: workspaceDir, }, }, } as OpenClawConfig; try { process.env.XDG_DATA_HOME = xdgDataHome; const provider = buildHermesMigrationProvider(); const ctx = secretContext({ config, reportDir, runtime: makeConfigRuntime(config), }); const plan = await provider.plan(ctx); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "secret:opencode:opencode-auth-json", source: path.join(xdgDataHome, "opencode", "auth.json"), status: "planned", }), ]), ); const result = await provider.apply(ctx, plan); expect(result.summary.errors).toBe(0); const authStore = readAuthProfileStore(agentDir); expect(authStore.profiles?.["opencode:hermes-import"]).toEqual( expect.objectContaining({ type: "api_key", provider: "opencode", key: "xdg-opencode-key", }), ); } finally { if (previousXdgDataHome === undefined) { delete process.env.XDG_DATA_HOME; } else { process.env.XDG_DATA_HOME = previousXdgDataHome; } } }); it("imports OpenCode OpenAI OAuth credentials as OpenAI auth", async () => { const { root, source, workspaceDir, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(".hermes"); const accessToken = fakeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, "https://api.openai.com/profile": { email: "opencode-openai@example.test" }, "https://api.openai.com/auth": { chatgpt_plan_type: "plus", }, }); await writeFile(path.join(source, "auth.json"), "{}"); await writeFile( path.join(root, ".local", "share", "opencode", "auth.json"), JSON.stringify({ openai: { type: "oauth", access: accessToken, refresh: "openai-refresh-token", expires: Date.now() + 3600_000, accountId: "acct_opencode", }, }), ); const config = { agents: { defaults: { workspace: workspaceDir, }, }, } as OpenClawConfig; const ctx = secretContext({ config, reportDir, runtime: makeConfigRuntime(config), }); const plan = await provider.plan(ctx); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "auth:openai", kind: "auth", status: "planned", source: path.join(root, ".local", "share", "opencode", "auth.json"), details: expect.objectContaining({ provider: "openai", sourceKind: "opencode-auth-json", sourceLabel: "OpenCode OpenAI OAuth credential", }), }), ]), ); const result = await provider.apply(ctx, plan); expect(result.summary.errors).toBe(0); const authStore = readAuthProfileStore(agentDir); expect(authStore.profiles?.["openai:account-acct_opencode"]).toEqual( expect.objectContaining({ type: "oauth", provider: "openai", accountId: "acct_opencode", access: accessToken, refresh: "openai-refresh-token", }), ); expect(config.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.6-sol", }); expect(config.agents?.defaults?.models?.["openai/gpt-5.6-sol"]).toEqual({}); }); it("does not apply a planned OpenCode OpenAI OAuth credential after the source token changes", async () => { const { root, source, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(".hermes"); const opencodeAuthPath = path.join(root, ".local", "share", "opencode", "auth.json"); await writeFile(path.join(source, "auth.json"), "{}"); await writeFile( opencodeAuthPath, JSON.stringify({ openai: { type: "oauth", access: "planned-opencode-access", refresh: "planned-opencode-refresh", }, }), ); const ctx = secretContext({ reportDir, }); const plan = await provider.plan(ctx); expect(plan.items).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "auth:openai", details: expect.objectContaining({ sourceCredentialFingerprint: expect.any(String), sourceCredentialIndex: 0, sourceKind: "opencode-auth-json", }), }), ]), ); await writeFile( opencodeAuthPath, JSON.stringify({ openai: { type: "oauth", access: "changed-opencode-access", refresh: "changed-opencode-refresh", }, }), ); const result = await provider.apply(ctx, plan); const authItem = result.items.find((item) => item.id === "auth:openai"); expect(authItem).toEqual( expect.objectContaining({ status: "skipped", reason: HERMES_REASON_SECRET_NO_LONGER_PRESENT, }), ); await expectMissingPath(path.join(agentDir, "auth-profiles.json")); }); it("reports OpenCode OpenAI OAuth config auth profile conflicts during planning", async () => { const { root, source, workspaceDir, secretContext, provider } = await makeHermesSecretFixture(); const accessToken = fakeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, "https://api.openai.com/profile": { email: "codex@example.test" }, "https://api.openai.com/auth": { chatgpt_account_id: "acct_conflict", chatgpt_plan_type: "plus", }, }); const config = { agents: { defaults: { workspace: workspaceDir, }, }, auth: { profiles: { "openai:account-acct_conflict": { provider: "openai", mode: "api_key", }, }, }, } as OpenClawConfig; await writeFile(path.join(source, "auth.json"), "{}"); await writeFile( path.join(root, ".local", "share", "opencode", "auth.json"), JSON.stringify({ openai: { type: "oauth", access: accessToken, refresh: "refresh-test-token", }, }), ); const plan = await provider.plan( secretContext({ config, }), ); const authItem = plan.items.find((item) => item.id === "auth:openai"); expect(authItem).toEqual( expect.objectContaining({ status: "conflict", reason: HERMES_REASON_AUTH_PROFILE_EXISTS, details: expect.objectContaining({ profileId: "openai:account-acct_conflict", }), }), ); }); it("does not collapse OpenCode OpenAI OAuth accounts that share an email", async () => { const { root, source, workspaceDir, secretContext, reportDir, agentDir, provider } = await makeHermesSecretFixture(); const sharedEmail = "shared@example.com"; const accessToken = fakeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, "https://api.openai.com/profile": { email: sharedEmail }, "https://api.openai.com/auth": { chatgpt_account_id: "acct_new", chatgpt_plan_type: "plus", }, }); const config = { agents: { defaults: { workspace: workspaceDir, model: { primary: "anthropic/claude-opus-4-8", fallbacks: ["openai/gpt-5.5"], }, }, }, } as OpenClawConfig; await writeFile(path.join(source, "config.yaml"), "model: openai/gpt-5.5\n"); await writeFile( path.join(root, ".local", "share", "opencode", "auth.json"), JSON.stringify({ openai: { type: "oauth", access: accessToken, refresh: "refresh-new-token", }, }), ); writeAuthProfileStore(agentDir, { version: 1, profiles: { "openai:account-acct_old": { type: "oauth", provider: "openai", access: "old-access-token", refresh: "old-refresh-token", expires: Date.now() + 3600_000, accountId: "acct_old", email: sharedEmail, }, }, }); const ctx = secretContext({ config, reportDir, runtime: makeConfigRuntime(config), }); const plan = await provider.plan(ctx); const authItem = plan.items.find((item) => item.id === "auth:openai"); expect(authItem).toEqual( expect.objectContaining({ status: "planned", details: expect.objectContaining({ profileId: "openai:account-acct_new", }), }), ); const result = await provider.apply(ctx, plan); expect(result.summary.errors).toBe(0); const authStore = readAuthProfileStore(agentDir); expect(authStore.profiles?.["openai:account-acct_old"]).toEqual( expect.objectContaining({ access: "old-access-token", accountId: "acct_old", email: sharedEmail, }), ); expect(authStore.profiles?.["openai:account-acct_new"]).toEqual( expect.objectContaining({ access: accessToken, accountId: "acct_new", email: sharedEmail, }), ); expect(config.agents?.defaults?.model).toEqual({ primary: "anthropic/claude-opus-4-8", fallbacks: ["openai/gpt-5.5"], }); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */