fix migrate supported auth imports

This commit is contained in:
FullerStackDev
2026-05-23 04:24:25 -06:00
committed by Peter Steinberger
parent 50e6cb0828
commit 44bb2be0b4
6 changed files with 1115 additions and 49 deletions

View File

@@ -4,7 +4,7 @@ import { applyAuthProfileConfig, type OpenClawConfig } from "openclaw/plugin-sdk
export type HermesAuthProfileConfig = {
profileId: string;
provider: string;
mode: "api_key" | "oauth";
mode: "api_key" | "oauth" | "token";
email?: string;
displayName?: string;
};

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { loadAuthProfileStoreWithoutExternalProfiles } from "openclaw/plugin-sdk/agent-runtime";
import {
createMigrationItem,
@@ -38,8 +39,12 @@ const HERMES_AUTH_DISPLAY_NAME = "Hermes import";
type HermesCodexAuthCandidate = {
access: string;
accountId?: string;
refresh: string;
sourceKind: "hermes-auth-json" | "opencode-auth-json";
sourceCredentialIndex?: number;
sourceLabel: string;
sourcePath: string;
updatedAt?: number;
};
@@ -86,7 +91,7 @@ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
}
}
function resolveCodexIdentity(access: string): CodexIdentity {
function resolveCodexIdentity(access: string, accountId?: string): CodexIdentity {
const payload = decodeJwtPayload(access);
const auth = isRecord(payload?.["https://api.openai.com/auth"])
? payload["https://api.openai.com/auth"]
@@ -95,11 +100,11 @@ function resolveCodexIdentity(access: string): CodexIdentity {
? payload["https://api.openai.com/profile"]
: {};
const email = readString(profile.email);
const accountId = readString(auth.chatgpt_account_id);
const resolvedAccountId = accountId ?? readString(auth.chatgpt_account_id);
const chatgptPlanType = readString(auth.chatgpt_plan_type);
if (email) {
return {
...(accountId ? { accountId } : {}),
...(resolvedAccountId ? { accountId: resolvedAccountId } : {}),
...(chatgptPlanType ? { chatgptPlanType } : {}),
email,
profileName: email,
@@ -109,9 +114,10 @@ function resolveCodexIdentity(access: string): CodexIdentity {
readString(auth.chatgpt_account_user_id) ??
readString(auth.chatgpt_user_id) ??
readString(auth.user_id) ??
readString(payload?.sub);
readString(payload?.sub) ??
resolvedAccountId;
return {
...(accountId ? { accountId } : {}),
...(resolvedAccountId ? { accountId: resolvedAccountId } : {}),
...(chatgptPlanType ? { chatgptPlanType } : {}),
...(stableSubject
? { profileName: `id-${Buffer.from(stableSubject).toString("base64url")}` }
@@ -131,7 +137,24 @@ function resolveAccessTokenExpiry(access: string): number | undefined {
return undefined;
}
function readProviderTokens(auth: Record<string, unknown>): HermesCodexAuthCandidate | undefined {
function sourceCredentialFingerprint(candidate: HermesCodexAuthCandidate): string {
const hash = createHash("sha256");
for (const part of [
candidate.sourceKind,
candidate.accountId ?? "",
candidate.access,
candidate.refresh,
]) {
hash.update(part);
hash.update("\0");
}
return hash.digest("hex");
}
function readProviderTokens(
auth: Record<string, unknown>,
sourcePath: string,
): HermesCodexAuthCandidate | undefined {
const providers = isRecord(auth.providers) ? auth.providers : {};
const provider = isRecord(providers[OPENAI_CODEX_PROVIDER_ID])
? providers[OPENAI_CODEX_PROVIDER_ID]
@@ -145,12 +168,17 @@ function readProviderTokens(auth: Record<string, unknown>): HermesCodexAuthCandi
return {
access,
refresh,
sourceKind: "hermes-auth-json",
sourceLabel: "Hermes active OpenAI Codex provider",
sourcePath,
updatedAt: readTimestamp(provider?.last_refresh),
};
}
function readPoolTokens(auth: Record<string, unknown>): HermesCodexAuthCandidate[] {
function readPoolTokens(
auth: Record<string, unknown>,
sourcePath: string,
): HermesCodexAuthCandidate[] {
const pool = isRecord(auth.credential_pool) ? auth.credential_pool : {};
const entries = Array.isArray(pool[OPENAI_CODEX_PROVIDER_ID])
? pool[OPENAI_CODEX_PROVIDER_ID]
@@ -169,7 +197,9 @@ function readPoolTokens(auth: Record<string, unknown>): HermesCodexAuthCandidate
candidates.push({
access,
refresh,
sourceKind: "hermes-auth-json",
sourceLabel: label,
sourcePath,
updatedAt: readTimestamp(entry.last_refresh) ?? readTimestamp(entry.last_status_at),
});
}
@@ -180,7 +210,7 @@ async function readHermesCodexAuthCandidates(
authPath: string | undefined,
): Promise<HermesCodexAuthCandidate[]> {
const raw = await readText(authPath);
if (!raw) {
if (!raw || !authPath) {
return [];
}
let parsed: unknown;
@@ -192,9 +222,49 @@ async function readHermesCodexAuthCandidates(
if (!isRecord(parsed)) {
return [];
}
return [readProviderTokens(parsed), ...readPoolTokens(parsed)]
const candidates = [readProviderTokens(parsed, authPath), ...readPoolTokens(parsed, authPath)]
.filter((candidate): candidate is HermesCodexAuthCandidate => candidate !== undefined)
.toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0));
candidates.forEach((candidate, index) => {
candidate.sourceCredentialIndex = index;
});
return candidates;
}
async function readOpenCodeOpenAICandidates(
authPath: string | undefined,
): Promise<HermesCodexAuthCandidate[]> {
const raw = await readText(authPath);
if (!raw || !authPath) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!isRecord(parsed)) {
return [];
}
const openai = isRecord(parsed.openai) ? parsed.openai : undefined;
const access = readString(openai?.access);
const accountId = readString(openai?.accountId);
const refresh = readString(openai?.refresh);
if (!access || !refresh) {
return [];
}
return [
{
access,
...(accountId ? { accountId } : {}),
refresh,
sourceKind: "opencode-auth-json",
sourceCredentialIndex: 0,
sourceLabel: "OpenCode OpenAI OAuth credential",
sourcePath: authPath,
},
];
}
function credentialExtra(identity: CodexIdentity): Record<string, unknown> | undefined {
@@ -219,7 +289,7 @@ function buildAuthResult(
candidate: HermesCodexAuthCandidate,
fallbackProfileName = "hermes-import",
): ProviderAuthResult {
const identity = resolveCodexIdentity(candidate.access);
const identity = resolveCodexIdentity(candidate.access, candidate.accountId);
return buildOauthProviderAuthResult({
providerId: OPENAI_CODEX_PROVIDER_ID,
defaultModel: OPENAI_CODEX_DEFAULT_MODEL,
@@ -243,10 +313,13 @@ function authProfileDedupeKey(profile: HermesCodexAuthProfile): string {
return `${profile.credential.provider}:profile:${profile.sourceProfileId}`;
}
async function readHermesCodexAuthProfiles(
authPath: string | undefined,
async function readCodexAuthProfilesFromSource(
source: HermesSource,
): Promise<HermesCodexAuthProfile[]> {
const candidates = await readHermesCodexAuthCandidates(authPath);
const candidates = [
...(await readHermesCodexAuthCandidates(source.authPath)),
...(await readOpenCodeOpenAICandidates(source.opencodeAuthPath)),
].toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0));
const profiles: HermesCodexAuthProfile[] = [];
const seen = new Set<string>();
for (const [index, candidate] of candidates.entries()) {
@@ -273,6 +346,24 @@ async function readHermesCodexAuthProfiles(
return profiles;
}
async function readCodexAuthProfilesFromPath(params: {
sourcePath: string | undefined;
sourceKind: unknown;
}): Promise<HermesCodexAuthProfile[]> {
if (params.sourceKind === "opencode-auth-json") {
return await readCodexAuthProfilesFromSource({
root: "",
archivePaths: [],
...(params.sourcePath ? { opencodeAuthPath: params.sourcePath } : {}),
});
}
return await readCodexAuthProfilesFromSource({
root: "",
archivePaths: [],
...(params.sourcePath ? { authPath: params.sourcePath } : {}),
});
}
function findMatchingProfile(
store: AuthProfileStore,
credential: OAuthCredential,
@@ -305,12 +396,47 @@ function oauthAuthProfileConfig(
};
}
function matchesSourceCredentialFingerprint(
profile: HermesCodexAuthProfile,
fingerprint: string,
): boolean {
return sourceCredentialFingerprint(profile.candidate) === fingerprint;
}
function findPlannedAuthProfile(params: {
profiles: HermesCodexAuthProfile[];
sourceProfileId: string;
sourceCredentialIndex?: number;
sourceCredentialFingerprint?: string;
}): HermesCodexAuthProfile | undefined {
const bySourceProfileId = params.profiles.find(
(entry) => entry.sourceProfileId === params.sourceProfileId,
);
const fingerprint = params.sourceCredentialFingerprint;
if (!fingerprint) {
return bySourceProfileId;
}
if (bySourceProfileId && matchesSourceCredentialFingerprint(bySourceProfileId, fingerprint)) {
return bySourceProfileId;
}
const byIndex =
params.sourceCredentialIndex === undefined
? undefined
: params.profiles.find(
(entry) => entry.candidate.sourceCredentialIndex === params.sourceCredentialIndex,
);
if (byIndex && matchesSourceCredentialFingerprint(byIndex, fingerprint)) {
return byIndex;
}
return params.profiles.find((entry) => matchesSourceCredentialFingerprint(entry, fingerprint));
}
export async function buildAuthItems(params: {
ctx: MigrationProviderContext;
source: HermesSource;
targets: PlannedTargets;
}): Promise<MigrationItem[]> {
const profiles = await readHermesCodexAuthProfiles(params.source.authPath);
const profiles = await readCodexAuthProfilesFromSource(params.source);
if (profiles.length === 0) {
return [];
}
@@ -335,7 +461,7 @@ export async function buildAuthItems(params: {
id: itemId,
kind: "auth",
action: skipped ? "skip" : "create",
source: params.source.authPath,
source: profile.candidate.sourcePath,
target: `${params.targets.agentDir}/auth-profiles.json#${profileId}`,
status: skipped ? "skipped" : conflict ? "conflict" : "planned",
sensitive: true,
@@ -350,8 +476,12 @@ export async function buildAuthItems(params: {
details: {
provider: OPENAI_CODEX_PROVIDER_ID,
profileId,
...(typeof profile.candidate.sourceCredentialIndex === "number"
? { sourceCredentialIndex: profile.candidate.sourceCredentialIndex }
: {}),
sourceCredentialFingerprint: sourceCredentialFingerprint(profile.candidate),
sourceProfileId: profile.sourceProfileId,
sourceKind: "hermes-auth-json",
sourceKind: profile.candidate.sourceKind,
sourceLabel: profile.candidate.sourceLabel,
},
});
@@ -370,12 +500,27 @@ export async function applyAuthItem(
const profileId = typeof item.details?.profileId === "string" ? item.details.profileId : "";
const sourceProfileId =
typeof item.details?.sourceProfileId === "string" ? item.details.sourceProfileId : profileId;
const sourceCredentialIndex =
typeof item.details?.sourceCredentialIndex === "number"
? item.details.sourceCredentialIndex
: undefined;
const sourceCredentialFingerprint =
typeof item.details?.sourceCredentialFingerprint === "string"
? item.details.sourceCredentialFingerprint
: undefined;
if (!source || !profileId) {
return markMigrationItemError(item, HERMES_REASON_MISSING_SECRET_METADATA);
}
const profile = (await readHermesCodexAuthProfiles(source)).find(
(entry) => entry.sourceProfileId === sourceProfileId,
);
const profiles = await readCodexAuthProfilesFromPath({
sourcePath: source,
sourceKind: item.details?.sourceKind,
});
const profile = findPlannedAuthProfile({
profiles,
sourceProfileId,
...(sourceCredentialIndex === undefined ? {} : { sourceCredentialIndex }),
...(sourceCredentialFingerprint ? { sourceCredentialFingerprint } : {}),
});
if (!profile) {
return markMigrationItemSkipped(item, HERMES_REASON_SECRET_NO_LONGER_PRESENT);
}

View File

@@ -50,9 +50,13 @@ export function createHermesSecretItem(params: {
includeSecrets?: boolean;
existsAlready?: boolean;
details: {
envVar: string;
envVar?: string;
provider: string;
profileId: string;
mode?: "token";
sourceKind?: "hermes-env" | "opencode-auth-json";
sourceProvider?: string;
secretField?: string;
};
}): MigrationItem {
const skipped = !params.includeSecrets;
@@ -74,13 +78,36 @@ export function createHermesSecretItem(params: {
});
}
export function readHermesSecretDetails(
item: MigrationItem,
): { envVar: string; provider: string; profileId: string } | undefined {
export function readHermesSecretDetails(item: MigrationItem):
| {
envVar?: string;
provider: string;
profileId: string;
mode?: "token";
sourceKind?: string;
sourceProvider?: string;
secretField?: string;
}
| undefined {
const envVar = readString(item.details?.envVar);
const provider = readString(item.details?.provider);
const profileId = readString(item.details?.profileId);
return envVar && provider && profileId ? { envVar, provider, profileId } : undefined;
if (!provider || !profileId) {
return undefined;
}
const mode = item.details?.mode === "token" ? "token" : undefined;
const sourceKind = readString(item.details?.sourceKind);
const sourceProvider = readString(item.details?.sourceProvider);
const secretField = readString(item.details?.secretField);
return {
...(envVar ? { envVar } : {}),
provider,
profileId,
...(mode ? { mode } : {}),
...(sourceKind ? { sourceKind } : {}),
...(sourceProvider ? { sourceProvider } : {}),
...(secretField ? { secretField } : {}),
};
}
export function hermesItemConflict(item: MigrationItem, reason: string): MigrationItem {

View File

@@ -3,7 +3,10 @@ import path from "node:path";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it } from "vitest";
import { HERMES_REASON_AUTH_PROFILE_EXISTS } from "./items.js";
import {
HERMES_REASON_AUTH_PROFILE_EXISTS,
HERMES_REASON_SECRET_NO_LONGER_PRESENT,
} from "./items.js";
import { buildHermesMigrationProvider } from "./provider.js";
import {
cleanupTempRoots,
@@ -441,6 +444,616 @@ describe("Hermes migration secret items", () => {
expect(config.agents?.defaults?.models?.["openai/gpt-5.5"]).toEqual({});
});
it("imports supported Hermes provider env credentials including OpenCode and GitHub Copilot", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
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");
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 provider = buildHermesMigrationProvider();
const ctx = makeContext({
source,
stateDir,
workspaceDir,
config,
includeSecrets: true,
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 = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"),
) as {
profiles?: Record<string, { key?: string; provider?: string; token?: string; type?: string }>;
};
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 root = await makeTempRoot();
const source = path.join(root, "hermes");
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");
await writeFile(path.join(source, ".env"), "PERPLEXITY_API_KEY=pplx-hermes\n");
const provider = buildHermesMigrationProvider();
const ctx = makeContext({
source,
stateDir,
workspaceDir,
includeSecrets: true,
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 = await makeTempRoot();
const source = path.join(root, ".hermes");
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");
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 provider = buildHermesMigrationProvider();
const ctx = makeContext({
source,
stateDir,
workspaceDir,
config,
includeSecrets: true,
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 = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"),
) as {
profiles?: Record<string, { key?: string; provider?: string; token?: string; type?: string }>;
};
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 = await makeTempRoot();
const source = path.join(root, ".hermes");
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");
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 provider = buildHermesMigrationProvider();
const ctx = makeContext({
source,
stateDir,
workspaceDir,
config,
includeSecrets: true,
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 = await makeTempRoot();
const source = path.join(root, ".hermes");
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 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 = makeContext({
source,
stateDir,
workspaceDir,
config,
includeSecrets: true,
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 = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"),
) as {
profiles?: Record<string, { key?: string; provider?: string; type?: string }>;
};
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 Codex auth", async () => {
const root = await makeTempRoot();
const source = path.join(root, ".hermes");
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 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, "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: "openai-refresh-token",
expires: Date.now() + 3600_000,
accountId: "acct_opencode",
},
}),
);
const config = {
agents: {
defaults: {
workspace: workspaceDir,
},
},
} as OpenClawConfig;
const provider = buildHermesMigrationProvider();
const ctx = makeContext({
source,
stateDir,
workspaceDir,
config,
includeSecrets: true,
reportDir,
runtime: makeConfigRuntime(config),
});
const plan = await provider.plan(ctx);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "auth:openai-codex",
kind: "auth",
status: "planned",
source: path.join(root, ".local", "share", "opencode", "auth.json"),
details: expect.objectContaining({
provider: "openai-codex",
sourceKind: "opencode-auth-json",
sourceLabel: "OpenCode OpenAI OAuth credential",
}),
}),
]),
);
const result = await provider.apply(ctx, plan);
expect(result.summary.errors).toBe(0);
const authStore = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"),
) as {
profiles?: Record<
string,
{ access?: string; accountId?: string; provider?: string; refresh?: string; type?: string }
>;
};
expect(authStore.profiles?.["openai-codex:account-acct_opencode"]).toEqual(
expect.objectContaining({
type: "oauth",
provider: "openai-codex",
accountId: "acct_opencode",
access: accessToken,
refresh: "openai-refresh-token",
}),
);
});
it("applies mixed Hermes and OpenCode OpenAI OAuth credentials with source-stable fingerprints", async () => {
const root = await makeTempRoot();
const source = path.join(root, ".hermes");
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");
await writeFile(
path.join(source, "auth.json"),
JSON.stringify({
providers: {
"openai-codex": {
tokens: {
access_token: "opaque-hermes-access",
refresh_token: "opaque-hermes-refresh",
},
},
},
}),
);
await writeFile(
path.join(root, ".local", "share", "opencode", "auth.json"),
JSON.stringify({
openai: {
type: "oauth",
access: "opaque-opencode-access",
refresh: "opaque-opencode-refresh",
},
}),
);
const provider = buildHermesMigrationProvider();
const ctx = makeContext({
source,
stateDir,
workspaceDir,
includeSecrets: true,
reportDir,
});
const plan = await provider.plan(ctx);
const authItems = plan.items.filter((item) => item.kind === "auth");
expect(authItems).toHaveLength(2);
expect(authItems.map((item) => item.status)).toEqual(["planned", "planned"]);
expect(authItems.map((item) => item.details?.sourceCredentialIndex)).toEqual([0, 0]);
expect(authItems.map((item) => item.details?.sourceCredentialFingerprint)).toEqual([
expect.any(String),
expect.any(String),
]);
const result = await provider.apply(ctx, plan);
expect(
result.items
.filter((item) => item.kind === "auth")
.map((item) => ({ id: item.id, status: item.status })),
).toEqual([
{ id: "auth:openai-codex:openai-codex:hermes-import-1", status: "migrated" },
{ id: "auth:openai-codex:openai-codex:hermes-import-2", status: "migrated" },
]);
const authStore = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"),
) as {
profiles?: Record<string, { access?: string; provider?: string; refresh?: string }>;
};
expect(authStore.profiles?.["openai-codex:hermes-import-1"]).toEqual(
expect.objectContaining({
provider: "openai-codex",
access: "opaque-hermes-access",
refresh: "opaque-hermes-refresh",
}),
);
expect(authStore.profiles?.["openai-codex:hermes-import-2"]).toEqual(
expect.objectContaining({
provider: "openai-codex",
access: "opaque-opencode-access",
refresh: "opaque-opencode-refresh",
}),
);
});
it("does not apply a planned OpenCode OpenAI OAuth credential after the source token changes", async () => {
const root = await makeTempRoot();
const source = path.join(root, ".hermes");
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 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 provider = buildHermesMigrationProvider();
const ctx = makeContext({
source,
stateDir,
workspaceDir,
includeSecrets: true,
reportDir,
});
const plan = await provider.plan(ctx);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "auth:openai-codex",
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-codex");
expect(authItem).toEqual(
expect.objectContaining({
status: "skipped",
reason: HERMES_REASON_SECRET_NO_LONGER_PRESENT,
}),
);
await expectMissingPath(path.join(agentDir, "auth-profiles.json"));
});
it("reports Hermes OAuth config auth profile conflicts during planning", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");

View File

@@ -22,10 +22,13 @@ import {
import type { HermesSource } from "./source.js";
import type { PlannedTargets } from "./targets.js";
type SecretCredentialMode = "api_key" | "token";
type SecretMapping = {
envVar: string;
provider: string;
profileId: string;
mode?: SecretCredentialMode;
};
const SECRET_MAPPINGS: readonly SecretMapping[] = [
@@ -38,17 +41,234 @@ const SECRET_MAPPINGS: readonly SecretMapping[] = [
{ envVar: "XAI_API_KEY", provider: "xai", profileId: "xai:hermes-import" },
{ envVar: "MISTRAL_API_KEY", provider: "mistral", profileId: "mistral:hermes-import" },
{ envVar: "DEEPSEEK_API_KEY", provider: "deepseek", profileId: "deepseek:hermes-import" },
{ envVar: "ZAI_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "Z_AI_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "GLM_API_KEY", provider: "zai", profileId: "zai:hermes-import" },
{ envVar: "KIMI_API_KEY", provider: "kimi-coding", profileId: "kimi-coding:hermes-import" },
{ envVar: "KIMICODE_API_KEY", provider: "kimi-coding", profileId: "kimi-coding:hermes-import" },
{ envVar: "MOONSHOT_API_KEY", provider: "moonshot", profileId: "moonshot:hermes-import" },
{ envVar: "MINIMAX_API_KEY", provider: "minimax", profileId: "minimax:hermes-import" },
{
envVar: "MINIMAX_CODING_API_KEY",
provider: "minimax",
profileId: "minimax:hermes-import",
},
{ envVar: "DASHSCOPE_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "QWEN_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "MODELSTUDIO_API_KEY", provider: "qwen", profileId: "qwen:hermes-import" },
{ envVar: "KILOCODE_API_KEY", provider: "kilocode", profileId: "kilocode:hermes-import" },
{
envVar: "AI_GATEWAY_API_KEY",
provider: "vercel-ai-gateway",
profileId: "vercel-ai-gateway:hermes-import",
},
{ envVar: "HF_TOKEN", provider: "huggingface", profileId: "huggingface:hermes-import" },
{
envVar: "HUGGINGFACE_HUB_TOKEN",
provider: "huggingface",
profileId: "huggingface:hermes-import",
},
{ envVar: "TOGETHER_API_KEY", provider: "together", profileId: "together:hermes-import" },
{ envVar: "FIREWORKS_API_KEY", provider: "fireworks", profileId: "fireworks:hermes-import" },
{ envVar: "DEEPINFRA_API_KEY", provider: "deepinfra", profileId: "deepinfra:hermes-import" },
{ envVar: "CEREBRAS_API_KEY", provider: "cerebras", profileId: "cerebras:hermes-import" },
{ envVar: "NVIDIA_API_KEY", provider: "nvidia", profileId: "nvidia:hermes-import" },
{ envVar: "VENICE_API_KEY", provider: "venice", profileId: "venice:hermes-import" },
{ envVar: "XIAOMI_API_KEY", provider: "xiaomi", profileId: "xiaomi:hermes-import" },
{ envVar: "ALIBABA_API_KEY", provider: "alibaba", profileId: "alibaba:hermes-import" },
{ envVar: "ARCEEAI_API_KEY", provider: "arcee", profileId: "arcee:hermes-import" },
{ envVar: "CHUTES_API_KEY", provider: "chutes", profileId: "chutes:hermes-import" },
{
envVar: "CLOUDFLARE_AI_GATEWAY_API_KEY",
provider: "cloudflare-ai-gateway",
profileId: "cloudflare-ai-gateway:hermes-import",
},
{ envVar: "QIANFAN_API_KEY", provider: "qianfan", profileId: "qianfan:hermes-import" },
{ envVar: "OPENCODE_API_KEY", provider: "opencode", profileId: "opencode:hermes-import" },
{ envVar: "OPENCODE_API_KEY", provider: "opencode-go", profileId: "opencode-go:hermes-import" },
{ envVar: "OPENCODE_ZEN_API_KEY", provider: "opencode", profileId: "opencode:hermes-import" },
{
envVar: "OPENCODE_ZEN_API_KEY",
provider: "opencode-go",
profileId: "opencode-go:hermes-import",
},
{
envVar: "OPENCODE_GO_API_KEY",
provider: "opencode-go",
profileId: "opencode-go:hermes-import",
},
{
envVar: "COPILOT_GITHUB_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
{
envVar: "GH_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
{
envVar: "GITHUB_TOKEN",
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
},
] as const;
function secretAuthProfileConfig(details: SecretMapping): HermesAuthProfileConfig {
type SecretCandidate = {
id: string;
source?: string;
envVar?: string;
provider: string;
profileId: string;
mode: SecretCredentialMode;
sourceKind?: "hermes-env" | "opencode-auth-json";
sourceProvider?: string;
secretField?: string;
};
function secretAuthProfileConfig(details: {
provider: string;
profileId: string;
mode?: SecretCredentialMode;
}): HermesAuthProfileConfig {
return {
profileId: details.profileId,
provider: details.provider,
mode: "api_key",
mode: details.mode ?? "api_key",
displayName: "Hermes import",
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function secretMode(mapping: SecretMapping): SecretCredentialMode {
return mapping.mode ?? "api_key";
}
function buildEnvSecretCandidates(params: {
env: Record<string, string>;
envPath?: string;
}): SecretCandidate[] {
return SECRET_MAPPINGS.flatMap((mapping) => {
const value = params.env[mapping.envVar]?.trim();
if (!value) {
return [];
}
return [
{
id: `secret:${mapping.provider}`,
source: params.envPath,
envVar: mapping.envVar,
provider: mapping.provider,
profileId: mapping.profileId,
mode: secretMode(mapping),
},
];
});
}
async function readOpenCodeAuthJson(
authPath: string | undefined,
): Promise<Record<string, unknown>> {
const raw = await readText(authPath);
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
async function buildOpenCodeSecretCandidates(
authPath: string | undefined,
): Promise<SecretCandidate[]> {
if (!authPath) {
return [];
}
const auth = await readOpenCodeAuthJson(authPath);
const opencode = isRecord(auth.opencode) ? auth.opencode : {};
const opencodeGo = isRecord(auth["opencode-go"]) ? auth["opencode-go"] : {};
const githubCopilot = isRecord(auth["github-copilot"]) ? auth["github-copilot"] : {};
const githubCopilotEnterpriseUrl = readString(githubCopilot.enterpriseUrl);
const candidates: SecretCandidate[] = [];
if (readString(opencode.key)) {
candidates.push({
id: "secret:opencode:opencode-auth-json",
source: authPath,
provider: "opencode",
profileId: "opencode:hermes-import",
mode: "api_key",
sourceKind: "opencode-auth-json",
sourceProvider: "opencode",
secretField: "key",
});
}
if (readString(opencodeGo.key)) {
candidates.push({
id: "secret:opencode-go:opencode-auth-json",
source: authPath,
provider: "opencode-go",
profileId: "opencode-go:hermes-import",
mode: "api_key",
sourceKind: "opencode-auth-json",
sourceProvider: "opencode-go",
secretField: "key",
});
}
// OpenClaw's Copilot token profile cannot preserve OpenCode enterprise routing yet.
if (readString(githubCopilot.refresh) && !githubCopilotEnterpriseUrl) {
candidates.push({
id: "secret:github-copilot:opencode-auth-json",
source: authPath,
provider: "github-copilot",
profileId: "github-copilot:github",
mode: "token",
sourceKind: "opencode-auth-json",
sourceProvider: "github-copilot",
secretField: "refresh",
});
}
return candidates;
}
async function readSecretCandidateValue(
details: {
envVar?: string;
sourceKind?: string;
sourceProvider?: string;
secretField?: string;
},
source: string,
): Promise<string | undefined> {
if (details.sourceKind === "opencode-auth-json") {
const auth = await readOpenCodeAuthJson(source);
const sourceProvider = details.sourceProvider;
const secretField = details.secretField;
if (!sourceProvider || !secretField) {
return undefined;
}
const provider = isRecord(auth[sourceProvider]) ? auth[sourceProvider] : {};
return readString(provider[secretField]);
}
if (!details.envVar) {
return undefined;
}
const env = parseEnv(await readText(source));
return env[details.envVar]?.trim() || undefined;
}
export async function buildSecretItems(params: {
ctx: MigrationProviderContext;
source: HermesSource;
@@ -58,29 +278,36 @@ export async function buildSecretItems(params: {
const store = loadAuthProfileStoreWithoutExternalProfiles(params.targets.agentDir);
const seenProfiles = new Set<string>();
const items: MigrationItem[] = [];
for (const mapping of SECRET_MAPPINGS) {
const value = env[mapping.envVar]?.trim();
if (!value || seenProfiles.has(mapping.profileId)) {
const candidates = [
...buildEnvSecretCandidates({ env, envPath: params.source.envPath }),
...(await buildOpenCodeSecretCandidates(params.source.opencodeAuthPath)),
];
for (const candidate of candidates) {
if (seenProfiles.has(candidate.profileId)) {
continue;
}
seenProfiles.add(mapping.profileId);
const existsAlready = Boolean(store.profiles[mapping.profileId]);
seenProfiles.add(candidate.profileId);
const existsAlready = Boolean(store.profiles[candidate.profileId]);
const configConflict = hasAuthProfileConfigConflict(
params.ctx.config,
secretAuthProfileConfig(mapping),
secretAuthProfileConfig(candidate),
Boolean(params.ctx.overwrite),
);
items.push(
createHermesSecretItem({
id: `secret:${mapping.provider}`,
source: params.source.envPath,
target: `${params.targets.agentDir}/auth-profiles.json#${mapping.profileId}`,
id: candidate.id,
source: candidate.source,
target: `${params.targets.agentDir}/auth-profiles.json#${candidate.profileId}`,
includeSecrets: params.ctx.includeSecrets,
existsAlready: (existsAlready && !params.ctx.overwrite) || configConflict,
details: {
envVar: mapping.envVar,
provider: mapping.provider,
profileId: mapping.profileId,
...(candidate.envVar ? { envVar: candidate.envVar } : {}),
provider: candidate.provider,
profileId: candidate.profileId,
...(candidate.mode === "token" ? { mode: candidate.mode } : {}),
...(candidate.sourceKind ? { sourceKind: candidate.sourceKind } : {}),
...(candidate.sourceProvider ? { sourceProvider: candidate.sourceProvider } : {}),
...(candidate.secretField ? { secretField: candidate.secretField } : {}),
},
}),
);
@@ -101,8 +328,7 @@ export async function applySecretItem(
if (!details || !source) {
return hermesItemError(item, HERMES_REASON_MISSING_SECRET_METADATA);
}
const env = parseEnv(await readText(source));
const key = env[details.envVar]?.trim();
const key = await readSecretCandidateValue(details, source);
if (!key) {
return hermesItemSkipped(item, HERMES_REASON_SECRET_NO_LONGER_PRESENT);
}
@@ -119,12 +345,20 @@ export async function applySecretItem(
conflicted = true;
return false;
}
freshStore.profiles[details.profileId] = {
type: "api_key",
provider: details.provider,
key,
displayName: "Hermes import",
};
freshStore.profiles[details.profileId] =
details.mode === "token"
? {
type: "token",
provider: details.provider,
token: key,
displayName: "Hermes import",
}
: {
type: "api_key",
provider: details.provider,
key,
displayName: "Hermes import",
};
wrote = true;
return true;
},

View File

@@ -6,6 +6,7 @@ export type HermesSource = {
configPath?: string;
envPath?: string;
authPath?: string;
opencodeAuthPath?: string;
soulPath?: string;
agentsPath?: string;
memoryPath?: string;
@@ -22,9 +23,54 @@ type HermesArchivePath = {
const HERMES_ARCHIVE_DIRS = ["plugins", "sessions", "logs", "cron", "mcp-tokens"] as const;
const HERMES_ARCHIVE_FILES = ["state.db"] as const;
const OPENCODE_AUTH_RELATIVE_PATH = path.join(".local", "share", "opencode", "auth.json");
function isSameOrInside(parent: string, candidate: string): boolean {
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function resolveOpenCodeXdgAuthPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
const xdgDataHome = env.XDG_DATA_HOME?.trim();
return xdgDataHome ? path.join(resolveHomePath(xdgDataHome), "opencode", "auth.json") : undefined;
}
async function discoverOpenCodeAuthPath(params: {
root: string;
includeGlobalFallback: boolean;
includeHomeFallback: boolean;
}): Promise<string | undefined> {
const rootParent = path.dirname(params.root);
const xdgAuthPath = resolveOpenCodeXdgAuthPath();
const candidates = Array.from(
new Set(
[
...(xdgAuthPath && (params.includeGlobalFallback || isSameOrInside(rootParent, xdgAuthPath))
? [xdgAuthPath]
: []),
path.join(rootParent, OPENCODE_AUTH_RELATIVE_PATH),
...(params.includeHomeFallback
? [resolveHomePath(`~/${OPENCODE_AUTH_RELATIVE_PATH}`)]
: []),
].filter((candidate): candidate is string => Boolean(candidate)),
),
);
for (const candidate of candidates) {
if (await exists(candidate)) {
return candidate;
}
}
return undefined;
}
export async function discoverHermesSource(input?: string): Promise<HermesSource> {
const root = resolveHomePath(input?.trim() || "~/.hermes");
const explicitInput = input?.trim();
const root = resolveHomePath(explicitInput || "~/.hermes");
const opencodeAuthPath = await discoverOpenCodeAuthPath({
root,
includeGlobalFallback: !explicitInput,
includeHomeFallback: !explicitInput,
});
const archivePaths: HermesArchivePath[] = [];
for (const dir of HERMES_ARCHIVE_DIRS) {
const candidate = path.join(root, dir);
@@ -48,6 +94,7 @@ export async function discoverHermesSource(input?: string): Promise<HermesSource
...((await exists(path.join(root, "auth.json")))
? { authPath: path.join(root, "auth.json") }
: {}),
...(opencodeAuthPath ? { opencodeAuthPath } : {}),
...((await exists(path.join(root, "SOUL.md"))) ? { soulPath: path.join(root, "SOUL.md") } : {}),
...((await exists(path.join(root, "AGENTS.md")))
? { agentsPath: path.join(root, "AGENTS.md") }