mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
7e87d77261
`openclaw models auth list` printed `Auth state store:
<state>/agents/main/agent/openclaw-agent.sqlite` on every install created since
a8a9f284fb, and that file does not exist. Credentials now persist in the shared
state database, so an operator debugging auth was sent to the wrong file while
the listed profiles resolved correctly from somewhere else.
`resolveAuthStorePathForDisplay` and `resolveAuthStatePathForDisplay` named the
agent-local file whenever an agent dir was supplied. That matched storage before
shared-auth ownership moved and stopped matching afterwards. The same helpers
feed `models auth order`, `models list --status`, the auth overview, two
auto-reply directive surfaces, and the `path` field of doctor's auth
HealthFindings, so structured diagnostics pointed at the wrong file too.
Display now mirrors the loader's own selection: an agent with a local auth store
shows its own database, otherwise the shared owner. Both helpers move to the
`paths.ts` barrel so they can consult `hasLocalAuthProfileStoreSource` without a
cycle back through `path-resolve`. Nothing about storage or loading changes.
`model-auth-provider` no longer derives the agent dir from the store path -- that
would have reported the state directory once the shared owner is selected -- and
uses the caller's agent dir instead.
Production -4 LOC.
1369 lines
43 KiB
TypeScript
1369 lines
43 KiB
TypeScript
// 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<void> {
|
|
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, unknown>): 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<Parameters<typeof makeProviderContext>[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<string, { baseUrl?: string }>;
|
|
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);
|
|
const plannedTarget = authProfileTarget(agentDir, "openai:hermes-import");
|
|
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: plannedTarget,
|
|
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. */
|