mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix migrate supported auth imports
This commit is contained in:
committed by
Peter Steinberger
parent
50e6cb0828
commit
44bb2be0b4
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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") }
|
||||
|
||||
Reference in New Issue
Block a user