diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 9506d07c932..03dc21c5d0d 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -90eb16c2723d037a6462192fbd689665c79ef354cb5a4aeafaf7d5e9689b6b65 plugin-sdk-api-baseline.json -e737cd21c1c18748f86c165d57dcce41eb23052cd2fe92ffb7ed7caf752baaad plugin-sdk-api-baseline.jsonl +0c69a93645885b5135fe4cb920b25aa24505cb2f818281a0185b2edc1966732d plugin-sdk-api-baseline.json +23d13fe064bb240d81a385c7fc02ca732c909b9cbdcae02fd45339dd3f74bd73 plugin-sdk-api-baseline.jsonl diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index ecc612874f8..6df3460fabb 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -46,7 +46,10 @@ openclaw onboard --import-from hermes --import-source ~/.hermes Override the source state directory. Hermes defaults to `~/.hermes`. - Import supported credentials. Off by default. + Import supported credentials without prompting. Interactive apply asks before importing detected auth credentials, with yes selected by default; non-interactive `--yes` requires `--include-secrets` to import them. + + + Skip auth credential import, including the interactive prompt. Allow apply to replace existing targets when the plan reports conflicts. @@ -91,7 +94,7 @@ openclaw onboard --import-from hermes --import-source ~/.hermes Apply refuses to continue when the plan has conflicts. Review the plan, then rerun with `--overwrite` if replacing existing targets is intentional. Providers may still write item-level backups for overwritten files in the migration report directory. - Secrets are never imported by default. Use `--include-secrets` to import supported credentials. + Interactive apply asks whether to import detected auth credentials, with yes selected by default. Use `--no-auth-credentials` to skip them, or use `--include-secrets` for unattended credential import with `--yes`. @@ -233,7 +236,8 @@ The bundled Hermes provider detects state at `~/.hermes` by default. Use `--from - Memory config defaults for OpenClaw file memory, plus archive or manual-review items for external memory providers such as Honcho. - Skills that include a `SKILL.md` file under `skills//`. - Per-skill config values from `skills.config`. -- Supported API keys from `.env`, only with `--include-secrets`. +- Supported OAuth credentials from `auth.json` when interactive credential migration is accepted, or when `--include-secrets` is set. +- Supported API keys from `.env` when interactive credential migration is accepted, or when `--include-secrets` is set. ### Supported `.env` keys @@ -248,7 +252,6 @@ Hermes state that OpenClaw cannot safely interpret is copied into the migration - `logs/` - `cron/` - `mcp-tokens/` -- `auth.json` - `state.db` ### After applying diff --git a/docs/install/migrating-hermes.md b/docs/install/migrating-hermes.md index 9e4f0810979..d45963936c5 100644 --- a/docs/install/migrating-hermes.md +++ b/docs/install/migrating-hermes.md @@ -65,8 +65,8 @@ Imports require a fresh OpenClaw setup. If you already have local OpenClaw state Skills with a `SKILL.md` file under `skills//` are copied, along with per-skill config values from `skills.config`. - - Set `--include-secrets` to import supported `.env` keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `GOOGLE_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `DEEPSEEK_API_KEY`. Without the flag, secrets are never copied. + + Interactive `openclaw migrate` asks before importing auth credentials, with yes selected by default. Accepted imports include supported OAuth credentials from `auth.json` and supported `.env` keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `GOOGLE_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `DEEPSEEK_API_KEY`. Use `--include-secrets` for non-interactive `openclaw migrate` credential import, `--no-auth-credentials` to skip it, or onboarding `--import-secrets` when importing from the onboarding wizard. @@ -79,7 +79,6 @@ The provider copies these into the migration report directory for manual review, - `logs/` - `cron/` - `mcp-tokens/` -- `auth.json` - `state.db` OpenClaw refuses to execute or trust this state automatically because the formats and trust assumptions can drift between systems. Move what you need by hand after reviewing the archive. @@ -100,7 +99,7 @@ OpenClaw refuses to execute or trust this state automatically because the format openclaw migrate apply hermes --yes ``` - OpenClaw creates and verifies a backup before applying. If you need API keys imported, add `--include-secrets`. + OpenClaw creates and verifies a backup before applying. This non-interactive example imports non-secret state. Run without `--yes` to answer the credential prompt, or add `--include-secrets` to include supported credentials in unattended runs. @@ -136,10 +135,12 @@ If a conflict surfaces mid-apply (for example, an unexpected race on a config fi ## Secrets -Secrets are never imported by default. +Interactive `openclaw migrate` asks whether to import detected auth credentials, with yes selected by default. -- Run `openclaw migrate apply hermes --yes` first to import non-secret state. -- If you also want supported `.env` keys copied across, rerun with `--include-secrets`. +- Accepting the prompt imports supported OAuth credentials from `auth.json` and supported `.env` keys. +- Use `--no-auth-credentials` or choose no at the prompt to import non-secret state only. +- Use `--include-secrets` when running unattended with `--yes`. +- Use onboarding `--import-secrets` when importing credentials from the onboarding wizard. - For SecretRef-managed credentials, configure the SecretRef source after the import completes. ## JSON output for automation @@ -164,7 +165,7 @@ With `--json` and no `--yes`, apply prints the plan and does not mutate state. T Onboarding imports require a fresh setup. Either reset state and re-onboard, or use `openclaw migrate apply hermes` directly, which supports `--overwrite` and explicit backup control. - `--include-secrets` is required, and only the keys listed above are recognized. Other variables in `.env` are ignored. + Interactive `openclaw migrate` runs import API keys only when you accept the credential prompt. Non-interactive `--yes` runs require `--include-secrets`; onboarding imports require `--import-secrets`. Only the keys listed above are recognized; other variables in `.env` are ignored. diff --git a/extensions/codex/src/migration/apply.ts b/extensions/codex/src/migration/apply.ts index 7b5e8cd9afa..a09f4e55f9d 100644 --- a/extensions/codex/src/migration/apply.ts +++ b/extensions/codex/src/migration/apply.ts @@ -43,6 +43,7 @@ import { clearSharedCodexAppServerClientIfCurrentAndWait, getSharedCodexAppServerClient, } from "../app-server/shared-client.js"; +import { applyCodexAuthItem, buildCodexAuthConfigPatchItems } from "./auth.js"; import { buildCodexMigrationPlan } from "./plan.js"; import { buildCodexPluginsConfigValue, @@ -115,6 +116,21 @@ export async function applyCodexMigrationPlan(params: { const plan = params.plan ?? (await buildCodexMigrationPlan(params.ctx)); const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "codex"); const items: MigrationItem[] = []; + const targets = resolveCodexMigrationTargets(params.ctx); + const codexHome = + typeof plan.metadata?.codexHome === "string" && plan.metadata.codexHome.trim() + ? plan.metadata.codexHome + : plan.source; + const authSource = { + root: plan.source, + confidence: "high" as const, + codexHome, + authPath: path.join(codexHome, "auth.json"), + modelsCachePath: path.join(codexHome, "models_cache.json"), + skills: [], + plugins: [], + archivePaths: [], + }; const runtime = withCachedMigrationConfigRuntime( params.ctx.runtime ?? params.runtime, params.ctx.config, @@ -127,6 +143,21 @@ export async function applyCodexMigrationPlan(params: { } if (item.id === CODEX_PLUGIN_CONFIG_ITEM_ID) { items.push(await applyCodexPluginConfigItem(applyCtx, item, items)); + } else if (item.kind === "auth") { + const authItem = await applyCodexAuthItem({ + ctx: applyCtx, + item, + source: authSource, + targets, + }); + items.push(authItem); + items.push( + ...(await buildCodexAuthConfigPatchItems({ + ctx: applyCtx, + item: authItem, + source: authSource, + })), + ); } else if (item.kind === "plugin" && item.action === "install") { items.push(await applyCodexPluginInstallItem(applyCtx, item)); } else if (item.kind === "manual") { diff --git a/extensions/codex/src/migration/auth.ts b/extensions/codex/src/migration/auth.ts new file mode 100644 index 00000000000..767376937f5 --- /dev/null +++ b/extensions/codex/src/migration/auth.ts @@ -0,0 +1,709 @@ +import { loadAuthProfileStoreWithoutExternalProfiles } from "openclaw/plugin-sdk/agent-runtime"; +import { + createMigrationItem, + markMigrationItemConflict, + markMigrationItemError, + markMigrationItemSkipped, +} from "openclaw/plugin-sdk/migration"; +import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; +import { + applyAuthProfileConfig, + applyProviderAuthConfigPatch, + buildApiKeyCredential, + buildOauthProviderAuthResult, + readCodexCliCredentialsCached, + updateAuthProfileStoreWithLock, + type AuthProfileStore, + type OAuthCredential, + type OpenClawConfig, + type ProviderAuthResult, +} from "openclaw/plugin-sdk/provider-auth"; +import { readJsonObject } from "./helpers.js"; +import type { CodexSource } from "./source.js"; +import type { resolveCodexMigrationTargets } from "./targets.js"; + +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +const OPENAI_PROVIDER_ID = "openai"; +const OPENAI_CODEX_DEFAULT_MODEL = "openai/gpt-5.5"; +const CODEX_IMPORT_DISPLAY_NAME = "Codex import"; +const CODEX_REASON_AUTH_NOT_SELECTED = "auth credential migration not selected"; +const CODEX_REASON_AUTH_PROFILE_EXISTS = "auth profile exists"; +const CODEX_REASON_AUTH_PROFILE_WRITE_FAILED = "failed to write auth profile"; +const CODEX_REASON_AUTH_NO_LONGER_PRESENT = "auth credential no longer present"; +const CODEX_REASON_MISSING_AUTH_METADATA = "missing auth metadata"; +const CODEX_CONFIG_PATCH_MODE_RETURN = "return"; + +type CodexMigrationTargets = ReturnType; + +type CodexAuthCredential = + | { + kind: "oauth"; + provider: typeof OPENAI_CODEX_PROVIDER_ID; + profileId: string; + result: ProviderAuthResult; + } + | { + kind: "api_key"; + provider: typeof OPENAI_PROVIDER_ID; + profileId: string; + key: string; + }; + +type CodexAuthProfileConfig = { + profileId: string; + provider: string; + mode: "api_key" | "oauth"; + email?: string; + displayName?: string; +}; + +type CodexAuthConfigApplyResult = "configured" | "conflict" | "unavailable"; + +class CodexAuthConfigConflict extends Error {} + +function isRecord(value: unknown): value is Record { + 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 decodeJwtPayload(token: string): Record | undefined { + const payload = token.split(".")[1]; + if (!payload) { + return undefined; + } + try { + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function resolveCodexIdentity( + access: string, + accountId?: string, +): { + accountId?: string; + chatgptPlanType?: string; + email?: string; + profileName?: string; +} { + const payload = decodeJwtPayload(access); + const auth = isRecord(payload?.["https://api.openai.com/auth"]) + ? payload["https://api.openai.com/auth"] + : {}; + const profile = isRecord(payload?.["https://api.openai.com/profile"]) + ? payload["https://api.openai.com/profile"] + : {}; + const email = readString(profile.email); + const resolvedAccountId = accountId ?? readString(auth.chatgpt_account_id); + const chatgptPlanType = readString(auth.chatgpt_plan_type); + if (email) { + return { + ...(resolvedAccountId ? { accountId: resolvedAccountId } : {}), + ...(chatgptPlanType ? { chatgptPlanType } : {}), + email, + profileName: email, + }; + } + const stableSubject = + readString(auth.chatgpt_account_user_id) ?? + readString(auth.chatgpt_user_id) ?? + readString(auth.user_id) ?? + readString(payload?.sub) ?? + resolvedAccountId; + return { + ...(resolvedAccountId ? { accountId: resolvedAccountId } : {}), + ...(chatgptPlanType ? { chatgptPlanType } : {}), + ...(stableSubject + ? { profileName: `id-${Buffer.from(stableSubject).toString("base64url")}` } + : {}), + }; +} + +function credentialExtra(identity: { + accountId?: string; + chatgptPlanType?: string; + idToken?: string; +}): Record | undefined { + const extra = { + ...(identity.accountId ? { accountId: identity.accountId } : {}), + ...(identity.chatgptPlanType ? { chatgptPlanType: identity.chatgptPlanType } : {}), + ...(identity.idToken ? { idToken: identity.idToken } : {}), + }; + return Object.keys(extra).length > 0 ? extra : undefined; +} + +function importProfileName( + identity: { accountId?: string; profileName?: string }, + fallback: string, +): string { + if (identity.accountId) { + return `account-${identity.accountId.replaceAll(/[^A-Za-z0-9._-]+/gu, "-")}`; + } + if (identity.profileName?.startsWith("id-")) { + return identity.profileName; + } + return fallback; +} + +async function readModelRefs(source: CodexSource): Promise { + const cache = await readJsonObject(source.modelsCachePath); + const models = Array.isArray(cache.models) ? cache.models : []; + const refs = new Set(); + for (const model of models) { + const slug = + typeof model === "string" + ? model.trim() + : isRecord(model) + ? (readString(model.slug) ?? readString(model.id) ?? readString(model.name)) + : undefined; + if (!slug) { + continue; + } + refs.add(`${OPENAI_PROVIDER_ID}/${slug}`); + } + refs.add(OPENAI_CODEX_DEFAULT_MODEL); + return [...refs].toSorted(); +} + +async function buildCodexOAuthCredential(source: CodexSource): Promise { + const credential = readCodexCliCredentialsCached({ + codexHome: source.codexHome, + allowKeychainPrompt: false, + ttlMs: 0, + }); + if (!credential) { + return null; + } + const identity = resolveCodexIdentity(credential.access, credential.accountId); + const modelRefs = await readModelRefs(source); + const configPatch = { + agents: { + defaults: { + models: Object.fromEntries(modelRefs.map((modelRef) => [modelRef, {}])), + }, + }, + } satisfies Partial; + const result = buildOauthProviderAuthResult({ + providerId: OPENAI_CODEX_PROVIDER_ID, + defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + access: credential.access, + refresh: credential.refresh, + expires: credential.expires, + email: identity.email, + profileName: importProfileName(identity, "codex-import"), + displayName: CODEX_IMPORT_DISPLAY_NAME, + credentialExtra: credentialExtra({ + accountId: identity.accountId, + chatgptPlanType: identity.chatgptPlanType, + idToken: credential.idToken, + }), + configPatch, + }); + const profile = result.profiles[0]; + return profile + ? { kind: "oauth", provider: OPENAI_CODEX_PROVIDER_ID, profileId: profile.profileId, result } + : null; +} + +async function buildCodexApiKeyCredential( + source: CodexSource, +): Promise { + const raw = await readJsonObject(source.authPath); + const key = readString(raw.OPENAI_API_KEY); + if (!key) { + return null; + } + return { + kind: "api_key", + provider: OPENAI_PROVIDER_ID, + profileId: "openai:codex-import", + key, + }; +} + +async function readCodexAuthCredentials(source: CodexSource): Promise { + const oauth = await buildCodexOAuthCredential(source); + const apiKey = await buildCodexApiKeyCredential(source); + return [oauth, apiKey].filter((entry): entry is CodexAuthCredential => entry !== null); +} + +function findMatchingOAuthProfile( + store: AuthProfileStore, + credential: OAuthCredential, +): string | undefined { + for (const [profileId, existing] of Object.entries(store.profiles)) { + if (existing.type !== "oauth" || existing.provider !== credential.provider) { + continue; + } + if (credential.accountId && existing.accountId === credential.accountId) { + return profileId; + } + const canMatchByEmail = !credential.accountId || !existing.accountId; + if (canMatchByEmail && credential.email && existing.email === credential.email) { + return profileId; + } + } + return undefined; +} + +function findMatchingApiKeyProfile( + store: AuthProfileStore, + provider: string, + key: string, +): string | undefined { + for (const [profileId, existing] of Object.entries(store.profiles)) { + if (existing.type === "api_key" && existing.provider === provider && existing.key === key) { + return profileId; + } + } + return undefined; +} + +function itemProfileTarget( + credential: CodexAuthCredential, + store: AuthProfileStore, +): { profileId: string; matchedExisting: boolean } { + if (credential.kind === "oauth") { + const profile = credential.result.profiles[0]; + const matched = + profile?.credential.type === "oauth" + ? findMatchingOAuthProfile(store, profile.credential) + : undefined; + return { profileId: matched ?? credential.profileId, matchedExisting: Boolean(matched) }; + } + const matched = findMatchingApiKeyProfile(store, credential.provider, credential.key); + return { profileId: matched ?? credential.profileId, matchedExisting: Boolean(matched) }; +} + +function replaceConfigDraft(draft: OpenClawConfig, next: OpenClawConfig): void { + for (const key of Object.keys(draft) as Array) { + delete draft[key]; + } + Object.assign(draft, next); +} + +function existingAuthProfileConfigIsCompatible( + existing: NonNullable["profiles"]>[string], + profile: CodexAuthProfileConfig, +): boolean { + if (existing.provider !== profile.provider || existing.mode !== profile.mode) { + return false; + } + if (existing.email && profile.email && existing.email !== profile.email) { + return false; + } + return true; +} + +function hasAuthProfileConfigConflict( + config: OpenClawConfig, + profile: CodexAuthProfileConfig, + overwrite: boolean, +): boolean { + if (overwrite) { + return false; + } + const existing = config.auth?.profiles?.[profile.profileId]; + return Boolean(existing && !existingAuthProfileConfigIsCompatible(existing, profile)); +} + +function hasCurrentAuthProfileConfigConflict( + ctx: MigrationProviderContext, + profile: CodexAuthProfileConfig, +): boolean { + let config = ctx.config as OpenClawConfig; + try { + config = (ctx.runtime?.config?.current?.() as OpenClawConfig | undefined) ?? config; + } catch { + // Fall back to the planning snapshot; direct config writes recheck inside mutate. + } + return hasAuthProfileConfigConflict(config, profile, Boolean(ctx.overwrite)); +} + +function applyDefaultModelIfMissing(cfg: OpenClawConfig): OpenClawConfig { + const currentModel = cfg.agents?.defaults?.model; + const primary = + typeof currentModel === "string" + ? currentModel + : isRecord(currentModel) + ? readString(currentModel.primary) + : undefined; + if (primary) { + return cfg; + } + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...(isRecord(currentModel) ? currentModel : {}), + primary: OPENAI_CODEX_DEFAULT_MODEL, + }, + }, + }, + }; +} + +function applyOAuthConfigToConfig( + cfg: OpenClawConfig, + credential: Extract, + profileId: string, +): OpenClawConfig { + let next = cfg; + if (credential.result.configPatch) { + next = applyProviderAuthConfigPatch(next, credential.result.configPatch, { + replaceDefaultModels: credential.result.replaceDefaultModels, + }); + } + const profile = credential.result.profiles[0]; + if (profile) { + next = applyAuthProfileConfig(next, { + profileId, + provider: profile.credential.provider, + mode: "oauth", + ...("email" in profile.credential && profile.credential.email + ? { email: profile.credential.email } + : {}), + ...("displayName" in profile.credential && profile.credential.displayName + ? { displayName: profile.credential.displayName } + : {}), + preferProfileFirst: false, + }); + } + return applyDefaultModelIfMissing(next); +} + +function applyApiKeyConfigToConfig( + cfg: OpenClawConfig, + credential: Extract, + profileId: string, +): OpenClawConfig { + return applyAuthProfileConfig(cfg, { + profileId, + provider: credential.provider, + mode: "api_key", + displayName: CODEX_IMPORT_DISPLAY_NAME, + preferProfileFirst: false, + }); +} + +function shouldReturnAuthConfigPatch(ctx: MigrationProviderContext): boolean { + return ctx.providerOptions?.configPatchMode === CODEX_CONFIG_PATCH_MODE_RETURN; +} + +function oauthAuthProfileConfig( + credential: Extract, + profileId: string, +): CodexAuthProfileConfig | null { + const profile = credential.result.profiles[0]; + if (!profile || profile.credential.type !== "oauth") { + return null; + } + return { + profileId, + provider: profile.credential.provider, + mode: "oauth", + ...("email" in profile.credential && profile.credential.email + ? { email: profile.credential.email } + : {}), + ...("displayName" in profile.credential && profile.credential.displayName + ? { displayName: profile.credential.displayName } + : {}), + }; +} + +function apiKeyAuthProfileConfig( + credential: Extract, + profileId: string, +): CodexAuthProfileConfig { + return { + profileId, + provider: credential.provider, + mode: "api_key", + displayName: CODEX_IMPORT_DISPLAY_NAME, + }; +} + +function authProfileConfigForCredential( + credential: CodexAuthCredential, + profileId: string, +): CodexAuthProfileConfig | null { + return credential.kind === "oauth" + ? oauthAuthProfileConfig(credential, profileId) + : apiKeyAuthProfileConfig(credential, profileId); +} + +async function applyCodexAuthProfileConfig( + ctx: MigrationProviderContext, + profile: CodexAuthProfileConfig, + applyConfig: (config: OpenClawConfig) => OpenClawConfig, +): Promise { + const configApi = ctx.runtime?.config; + if (!configApi?.current || !configApi.mutateConfigFile) { + return "unavailable"; + } + try { + await configApi.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "auto" }, + mutate(draft) { + const current = draft as OpenClawConfig; + if (hasAuthProfileConfigConflict(current, profile, Boolean(ctx.overwrite))) { + throw new CodexAuthConfigConflict(); + } + const next = applyConfig(current); + replaceConfigDraft(draft as OpenClawConfig, next); + }, + }); + return "configured"; + } catch (error) { + return error instanceof CodexAuthConfigConflict ? "conflict" : "unavailable"; + } +} + +async function applyOAuthConfig( + ctx: MigrationProviderContext, + credential: Extract, + profileId: string, +): Promise { + const profile = oauthAuthProfileConfig(credential, profileId); + if (!profile) { + return "unavailable"; + } + return applyCodexAuthProfileConfig(ctx, profile, (config) => + applyOAuthConfigToConfig(config, credential, profileId), + ); +} + +async function applyApiKeyConfig( + ctx: MigrationProviderContext, + credential: Extract, + profileId: string, +): Promise { + return applyCodexAuthProfileConfig( + ctx, + apiKeyAuthProfileConfig(credential, profileId), + (config) => applyApiKeyConfigToConfig(config, credential, profileId), + ); +} + +export async function buildCodexAuthItems(params: { + ctx: MigrationProviderContext; + source: CodexSource; + targets: CodexMigrationTargets; +}): Promise { + const credentials = await readCodexAuthCredentials(params.source); + if (credentials.length === 0) { + return []; + } + const store = loadAuthProfileStoreWithoutExternalProfiles(params.targets.agentDir); + const skipped = !params.ctx.includeSecrets; + return credentials.map((credential) => { + const { profileId, matchedExisting } = itemProfileTarget(credential, store); + const targetExists = Boolean(store.profiles[profileId]); + const configProfile = authProfileConfigForCredential(credential, profileId); + const configConflict = configProfile + ? hasAuthProfileConfigConflict( + params.ctx.config as OpenClawConfig, + configProfile, + Boolean(params.ctx.overwrite), + ) + : false; + const conflict = Boolean( + ((targetExists && !matchedExisting && !params.ctx.overwrite) || configConflict) && !skipped, + ); + return createMigrationItem({ + id: `auth:${credential.provider}`, + kind: "auth", + action: skipped ? "skip" : "create", + source: params.source.authPath, + target: `${params.targets.agentDir}/auth-profiles.json#${profileId}`, + status: skipped ? "skipped" : conflict ? "conflict" : "planned", + sensitive: true, + reason: skipped + ? CODEX_REASON_AUTH_NOT_SELECTED + : conflict + ? CODEX_REASON_AUTH_PROFILE_EXISTS + : undefined, + message: + credential.kind === "oauth" + ? "Import Codex OAuth credentials and configure OpenAI Codex models." + : "Import Codex OpenAI API key.", + details: { + provider: credential.provider, + profileId, + sourceProfileId: credential.profileId, + sourceKind: "codex-auth-json", + credentialKind: credential.kind, + }, + }); + }); +} + +export async function applyCodexAuthItem(params: { + ctx: MigrationProviderContext; + item: MigrationItem; + source: CodexSource; + targets: CodexMigrationTargets; +}): Promise { + const { ctx, item, source, targets } = params; + if (item.status !== "planned") { + return item; + } + const profileId = typeof item.details?.profileId === "string" ? item.details.profileId : ""; + const provider = typeof item.details?.provider === "string" ? item.details.provider : ""; + const sourceProfileId = + typeof item.details?.sourceProfileId === "string" ? item.details.sourceProfileId : undefined; + if (!profileId || !provider) { + return markMigrationItemError(item, CODEX_REASON_MISSING_AUTH_METADATA); + } + const credential = (await readCodexAuthCredentials(source)).find( + (candidate) => candidate.provider === provider, + ); + if (!credential) { + return markMigrationItemSkipped(item, CODEX_REASON_AUTH_NO_LONGER_PRESENT); + } + if (credential.kind === "oauth" && sourceProfileId && credential.profileId !== sourceProfileId) { + return markMigrationItemSkipped(item, CODEX_REASON_AUTH_NO_LONGER_PRESENT); + } + const oauthProfile = credential.kind === "oauth" ? credential.result.profiles[0] : undefined; + const oauthCredential = + oauthProfile?.credential.type === "oauth" ? oauthProfile.credential : undefined; + if (credential.kind === "oauth" && !oauthCredential) { + return markMigrationItemError(item, CODEX_REASON_MISSING_AUTH_METADATA); + } + const configProfile = authProfileConfigForCredential(credential, profileId); + if (!configProfile) { + return markMigrationItemError(item, CODEX_REASON_MISSING_AUTH_METADATA); + } + if (hasCurrentAuthProfileConfigConflict(ctx, configProfile)) { + return markMigrationItemConflict(item, CODEX_REASON_AUTH_PROFILE_EXISTS); + } + let conflicted = false; + let wrote = false; + const store = await updateAuthProfileStoreWithLock({ + agentDir: targets.agentDir, + updater: (freshStore) => { + const existing = freshStore.profiles[profileId]; + if (!ctx.overwrite && existing) { + const matchedProfileId = + credential.kind === "oauth" + ? findMatchingOAuthProfile(freshStore, oauthCredential!) + : findMatchingApiKeyProfile(freshStore, credential.provider, credential.key); + if (matchedProfileId === profileId) { + return false; + } + conflicted = true; + return false; + } + freshStore.profiles[profileId] = + credential.kind === "oauth" + ? { + ...oauthCredential!, + displayName: CODEX_IMPORT_DISPLAY_NAME, + } + : { + ...buildApiKeyCredential(credential.provider, credential.key), + displayName: CODEX_IMPORT_DISPLAY_NAME, + }; + wrote = true; + return true; + }, + }); + if (conflicted) { + return markMigrationItemConflict(item, CODEX_REASON_AUTH_PROFILE_EXISTS); + } + if (!store?.profiles[profileId]) { + return markMigrationItemError(item, CODEX_REASON_AUTH_PROFILE_WRITE_FAILED); + } + const configResult = shouldReturnAuthConfigPatch(ctx) + ? "unavailable" + : credential.kind === "oauth" + ? await applyOAuthConfig(ctx, credential, profileId) + : await applyApiKeyConfig(ctx, credential, profileId); + if (configResult === "conflict") { + return markMigrationItemConflict(item, CODEX_REASON_AUTH_PROFILE_EXISTS); + } + return { + ...item, + status: "migrated", + details: { + ...item.details, + wroteAuthProfile: wrote, + configUpdated: configResult === "configured", + ...(shouldReturnAuthConfigPatch(ctx) ? { configPatchReturned: true } : {}), + }, + }; +} + +export async function buildCodexAuthConfigPatchItems(params: { + ctx: MigrationProviderContext; + item: MigrationItem; + source: CodexSource; +}): Promise { + const { ctx, item, source } = params; + if (item.status !== "migrated" || !shouldReturnAuthConfigPatch(ctx)) { + return []; + } + const profileId = typeof item.details?.profileId === "string" ? item.details.profileId : ""; + const provider = typeof item.details?.provider === "string" ? item.details.provider : ""; + const sourceProfileId = + typeof item.details?.sourceProfileId === "string" ? item.details.sourceProfileId : undefined; + if (!profileId || !provider) { + return []; + } + const credential = (await readCodexAuthCredentials(source)).find( + (candidate) => candidate.provider === provider, + ); + if (!credential) { + return []; + } + if (credential.kind === "oauth" && sourceProfileId && credential.profileId !== sourceProfileId) { + return []; + } + const next = + credential.kind === "oauth" + ? applyOAuthConfigToConfig(ctx.config as OpenClawConfig, credential, profileId) + : applyApiKeyConfigToConfig(ctx.config as OpenClawConfig, credential, profileId); + const items: MigrationItem[] = []; + if (next.auth) { + items.push( + createMigrationItem({ + id: `${item.id}:config:auth`, + kind: "config", + action: "merge", + status: "migrated", + target: "auth", + message: "Configure imported Codex auth profile.", + details: { + path: ["auth"], + value: next.auth, + }, + }), + ); + } + if (next.agents?.defaults) { + items.push( + createMigrationItem({ + id: `${item.id}:config:agents-defaults`, + kind: "config", + action: "merge", + status: "migrated", + target: "agents.defaults", + message: "Configure imported Codex models.", + details: { + path: ["agents", "defaults"], + value: next.agents.defaults, + }, + }), + ); + } + return items; +} diff --git a/extensions/codex/src/migration/plan.ts b/extensions/codex/src/migration/plan.ts index 7fe4034818a..f3d7e27e284 100644 --- a/extensions/codex/src/migration/plan.ts +++ b/extensions/codex/src/migration/plan.ts @@ -13,6 +13,7 @@ import type { MigrationProviderContext, } from "openclaw/plugin-sdk/plugin-entry"; import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js"; +import { buildCodexAuthItems } from "./auth.js"; import { exists, sanitizeName } from "./helpers.js"; import { codexPluginMigrationSubscriptionWarning, @@ -396,6 +397,7 @@ export async function buildCodexMigrationPlan( ); } const items: MigrationItem[] = []; + items.push(...(await buildCodexAuthItems({ ctx, source, targets }))); items.push( ...(await buildSkillItems({ skills: source.skills, @@ -424,6 +426,11 @@ export async function buildCodexMigrationPlan( ); } const warnings = [ + ...(!ctx.includeSecrets && items.some((item) => item.kind === "auth") + ? [ + "Auth credentials were detected but skipped. Re-run interactively or pass --include-secrets to import supported credentials.", + ] + : []), ...(items.some((item) => item.status === "conflict") ? [ "Conflicts were found. Re-run with --overwrite to replace conflicting migration targets after item-level backups.", diff --git a/extensions/codex/src/migration/provider.test.ts b/extensions/codex/src/migration/provider.test.ts index 1eb52d795fe..c1dd725ae92 100644 --- a/extensions/codex/src/migration/provider.test.ts +++ b/extensions/codex/src/migration/provider.test.ts @@ -40,6 +40,7 @@ function makeContext(params: { stateDir: string; workspaceDir: string; overwrite?: boolean; + includeSecrets?: boolean; verifyPluginApps?: boolean; providerOptions?: MigrationProviderContext["providerOptions"]; reportDir?: string; @@ -59,6 +60,7 @@ function makeContext(params: { runtime: params.runtime, source: params.source, stateDir: params.stateDir, + includeSecrets: params.includeSecrets, overwrite: params.overwrite, providerOptions: params.providerOptions ?? (params.verifyPluginApps ? { verifyPluginApps: true } : undefined), @@ -94,6 +96,12 @@ function expectRecordFields(record: unknown, expected: Record) return actual; } +function fakeJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + function mockCallArg(mock: ReturnType, callIndex = 0, argIndex = 0) { const call = mock.mock.calls[callIndex]; if (!call) { @@ -273,6 +281,546 @@ describe("buildCodexMigrationProvider", () => { }); }); + it("imports Codex auth.json OAuth and seeds cached OpenAI Codex models", async () => { + const fixture = await createCodexFixture(); + const reportDir = path.join(fixture.root, "report"); + const configState: MigrationProviderContext["config"] = { + agents: { + defaults: { + model: { fallbacks: [] }, + workspace: fixture.workspaceDir, + }, + }, + } as MigrationProviderContext["config"]; + const accessToken = fakeJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { email: "codex@example.test" }, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_test", + chatgpt_plan_type: "plus", + }, + }); + await writeFile( + path.join(fixture.codexHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: "refresh-test-token", + id_token: "id-test-token", + account_id: "acct_test", + }, + }), + ); + await writeFile( + path.join(fixture.codexHome, "models_cache.json"), + JSON.stringify({ models: [{ slug: "gpt-5.5" }, { slug: "gpt-5.4-mini" }] }), + ); + const provider = buildCodexMigrationProvider(); + + const skippedPlan = await provider.plan( + makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + }), + ); + expectRecordFields(findItem(skippedPlan.items, "auth:openai-codex"), { + kind: "auth", + status: "skipped", + sensitive: true, + }); + + const ctx = makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + runtime: createConfigRuntime(configState), + reportDir, + includeSecrets: true, + }); + const plan = await provider.plan(ctx); + expectRecordFields(findItem(plan.items, "auth:openai-codex"), { + kind: "auth", + status: "planned", + sensitive: true, + }); + + const result = await provider.apply(ctx, plan); + + expectRecordFields(findItem(result.items, "auth:openai-codex"), { status: "migrated" }); + const authStore = JSON.parse( + await fs.readFile( + path.join(fixture.stateDir, "agents", "main", "agent", "auth-profiles.json"), + "utf8", + ), + ) as { + profiles?: Record< + string, + { access?: string; provider?: string; refresh?: string; type?: string } + >; + }; + expect(authStore.profiles?.["openai-codex:account-acct_test"]).toEqual( + expect.objectContaining({ + type: "oauth", + provider: "openai-codex", + access: accessToken, + refresh: "refresh-test-token", + }), + ); + expect(configState.auth?.profiles?.["openai-codex:account-acct_test"]).toEqual( + expect.objectContaining({ + provider: "openai-codex", + mode: "oauth", + }), + ); + expect(configState.agents?.defaults?.models?.["openai/gpt-5.4-mini"]).toEqual({}); + expect(configState.agents?.defaults?.model).toEqual({ + fallbacks: [], + primary: "openai/gpt-5.5", + }); + }); + + it("reports Codex OAuth config auth profile conflicts during planning", async () => { + const fixture = await createCodexFixture(); + const accessToken = fakeJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_conflict", + chatgpt_plan_type: "plus", + }, + "https://api.openai.com/profile": { + email: "codex@example.test", + }, + }); + await writeFile( + path.join(fixture.codexHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: "refresh-conflict-token", + account_id: "acct_conflict", + }, + }), + ); + const configState: MigrationProviderContext["config"] = { + agents: { + defaults: { + workspace: fixture.workspaceDir, + }, + }, + auth: { + profiles: { + "openai-codex:account-acct_conflict": { + provider: "openai-codex", + mode: "api_key", + }, + }, + }, + }; + const provider = buildCodexMigrationProvider(); + + const plan = await provider.plan( + makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + includeSecrets: true, + }), + ); + + expect(findItem(plan.items, "auth:openai-codex")).toEqual( + expect.objectContaining({ + status: "conflict", + reason: "auth profile exists", + details: expect.objectContaining({ + profileId: "openai-codex:account-acct_conflict", + }), + }), + ); + }); + + it("reports late-created Codex API key config auth profile conflicts before writing", async () => { + const fixture = await createCodexFixture(); + const reportDir = path.join(fixture.root, "report"); + await writeFile( + path.join(fixture.codexHome, "auth.json"), + JSON.stringify({ OPENAI_API_KEY: "sk-codex" }), + ); + const configState: MigrationProviderContext["config"] = { + agents: { + defaults: { + workspace: fixture.workspaceDir, + }, + }, + }; + const provider = buildCodexMigrationProvider(); + const ctx = makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + runtime: createConfigRuntime(configState), + reportDir, + includeSecrets: true, + }); + const plan = await provider.plan(ctx); + configState.auth = { + profiles: { + "openai:codex-import": { + provider: "anthropic", + mode: "api_key", + }, + }, + }; + + const result = await provider.apply(ctx, plan); + + expect(findItem(result.items, "auth:openai")).toEqual( + expect.objectContaining({ + status: "conflict", + reason: "auth profile exists", + }), + ); + await expect( + fs.access(path.join(fixture.stateDir, "agents", "main", "agent", "auth-profiles.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("skips Codex OAuth import when the source account changes after planning", async () => { + const fixture = await createCodexFixture(); + const reportDir = path.join(fixture.root, "report"); + const plannedAccessToken = fakeJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_planned", + }, + "https://api.openai.com/profile": { + email: "planned@example.test", + }, + }); + const changedAccessToken = fakeJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_changed", + }, + "https://api.openai.com/profile": { + email: "changed@example.test", + }, + }); + await writeFile( + path.join(fixture.codexHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: plannedAccessToken, + refresh_token: "refresh-planned-token", + account_id: "acct_planned", + }, + }), + ); + const configState: MigrationProviderContext["config"] = { + agents: { + defaults: { + workspace: fixture.workspaceDir, + }, + }, + }; + const provider = buildCodexMigrationProvider(); + const ctx = makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + runtime: createConfigRuntime(configState), + reportDir, + includeSecrets: true, + }); + const plan = await provider.plan(ctx); + expect(findItem(plan.items, "auth:openai-codex").details).toEqual( + expect.objectContaining({ + profileId: "openai-codex:account-acct_planned", + sourceProfileId: "openai-codex:account-acct_planned", + }), + ); + await writeFile( + path.join(fixture.codexHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: changedAccessToken, + refresh_token: "refresh-changed-token", + account_id: "acct_changed", + }, + }), + ); + + const result = await provider.apply(ctx, plan); + + expect(findItem(result.items, "auth:openai-codex")).toEqual( + expect.objectContaining({ + status: "skipped", + reason: "auth credential no longer present", + }), + ); + await expect( + fs.access(path.join(fixture.stateDir, "agents", "main", "agent", "auth-profiles.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(configState.auth).toBeUndefined(); + }); + + it("does not collapse Codex OAuth accounts that share an email", async () => { + const fixture = await createCodexFixture(); + const reportDir = path.join(fixture.root, "report"); + const sharedEmail = "shared@example.com"; + const accessToken = fakeJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_new", + chatgpt_plan_type: "plus", + }, + "https://api.openai.com/profile": { + email: sharedEmail, + }, + }); + await writeFile( + path.join(fixture.codexHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: "refresh-new-token", + account_id: "acct_new", + }, + }), + ); + await writeFile( + path.join(fixture.stateDir, "agents", "main", "agent", "auth-profiles.json"), + JSON.stringify({ + profiles: { + "openai-codex:account-acct_old": { + type: "oauth", + provider: "openai-codex", + access: "old-access-token", + refresh: "old-refresh-token", + accountId: "acct_old", + email: sharedEmail, + }, + }, + }), + ); + const configState: MigrationProviderContext["config"] = { + agents: { + defaults: { + workspace: fixture.workspaceDir, + }, + }, + }; + const provider = buildCodexMigrationProvider(); + const ctx = makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + runtime: createConfigRuntime(configState), + reportDir, + includeSecrets: true, + }); + + const plan = await provider.plan(ctx); + expectRecordFields(findItem(plan.items, "auth:openai-codex"), { + status: "planned", + }); + expect(findItem(plan.items, "auth:openai-codex").details).toEqual( + expect.objectContaining({ + profileId: "openai-codex:account-acct_new", + }), + ); + + const result = await provider.apply(ctx, plan); + + expectRecordFields(findItem(result.items, "auth:openai-codex"), { status: "migrated" }); + const authStore = JSON.parse( + await fs.readFile( + path.join(fixture.stateDir, "agents", "main", "agent", "auth-profiles.json"), + "utf8", + ), + ) as { + profiles?: Record; + }; + expect(authStore.profiles?.["openai-codex:account-acct_old"]).toEqual( + expect.objectContaining({ + access: "old-access-token", + accountId: "acct_old", + email: sharedEmail, + }), + ); + expect(authStore.profiles?.["openai-codex:account-acct_new"]).toEqual( + expect.objectContaining({ + access: accessToken, + accountId: "acct_new", + email: sharedEmail, + }), + ); + }); + + it("reports Codex auth import when config update fails after profile write", async () => { + const fixture = await createCodexFixture(); + const reportDir = path.join(fixture.root, "report"); + const accessToken = fakeJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_test", + }, + "https://api.openai.com/profile": { + email: "codex@example.test", + }, + }); + await writeFile( + path.join(fixture.codexHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: "refresh-test-token", + account_id: "acct_test", + }, + }), + ); + const configState: MigrationProviderContext["config"] = { + agents: { + defaults: { + workspace: fixture.workspaceDir, + }, + }, + }; + const provider = buildCodexMigrationProvider(); + const ctx = makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + runtime: createFailingConfigRuntime(configState), + reportDir, + includeSecrets: true, + }); + const plan = await provider.plan(ctx); + + const result = await provider.apply(ctx, plan); + + expectRecordFields(findItem(result.items, "auth:openai-codex"), { status: "migrated" }); + expect(findItem(result.items, "auth:openai-codex").details).toEqual( + expect.objectContaining({ + configUpdated: false, + }), + ); + const authStore = JSON.parse( + await fs.readFile( + path.join(fixture.stateDir, "agents", "main", "agent", "auth-profiles.json"), + "utf8", + ), + ) as { + profiles?: Record; + }; + expect(authStore.profiles?.["openai-codex:account-acct_test"]).toEqual( + expect.objectContaining({ + type: "oauth", + provider: "openai-codex", + access: accessToken, + }), + ); + }); + + it("returns Codex auth config patches without direct config writes in return mode", async () => { + const fixture = await createCodexFixture(); + const reportDir = path.join(fixture.root, "report"); + const accessToken = fakeJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_test", + }, + "https://api.openai.com/profile": { + email: "codex@example.test", + }, + }); + await writeFile( + path.join(fixture.codexHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: "refresh-test-token", + account_id: "acct_test", + }, + }), + ); + await writeFile( + path.join(fixture.codexHome, "models_cache.json"), + JSON.stringify({ models: [{ slug: "gpt-5.5" }, { slug: "gpt-5.4-mini" }] }), + ); + const configState: MigrationProviderContext["config"] = { + agents: { + defaults: { + workspace: fixture.workspaceDir, + }, + }, + }; + const provider = buildCodexMigrationProvider(); + const ctx = makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + runtime: createFailingConfigRuntime(configState), + reportDir, + includeSecrets: true, + providerOptions: { configPatchMode: "return" }, + }); + const plan = await provider.plan(ctx); + + const result = await provider.apply(ctx, plan); + + expect(findItem(result.items, "auth:openai-codex").details).toEqual( + expect.objectContaining({ + configUpdated: false, + configPatchReturned: true, + }), + ); + expect(findItem(result.items, "auth:openai-codex:config:auth")).toEqual( + expect.objectContaining({ + kind: "config", + action: "merge", + status: "migrated", + details: expect.objectContaining({ + path: ["auth"], + value: expect.objectContaining({ + profiles: expect.objectContaining({ + "openai-codex:account-acct_test": expect.objectContaining({ + provider: "openai-codex", + mode: "oauth", + }), + }), + }), + }), + }), + ); + expect(findItem(result.items, "auth:openai-codex:config:agents-defaults")).toEqual( + expect.objectContaining({ + kind: "config", + action: "merge", + status: "migrated", + details: expect.objectContaining({ + path: ["agents", "defaults"], + value: expect.objectContaining({ + model: { primary: "openai/gpt-5.5" }, + models: expect.objectContaining({ + "openai/gpt-5.4-mini": {}, + }), + }), + }), + }), + ); + expect(configState.auth).toBeUndefined(); + expect(configState.agents?.defaults?.model).toBeUndefined(); + }); + it("skips source-installed plugins whose owned apps are inaccessible", async () => { const fixture = await createCodexFixture(); appServerRequest.mockImplementation( @@ -1684,6 +2232,21 @@ function pluginRead(pluginName: string, apps: v2.AppSummary[] = []): v2.PluginRe }; } +function createFailingConfigRuntime( + configState: MigrationProviderContext["config"], +): MigrationProviderContext["runtime"] { + type Runtime = NonNullable; + type MutateConfigFileParams = Parameters[0]; + return { + config: { + current: () => configState, + mutateConfigFile: async (_params: MutateConfigFileParams): Promise => { + throw new Error("config write failed"); + }, + }, + } as unknown as MigrationProviderContext["runtime"]; +} + function pluginApp(id: string, overrides: Partial = {}): v2.AppSummary { return { id, diff --git a/extensions/codex/src/migration/source.ts b/extensions/codex/src/migration/source.ts index 30dc9fede7d..25dca10f995 100644 --- a/extensions/codex/src/migration/source.ts +++ b/extensions/codex/src/migration/source.ts @@ -78,13 +78,15 @@ type CodexArchiveSource = { message?: string; }; -type CodexSource = { +export type CodexSource = { root: string; confidence: "low" | "medium" | "high"; codexHome: string; codexSkillsDir?: string; personalAgentsSkillsDir?: string; configPath?: string; + authPath?: string; + modelsCachePath?: string; hooksPath?: string; skills: CodexSkillSource[]; plugins: CodexPluginSource[]; @@ -573,6 +575,8 @@ export async function discoverCodexSource( const codexSkillsDir = path.join(codexHome, "skills"); const agentsSkillsDir = personalAgentsSkillsDir(); const configPath = path.join(codexHome, "config.toml"); + const authPath = path.join(codexHome, "auth.json"); + const modelsCachePath = path.join(codexHome, "models_cache.json"); const hooksPath = path.join(codexHome, "hooks", "hooks.json"); const codexSkills = await discoverSkillDirs({ root: codexSkillsDir, @@ -617,7 +621,8 @@ export async function discoverCodexSource( const skills = [...codexSkills, ...personalAgentSkills].toSorted((a, b) => a.source.localeCompare(b.source), ); - const high = Boolean(codexSkills.length || plugins.length || archivePaths.length); + const hasAuth = await exists(authPath); + const high = Boolean(codexSkills.length || plugins.length || archivePaths.length || hasAuth); const medium = personalAgentSkills.length > 0; return { root: codexHome, @@ -626,6 +631,8 @@ export async function discoverCodexSource( ...((await isDirectory(codexSkillsDir)) ? { codexSkillsDir } : {}), ...((await isDirectory(agentsSkillsDir)) ? { personalAgentsSkillsDir: agentsSkillsDir } : {}), ...((await exists(configPath)) ? { configPath } : {}), + ...(hasAuth ? { authPath } : {}), + ...((await exists(modelsCachePath)) ? { modelsCachePath } : {}), ...((await exists(hooksPath)) ? { hooksPath } : {}), skills, plugins, diff --git a/extensions/migrate-hermes/apply.ts b/extensions/migrate-hermes/apply.ts index 8b8ecf75494..358f7cc508a 100644 --- a/extensions/migrate-hermes/apply.ts +++ b/extensions/migrate-hermes/apply.ts @@ -12,6 +12,7 @@ import type { MigrationPlan, MigrationProviderContext, } from "openclaw/plugin-sdk/plugin-entry"; +import { applyAuthItem } from "./auth.js"; import { applyConfigItem, applyManualItem } from "./config.js"; import { appendItem } from "./helpers.js"; import { applyModelItem } from "./model.js"; @@ -54,8 +55,10 @@ export async function applyHermesPlan(params: { appliedItem = applyManualItem(item); } else if (item.action === "archive") { appliedItem = await archiveMigrationItem(item, reportDir); + } else if (item.kind === "auth") { + appliedItem = await applyAuthItem(applyCtx, item, targets); } else if (item.kind === "secret") { - appliedItem = await applySecretItem(params.ctx, item, targets); + appliedItem = await applySecretItem(applyCtx, item, targets); } else if (item.action === "append") { appliedItem = await appendItem(item); } else { diff --git a/extensions/migrate-hermes/auth-config.ts b/extensions/migrate-hermes/auth-config.ts new file mode 100644 index 00000000000..094b78c01ee --- /dev/null +++ b/extensions/migrate-hermes/auth-config.ts @@ -0,0 +1,97 @@ +import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; +import { applyAuthProfileConfig, type OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; + +export type HermesAuthProfileConfig = { + profileId: string; + provider: string; + mode: "api_key" | "oauth"; + email?: string; + displayName?: string; +}; + +export type HermesAuthConfigApplyResult = "configured" | "conflict" | "unavailable"; + +class HermesAuthConfigConflict extends Error {} + +function existingProfileIsCompatible( + existing: NonNullable["profiles"]>[string], + profile: HermesAuthProfileConfig, +): boolean { + if (existing.provider !== profile.provider || existing.mode !== profile.mode) { + return false; + } + if (existing.email && profile.email && existing.email !== profile.email) { + return false; + } + return true; +} + +export function hasAuthProfileConfigConflict( + config: OpenClawConfig, + profile: HermesAuthProfileConfig, + overwrite: boolean, +): boolean { + if (overwrite) { + return false; + } + const existing = config.auth?.profiles?.[profile.profileId]; + return Boolean(existing && !existingProfileIsCompatible(existing, profile)); +} + +function replaceConfigDraft(draft: OpenClawConfig, next: OpenClawConfig): void { + for (const key of Object.keys(draft) as Array) { + delete draft[key]; + } + Object.assign(draft, next); +} + +export function hasCurrentAuthProfileConfigConflict( + ctx: MigrationProviderContext, + profile: HermesAuthProfileConfig, +): boolean { + let config = ctx.config as OpenClawConfig; + try { + config = (ctx.runtime?.config?.current?.() as OpenClawConfig | undefined) ?? config; + } catch { + // Fall back to the planning snapshot; apply still rechecks inside mutate. + } + return hasAuthProfileConfigConflict(config, profile, Boolean(ctx.overwrite)); +} + +export async function applyAuthProfileConfigWithConflictCheck(params: { + ctx: MigrationProviderContext; + profile: HermesAuthProfileConfig; + applyConfigPatch?: (config: OpenClawConfig) => OpenClawConfig; +}): Promise { + const configApi = params.ctx.runtime?.config; + if (!configApi?.current || !configApi.mutateConfigFile) { + return "unavailable"; + } + try { + await configApi.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "auto" }, + mutate(draft) { + let next = draft as OpenClawConfig; + if (params.applyConfigPatch) { + next = params.applyConfigPatch(next); + } + if (hasAuthProfileConfigConflict(next, params.profile, Boolean(params.ctx.overwrite))) { + throw new HermesAuthConfigConflict(); + } + next = applyAuthProfileConfig(next, { + profileId: params.profile.profileId, + provider: params.profile.provider, + mode: params.profile.mode, + ...(params.profile.email ? { email: params.profile.email } : {}), + ...(params.profile.displayName ? { displayName: params.profile.displayName } : {}), + preferProfileFirst: false, + }); + replaceConfigDraft(draft as OpenClawConfig, next); + }, + }); + return "configured"; + } catch (error) { + return error instanceof HermesAuthConfigConflict ? "conflict" : "unavailable"; + } +} diff --git a/extensions/migrate-hermes/auth.ts b/extensions/migrate-hermes/auth.ts new file mode 100644 index 00000000000..3954ff7907d --- /dev/null +++ b/extensions/migrate-hermes/auth.ts @@ -0,0 +1,447 @@ +import { loadAuthProfileStoreWithoutExternalProfiles } from "openclaw/plugin-sdk/agent-runtime"; +import { + createMigrationItem, + markMigrationItemConflict, + markMigrationItemError, + markMigrationItemSkipped, +} from "openclaw/plugin-sdk/migration"; +import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; +import { + applyProviderAuthConfigPatch, + buildOauthProviderAuthResult, + updateAuthProfileStoreWithLock, + type AuthProfileStore, + type OAuthCredential, + type ProviderAuthResult, +} from "openclaw/plugin-sdk/provider-auth"; +import { + applyAuthProfileConfigWithConflictCheck, + hasAuthProfileConfigConflict, + hasCurrentAuthProfileConfigConflict, + type HermesAuthProfileConfig, +} from "./auth-config.js"; +import { readText } from "./helpers.js"; +import { + HERMES_REASON_AUTH_PROFILE_EXISTS, + HERMES_REASON_AUTH_PROFILE_WRITE_FAILED, + HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE, + HERMES_REASON_INCLUDE_SECRETS, + HERMES_REASON_MISSING_SECRET_METADATA, + HERMES_REASON_SECRET_NO_LONGER_PRESENT, +} from "./items.js"; +import type { HermesSource } from "./source.js"; +import type { PlannedTargets } from "./targets.js"; + +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +const OPENAI_CODEX_DEFAULT_MODEL = "openai/gpt-5.5"; +const HERMES_AUTH_DISPLAY_NAME = "Hermes import"; + +type HermesCodexAuthCandidate = { + access: string; + refresh: string; + sourceLabel: string; + updatedAt?: number; +}; + +type HermesCodexAuthProfile = { + candidate: HermesCodexAuthCandidate; + credential: OAuthCredential; + result: ProviderAuthResult; + sourceProfileId: string; +}; + +type CodexIdentity = { + accountId?: string; + chatgptPlanType?: string; + email?: string; + profileName?: string; +}; + +function isRecord(value: unknown): value is Record { + 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 readTimestamp(value: unknown): number | undefined { + if (typeof value !== "string" || !value.trim()) { + return undefined; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function decodeJwtPayload(token: string): Record | undefined { + const payload = token.split(".")[1]; + if (!payload) { + return undefined; + } + try { + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function resolveCodexIdentity(access: string): CodexIdentity { + const payload = decodeJwtPayload(access); + const auth = isRecord(payload?.["https://api.openai.com/auth"]) + ? payload["https://api.openai.com/auth"] + : {}; + const profile = isRecord(payload?.["https://api.openai.com/profile"]) + ? payload["https://api.openai.com/profile"] + : {}; + const email = readString(profile.email); + const accountId = readString(auth.chatgpt_account_id); + const chatgptPlanType = readString(auth.chatgpt_plan_type); + if (email) { + return { + ...(accountId ? { accountId } : {}), + ...(chatgptPlanType ? { chatgptPlanType } : {}), + email, + profileName: email, + }; + } + const stableSubject = + readString(auth.chatgpt_account_user_id) ?? + readString(auth.chatgpt_user_id) ?? + readString(auth.user_id) ?? + readString(payload?.sub); + return { + ...(accountId ? { accountId } : {}), + ...(chatgptPlanType ? { chatgptPlanType } : {}), + ...(stableSubject + ? { profileName: `id-${Buffer.from(stableSubject).toString("base64url")}` } + : {}), + }; +} + +function resolveAccessTokenExpiry(access: string): number | undefined { + const payload = decodeJwtPayload(access); + const exp = payload?.exp; + if (typeof exp === "number" && Number.isFinite(exp) && exp > 0) { + return Math.trunc(exp) * 1000; + } + if (typeof exp === "string" && /^\d+$/u.test(exp.trim())) { + return Number.parseInt(exp.trim(), 10) * 1000; + } + return undefined; +} + +function readProviderTokens(auth: Record): HermesCodexAuthCandidate | undefined { + const providers = isRecord(auth.providers) ? auth.providers : {}; + const provider = isRecord(providers[OPENAI_CODEX_PROVIDER_ID]) + ? providers[OPENAI_CODEX_PROVIDER_ID] + : undefined; + const tokens = isRecord(provider?.tokens) ? provider.tokens : undefined; + const access = readString(tokens?.access_token); + const refresh = readString(tokens?.refresh_token); + if (!access || !refresh) { + return undefined; + } + return { + access, + refresh, + sourceLabel: "Hermes active OpenAI Codex provider", + updatedAt: readTimestamp(provider?.last_refresh), + }; +} + +function readPoolTokens(auth: Record): HermesCodexAuthCandidate[] { + const pool = isRecord(auth.credential_pool) ? auth.credential_pool : {}; + const entries = Array.isArray(pool[OPENAI_CODEX_PROVIDER_ID]) + ? pool[OPENAI_CODEX_PROVIDER_ID] + : []; + const candidates: HermesCodexAuthCandidate[] = []; + for (const entry of entries) { + if (!isRecord(entry)) { + continue; + } + const access = readString(entry.access_token); + const refresh = readString(entry.refresh_token); + if (!access || !refresh) { + continue; + } + const label = readString(entry.label) ?? "Hermes OpenAI Codex credential pool"; + candidates.push({ + access, + refresh, + sourceLabel: label, + updatedAt: readTimestamp(entry.last_refresh) ?? readTimestamp(entry.last_status_at), + }); + } + return candidates; +} + +async function readHermesCodexAuthCandidates( + authPath: string | undefined, +): Promise { + const raw = await readText(authPath); + if (!raw) { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!isRecord(parsed)) { + return []; + } + return [readProviderTokens(parsed), ...readPoolTokens(parsed)] + .filter((candidate): candidate is HermesCodexAuthCandidate => candidate !== undefined) + .toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)); +} + +function credentialExtra(identity: CodexIdentity): Record | undefined { + const extra = { + ...(identity.accountId ? { accountId: identity.accountId } : {}), + ...(identity.chatgptPlanType ? { chatgptPlanType: identity.chatgptPlanType } : {}), + }; + return Object.keys(extra).length > 0 ? extra : undefined; +} + +function importProfileName(identity: CodexIdentity, fallback: string): string { + if (identity.accountId) { + return `account-${identity.accountId.replaceAll(/[^A-Za-z0-9._-]+/gu, "-")}`; + } + if (identity.profileName?.startsWith("id-")) { + return identity.profileName; + } + return fallback; +} + +function buildAuthResult( + candidate: HermesCodexAuthCandidate, + fallbackProfileName = "hermes-import", +): ProviderAuthResult { + const identity = resolveCodexIdentity(candidate.access); + return buildOauthProviderAuthResult({ + providerId: OPENAI_CODEX_PROVIDER_ID, + defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + access: candidate.access, + refresh: candidate.refresh, + expires: resolveAccessTokenExpiry(candidate.access), + email: identity.email, + profileName: importProfileName(identity, fallbackProfileName), + displayName: HERMES_AUTH_DISPLAY_NAME, + credentialExtra: credentialExtra(identity), + }); +} + +function authProfileDedupeKey(profile: HermesCodexAuthProfile): string { + if (profile.credential.accountId) { + return `${profile.credential.provider}:account:${profile.credential.accountId}`; + } + if (profile.credential.email) { + return `${profile.credential.provider}:email:${profile.credential.email}`; + } + return `${profile.credential.provider}:profile:${profile.sourceProfileId}`; +} + +async function readHermesCodexAuthProfiles( + authPath: string | undefined, +): Promise { + const candidates = await readHermesCodexAuthCandidates(authPath); + const profiles: HermesCodexAuthProfile[] = []; + const seen = new Set(); + for (const [index, candidate] of candidates.entries()) { + const fallbackProfileName = + candidates.length === 1 ? "hermes-import" : `hermes-import-${index + 1}`; + const result = buildAuthResult(candidate, fallbackProfileName); + const profile = result.profiles[0]; + if (!profile || profile.credential.type !== "oauth") { + continue; + } + const entry = { + candidate, + credential: profile.credential, + result, + sourceProfileId: profile.profileId, + }; + const dedupeKey = authProfileDedupeKey(entry); + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + profiles.push(entry); + } + return profiles; +} + +function findMatchingProfile( + store: AuthProfileStore, + credential: OAuthCredential, +): string | undefined { + for (const [profileId, existing] of Object.entries(store.profiles)) { + if (existing.type !== "oauth" || existing.provider !== credential.provider) { + continue; + } + if (credential.accountId && existing.accountId === credential.accountId) { + return profileId; + } + const canMatchByEmail = !credential.accountId || !existing.accountId; + if (canMatchByEmail && credential.email && existing.email === credential.email) { + return profileId; + } + } + return undefined; +} + +function oauthAuthProfileConfig( + profileId: string, + credential: OAuthCredential, +): HermesAuthProfileConfig { + return { + profileId, + provider: credential.provider, + mode: "oauth", + ...(credential.email ? { email: credential.email } : {}), + ...(credential.displayName ? { displayName: credential.displayName } : {}), + }; +} + +export async function buildAuthItems(params: { + ctx: MigrationProviderContext; + source: HermesSource; + targets: PlannedTargets; +}): Promise { + const profiles = await readHermesCodexAuthProfiles(params.source.authPath); + if (profiles.length === 0) { + return []; + } + const store = loadAuthProfileStoreWithoutExternalProfiles(params.targets.agentDir); + return profiles.map((profile) => { + const matchedProfileId = findMatchingProfile(store, profile.credential); + const profileId = matchedProfileId ?? profile.sourceProfileId; + const targetExists = Boolean(store.profiles[profileId]); + const skipped = !params.ctx.includeSecrets; + const configConflict = hasAuthProfileConfigConflict( + params.ctx.config, + oauthAuthProfileConfig(profileId, profile.credential), + Boolean(params.ctx.overwrite), + ); + const conflict = Boolean( + ((targetExists && !matchedProfileId && !params.ctx.overwrite) || configConflict) && !skipped, + ); + const itemId = + profiles.length === 1 + ? `auth:${OPENAI_CODEX_PROVIDER_ID}` + : `auth:${OPENAI_CODEX_PROVIDER_ID}:${profile.sourceProfileId}`; + return createMigrationItem({ + id: itemId, + kind: "auth", + action: skipped ? "skip" : "create", + source: params.source.authPath, + target: `${params.targets.agentDir}/auth-profiles.json#${profileId}`, + status: skipped ? "skipped" : conflict ? "conflict" : "planned", + sensitive: true, + reason: skipped + ? HERMES_REASON_INCLUDE_SECRETS + : conflict + ? HERMES_REASON_AUTH_PROFILE_EXISTS + : undefined, + message: skipped + ? "OpenAI Codex OAuth credentials detected in Hermes." + : "Import Hermes OpenAI Codex OAuth credentials and configure OpenAI Codex models.", + details: { + provider: OPENAI_CODEX_PROVIDER_ID, + profileId, + sourceProfileId: profile.sourceProfileId, + sourceKind: "hermes-auth-json", + sourceLabel: profile.candidate.sourceLabel, + }, + }); + }); +} + +export async function applyAuthItem( + ctx: MigrationProviderContext, + item: MigrationItem, + targets: PlannedTargets, +): Promise { + if (item.status !== "planned") { + return item; + } + const source = item.source; + const profileId = typeof item.details?.profileId === "string" ? item.details.profileId : ""; + const sourceProfileId = + typeof item.details?.sourceProfileId === "string" ? item.details.sourceProfileId : profileId; + if (!source || !profileId) { + return markMigrationItemError(item, HERMES_REASON_MISSING_SECRET_METADATA); + } + const profile = (await readHermesCodexAuthProfiles(source)).find( + (entry) => entry.sourceProfileId === sourceProfileId, + ); + if (!profile) { + return markMigrationItemSkipped(item, HERMES_REASON_SECRET_NO_LONGER_PRESENT); + } + let conflicted = false; + let wrote = false; + const credential = { + ...profile.credential, + displayName: + "displayName" in profile.credential && profile.credential.displayName + ? profile.credential.displayName + : HERMES_AUTH_DISPLAY_NAME, + }; + const configProfile = oauthAuthProfileConfig(profileId, credential); + if (hasCurrentAuthProfileConfigConflict(ctx, configProfile)) { + return markMigrationItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS); + } + const store = await updateAuthProfileStoreWithLock({ + agentDir: targets.agentDir, + updater: (freshStore) => { + const existing = freshStore.profiles[profileId]; + if (!ctx.overwrite && existing) { + const matchedProfileId = findMatchingProfile(freshStore, credential); + if (matchedProfileId !== profileId) { + conflicted = true; + return false; + } + return false; + } + freshStore.profiles[profileId] = credential; + wrote = true; + return true; + }, + }); + if (conflicted) { + return markMigrationItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS); + } + if (!store?.profiles[profileId]) { + return markMigrationItemError(item, HERMES_REASON_AUTH_PROFILE_WRITE_FAILED); + } + const configResult = await applyAuthProfileConfigWithConflictCheck({ + ctx, + profile: configProfile, + applyConfigPatch(config) { + if (!profile.result.configPatch) { + return config; + } + return applyProviderAuthConfigPatch(config, profile.result.configPatch, { + replaceDefaultModels: profile.result.replaceDefaultModels, + }); + }, + }); + if (configResult === "conflict") { + return markMigrationItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS); + } + return { + ...item, + status: "migrated", + message: + configResult === "configured" + ? item.message + : `${item.message ?? "Imported auth profile."} ${HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE}.`, + details: { + ...item.details, + wroteAuthProfile: wrote, + configUpdated: configResult === "configured", + }, + }; +} diff --git a/extensions/migrate-hermes/files-and-skills.test.ts b/extensions/migrate-hermes/files-and-skills.test.ts index ee8288c8547..80e0aa6a8b4 100644 --- a/extensions/migrate-hermes/files-and-skills.test.ts +++ b/extensions/migrate-hermes/files-and-skills.test.ts @@ -149,7 +149,7 @@ describe("Hermes migration file and skill items", () => { expect(authStore.profiles?.["openai:hermes-import"]?.key).toBe("sk-hermes"); }); - it("archives unsupported Hermes state into the report without importing it", async () => { + it("archives unsupported Hermes state without copying raw auth credentials", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); @@ -165,10 +165,7 @@ describe("Hermes migration file and skill items", () => { expect(plannedLogs?.kind).toBe("archive"); expect(plannedLogs?.action).toBe("archive"); expect(plannedLogs?.status).toBe("planned"); - const plannedAuth = itemById(plan.items, "archive:auth.json"); - expect(plannedAuth?.kind).toBe("archive"); - expect(plannedAuth?.action).toBe("archive"); - expect(plannedAuth?.status).toBe("planned"); + expect(plan.items.find((item) => item.id === "archive:auth.json")).toBeUndefined(); expect(plan.warnings).toEqual([ "Some Hermes files are archive-only. They will be copied into the migration report for manual review, not loaded into OpenClaw.", ]); @@ -179,12 +176,10 @@ describe("Hermes migration file and skill items", () => { const migratedLogs = itemById(result.items, "archive:logs"); expect(migratedLogs?.status).toBe("migrated"); expect(migratedLogs?.target).toBe(path.join(reportDir, "archive", "logs")); - const migratedAuth = itemById(result.items, "archive:auth.json"); - expect(migratedAuth?.status).toBe("migrated"); - expect(migratedAuth?.target).toBe(path.join(reportDir, "archive", "auth.json")); expect(await fs.readFile(path.join(reportDir, "archive", "logs", "session.log"), "utf8")).toBe( "log line\n", ); + await expectPathMissing(path.join(reportDir, "archive", "auth.json")); await expectPathMissing(path.join(workspaceDir, "logs", "session.log")); }); }); diff --git a/extensions/migrate-hermes/items.ts b/extensions/migrate-hermes/items.ts index 47ff8b3d4ff..758c2e5c7b8 100644 --- a/extensions/migrate-hermes/items.ts +++ b/extensions/migrate-hermes/items.ts @@ -9,7 +9,7 @@ import { readString } from "./helpers.js"; export const HERMES_REASON_ALREADY_CONFIGURED = "already configured"; export const HERMES_REASON_DEFAULT_MODEL_CONFIGURED = "default model already configured"; -export const HERMES_REASON_INCLUDE_SECRETS = "use --include-secrets to import"; +export const HERMES_REASON_INCLUDE_SECRETS = "auth credential migration not selected"; export const HERMES_REASON_AUTH_PROFILE_EXISTS = "auth profile exists"; export const HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE = "config runtime unavailable"; export const HERMES_REASON_MISSING_SECRET_METADATA = "missing secret metadata"; diff --git a/extensions/migrate-hermes/plan.ts b/extensions/migrate-hermes/plan.ts index 9930cd8021c..069af834cad 100644 --- a/extensions/migrate-hermes/plan.ts +++ b/extensions/migrate-hermes/plan.ts @@ -9,6 +9,7 @@ import type { MigrationPlan, MigrationProviderContext, } from "openclaw/plugin-sdk/plugin-entry"; +import { buildAuthItems } from "./auth.js"; import { buildConfigItems } from "./config.js"; import { exists, parseHermesConfig, readText } from "./helpers.js"; import { createHermesModelItem } from "./items.js"; @@ -114,6 +115,7 @@ export async function buildHermesPlan(ctx: MigrationProviderContext): Promise item.kind === "secret") + ...(!ctx.includeSecrets && items.some((item) => item.kind === "secret" || item.kind === "auth") ? [ - "Secrets were detected but skipped. Re-run with --include-secrets to import supported API keys.", + "Auth credentials were detected but skipped. Re-run interactively or pass --include-secrets to import supported credentials.", ] : []), ...(items.some((item) => item.status === "conflict") diff --git a/extensions/migrate-hermes/provider.secret-failure.test.ts b/extensions/migrate-hermes/provider.secret-failure.test.ts index bdcefb0d7cd..d46ab21ce4d 100644 --- a/extensions/migrate-hermes/provider.secret-failure.test.ts +++ b/extensions/migrate-hermes/provider.secret-failure.test.ts @@ -10,7 +10,8 @@ const mocks = vi.hoisted(() => ({ updateAuthProfileStoreWithLock: vi.fn(async () => null), })); -vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ +vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => ({ + ...(await importOriginal()), updateAuthProfileStoreWithLock: mocks.updateAuthProfileStoreWithLock, })); @@ -58,6 +59,12 @@ function makeContext(params: { }; } +function fakeJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + describe("Hermes migration provider secret write failures", () => { afterEach(async () => { for (const root of tempRoots) { @@ -104,4 +111,63 @@ describe("Hermes migration provider secret write failures", () => { expect(result.summary.errors).toBe(1); expect(result.summary.migrated).toBe(0); }); + + it("reports an error when an OAuth auth-profile write fails", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "hermes"); + const workspaceDir = path.join(root, "workspace"); + const stateDir = path.join(root, "state"); + const accessToken = fakeJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { email: "codex@example.test" }, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_fail", + chatgpt_plan_type: "plus", + }, + }); + await writeFile( + path.join(source, "auth.json"), + JSON.stringify({ + providers: { + "openai-codex": { + last_refresh: new Date().toISOString(), + tokens: { + access_token: accessToken, + refresh_token: "refresh-fail-token", + }, + }, + }, + }), + ); + + const provider = buildHermesMigrationProvider(); + const result = await provider.apply( + makeContext({ + source, + stateDir, + workspaceDir, + reportDir: path.join(root, "report"), + }), + ); + + expect(result.items).toEqual([ + expect.objectContaining({ + id: "auth:openai-codex", + kind: "auth", + action: "create", + source: path.join(source, "auth.json"), + target: `${path.join(stateDir, "agents", "main", "agent")}/auth-profiles.json#openai-codex:account-acct_fail`, + status: "error", + sensitive: true, + reason: HERMES_REASON_AUTH_PROFILE_WRITE_FAILED, + details: expect.objectContaining({ + provider: "openai-codex", + profileId: "openai-codex:account-acct_fail", + sourceProfileId: "openai-codex:account-acct_fail", + }), + }), + ]); + expect(result.summary.errors).toBe(1); + expect(result.summary.migrated).toBe(0); + }); }); diff --git a/extensions/migrate-hermes/provider.test.ts b/extensions/migrate-hermes/provider.test.ts index 698da53bb42..3ce7d5efd27 100644 --- a/extensions/migrate-hermes/provider.test.ts +++ b/extensions/migrate-hermes/provider.test.ts @@ -137,7 +137,7 @@ describe("Hermes migration provider", () => { expect(secret?.status).toBe("skipped"); expect(secret?.reason).toBe(HERMES_REASON_INCLUDE_SECRETS); expect(plan.warnings).toEqual([ - "Secrets were detected but skipped. Re-run with --include-secrets to import supported API keys.", + "Auth credentials were detected but skipped. Re-run interactively or pass --include-secrets to import supported credentials.", "Conflicts were found. Re-run with --overwrite to replace conflicting targets after item-level backups.", ]); }); diff --git a/extensions/migrate-hermes/secrets.test.ts b/extensions/migrate-hermes/secrets.test.ts index f2eb3c90d90..27ee0d83aaf 100644 --- a/extensions/migrate-hermes/secrets.test.ts +++ b/extensions/migrate-hermes/secrets.test.ts @@ -1,10 +1,17 @@ import fs from "node:fs/promises"; 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 { buildHermesMigrationProvider } from "./provider.js"; -import { cleanupTempRoots, makeContext, makeTempRoot, writeFile } from "./test/provider-helpers.js"; +import { + cleanupTempRoots, + makeConfigRuntime, + makeContext, + makeTempRoot, + writeFile, +} from "./test/provider-helpers.js"; async function expectMissingPath(filePath: string): Promise { try { @@ -16,6 +23,12 @@ async function expectMissingPath(filePath: string): Promise { throw new Error(`expected missing path: ${filePath}`); } +function fakeJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + describe("Hermes migration secret items", () => { afterEach(async () => { await cleanupTempRoots(); @@ -102,6 +115,65 @@ describe("Hermes migration secret items", () => { await expectMissingPath(path.join(stateDir, "agents", "custom", "agent", "auth-profiles.json")); }); + it("reports API key import when config update fails after profile write", 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"), "OPENAI_API_KEY=sk-hermes\n"); + const config = { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + } as OpenClawConfig; + const runtime = { + config: { + current: () => config, + mutateConfigFile: async () => { + throw new Error("config write failed"); + }, + }, + } as unknown as MigrationProviderContext["runtime"]; + + const provider = buildHermesMigrationProvider(); + const ctx = makeContext({ + source, + stateDir, + workspaceDir, + config, + includeSecrets: true, + reportDir, + runtime, + }); + const plan = await provider.plan(ctx); + + const result = await provider.apply(ctx, plan); + + const item = result.items.find((entry) => entry.id === "secret:openai"); + expect(item).toEqual( + expect.objectContaining({ + status: "migrated", + details: expect.objectContaining({ + configUpdated: false, + }), + }), + ); + const authStore = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"), + ) as { profiles?: Record }; + expect(authStore.profiles?.["openai:hermes-import"]).toEqual( + expect.objectContaining({ + type: "api_key", + provider: "openai", + key: "sk-hermes", + }), + ); + }); + it("keeps secret conflict checks read-only during planning", async () => { const root = await makeTempRoot(); const source = path.join(root, "hermes"); @@ -184,4 +256,485 @@ describe("Hermes migration secret items", () => { ) as { profiles?: Record }; expect(authStore.profiles?.["openai:hermes-import"]?.key).toBe("sk-late"); }); + + it("reports API key config auth profile conflicts during planning", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "hermes"); + const workspaceDir = path.join(root, "workspace"); + const stateDir = path.join(root, "state"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); + const config = { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + auth: { + profiles: { + "openai:hermes-import": { + provider: "anthropic", + mode: "api_key", + }, + }, + }, + } as OpenClawConfig; + + const provider = buildHermesMigrationProvider(); + const ctx = makeContext({ + source, + stateDir, + workspaceDir, + config, + includeSecrets: true, + }); + const plan = await provider.plan(ctx); + + expect(plan.items).toEqual([ + expect.objectContaining({ + id: "secret:openai", + status: "conflict", + reason: HERMES_REASON_AUTH_PROFILE_EXISTS, + }), + ]); + + const result = await provider.apply(ctx, plan); + + expect(result.summary.conflicts).toBe(1); + await expectMissingPath(path.join(agentDir, "auth-profiles.json")); + }); + + it("reports late-created API key config auth profile conflicts before writing", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "hermes"); + const workspaceDir = path.join(root, "workspace"); + const stateDir = path.join(root, "state"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + await writeFile(path.join(source, ".env"), "OPENAI_API_KEY=sk-hermes\n"); + const config = { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + } as OpenClawConfig; + + const provider = buildHermesMigrationProvider(); + const ctx = makeContext({ + source, + stateDir, + workspaceDir, + config, + includeSecrets: true, + runtime: makeConfigRuntime(config), + }); + const plan = await provider.plan(ctx); + config.auth = { + profiles: { + "openai:hermes-import": { + provider: "anthropic", + mode: "api_key", + }, + }, + }; + + const result = await provider.apply(ctx, plan); + + expect(result.items).toEqual([ + expect.objectContaining({ + id: "secret:openai", + status: "conflict", + reason: HERMES_REASON_AUTH_PROFILE_EXISTS, + }), + ]); + expect(result.summary.conflicts).toBe(1); + await expectMissingPath(path.join(agentDir, "auth-profiles.json")); + }); + + it("imports Hermes auth.json OpenAI Codex OAuth and configures models", 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: "codex@example.test" }, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_test", + chatgpt_plan_type: "plus", + }, + }); + const config = { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + } as OpenClawConfig; + await writeFile( + path.join(source, "auth.json"), + JSON.stringify({ + providers: { + "openai-codex": { + last_refresh: new Date().toISOString(), + tokens: { + access_token: accessToken, + refresh_token: "refresh-test-token", + }, + }, + }, + }), + ); + + 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", + sensitive: true, + }), + ]), + ); + + const result = await provider.apply(ctx, plan); + + expect(result.summary.errors).toBe(0); + expect(result.summary.migrated).toBeGreaterThanOrEqual(1); + const authStore = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"), + ) as { + profiles?: Record< + string, + { access?: string; provider?: string; refresh?: string; type?: string } + >; + }; + const profile = authStore.profiles?.["openai-codex:account-acct_test"]; + expect(profile).toEqual( + expect.objectContaining({ + type: "oauth", + provider: "openai-codex", + access: accessToken, + refresh: "refresh-test-token", + }), + ); + expect(config.auth?.profiles?.["openai-codex:account-acct_test"]).toEqual( + expect.objectContaining({ + provider: "openai-codex", + mode: "oauth", + }), + ); + expect(config.agents?.defaults?.models?.["openai/gpt-5.5"]).toEqual({}); + }); + + it("reports Hermes OAuth config auth profile conflicts during planning", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "hermes"); + const workspaceDir = path.join(root, "workspace"); + const stateDir = path.join(root, "state"); + const accessToken = fakeJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { email: "codex@example.test" }, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_conflict", + chatgpt_plan_type: "plus", + }, + }); + const config = { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + auth: { + profiles: { + "openai-codex:account-acct_conflict": { + provider: "openai-codex", + mode: "api_key", + }, + }, + }, + } as OpenClawConfig; + await writeFile( + path.join(source, "auth.json"), + JSON.stringify({ + providers: { + "openai-codex": { + last_refresh: new Date().toISOString(), + tokens: { + access_token: accessToken, + refresh_token: "refresh-test-token", + }, + }, + }, + }), + ); + + const provider = buildHermesMigrationProvider(); + const plan = await provider.plan( + makeContext({ + source, + stateDir, + workspaceDir, + config, + includeSecrets: true, + }), + ); + const authItem = plan.items.find((item) => item.id === "auth:openai-codex"); + + expect(authItem).toEqual( + expect.objectContaining({ + status: "conflict", + reason: HERMES_REASON_AUTH_PROFILE_EXISTS, + details: expect.objectContaining({ + profileId: "openai-codex:account-acct_conflict", + }), + }), + ); + }); + + it("imports every distinct Hermes auth.json OpenAI Codex OAuth credential", 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 activeAccessToken = fakeJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { email: "active@example.test" }, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_active", + chatgpt_plan_type: "plus", + }, + }); + const poolAccessToken = fakeJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { email: "pool@example.test" }, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_pool", + chatgpt_plan_type: "team", + }, + }); + const secondPoolAccessToken = fakeJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { email: "second-pool@example.test" }, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_second_pool", + chatgpt_plan_type: "pro", + }, + }); + const config = { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + } as OpenClawConfig; + await writeFile( + path.join(source, "auth.json"), + JSON.stringify({ + providers: { + "openai-codex": { + last_refresh: "2026-01-03T00:00:00.000Z", + tokens: { + access_token: activeAccessToken, + refresh_token: "refresh-active-token", + }, + }, + }, + credential_pool: { + "openai-codex": [ + { + label: "Pool account", + last_refresh: "2026-01-02T00:00:00.000Z", + access_token: poolAccessToken, + refresh_token: "refresh-pool-token", + }, + { + label: "Second pool account", + last_refresh: "2026-01-01T00:00:00.000Z", + access_token: secondPoolAccessToken, + refresh_token: "refresh-second-pool-token", + }, + ], + }, + }), + ); + + const provider = buildHermesMigrationProvider(); + const ctx = makeContext({ + source, + stateDir, + workspaceDir, + config, + includeSecrets: true, + reportDir, + runtime: makeConfigRuntime(config), + }); + const plan = await provider.plan(ctx); + const authItems = plan.items.filter((item) => item.kind === "auth"); + + expect(authItems).toHaveLength(3); + expect(authItems.map((item) => item.details?.profileId).sort()).toEqual([ + "openai-codex:account-acct_active", + "openai-codex:account-acct_pool", + "openai-codex:account-acct_second_pool", + ]); + + 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; provider?: string; refresh?: string; type?: string } + >; + }; + expect(authStore.profiles?.["openai-codex:account-acct_active"]).toEqual( + expect.objectContaining({ + type: "oauth", + provider: "openai-codex", + access: activeAccessToken, + refresh: "refresh-active-token", + }), + ); + expect(authStore.profiles?.["openai-codex:account-acct_pool"]).toEqual( + expect.objectContaining({ + type: "oauth", + provider: "openai-codex", + access: poolAccessToken, + refresh: "refresh-pool-token", + }), + ); + expect(authStore.profiles?.["openai-codex:account-acct_second_pool"]).toEqual( + expect.objectContaining({ + type: "oauth", + provider: "openai-codex", + access: secondPoolAccessToken, + refresh: "refresh-second-pool-token", + }), + ); + expect(Object.keys(config.auth?.profiles ?? {}).sort()).toEqual([ + "openai-codex:account-acct_active", + "openai-codex:account-acct_pool", + "openai-codex:account-acct_second_pool", + ]); + expect(config.agents?.defaults?.models?.["openai/gpt-5.5"]).toEqual({}); + }); + + it("does not collapse Hermes OAuth accounts that share an email", 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 sharedEmail = "shared@example.com"; + const accessToken = fakeJwt({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/profile": { email: sharedEmail }, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_new", + chatgpt_plan_type: "plus", + }, + }); + const config = { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + } as OpenClawConfig; + await writeFile( + path.join(source, "auth.json"), + JSON.stringify({ + providers: { + "openai-codex": { + last_refresh: new Date().toISOString(), + tokens: { + access_token: accessToken, + refresh_token: "refresh-new-token", + }, + }, + }, + }), + ); + await writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify({ + profiles: { + "openai-codex:account-acct_old": { + type: "oauth", + provider: "openai-codex", + access: "old-access-token", + refresh: "old-refresh-token", + accountId: "acct_old", + email: sharedEmail, + }, + }, + }), + ); + + const provider = buildHermesMigrationProvider(); + const ctx = makeContext({ + source, + stateDir, + workspaceDir, + config, + includeSecrets: true, + reportDir, + runtime: makeConfigRuntime(config), + }); + const plan = await provider.plan(ctx); + const authItem = plan.items.find((item) => item.id === "auth:openai-codex"); + + expect(authItem).toEqual( + expect.objectContaining({ + status: "planned", + details: expect.objectContaining({ + profileId: "openai-codex:account-acct_new", + }), + }), + ); + + 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; + }; + expect(authStore.profiles?.["openai-codex:account-acct_old"]).toEqual( + expect.objectContaining({ + access: "old-access-token", + accountId: "acct_old", + email: sharedEmail, + }), + ); + expect(authStore.profiles?.["openai-codex:account-acct_new"]).toEqual( + expect.objectContaining({ + access: accessToken, + accountId: "acct_new", + email: sharedEmail, + }), + ); + }); }); diff --git a/extensions/migrate-hermes/secrets.ts b/extensions/migrate-hermes/secrets.ts index 0ecf876b1b1..4c8b4eb66c9 100644 --- a/extensions/migrate-hermes/secrets.ts +++ b/extensions/migrate-hermes/secrets.ts @@ -1,6 +1,12 @@ import { loadAuthProfileStoreWithoutExternalProfiles } from "openclaw/plugin-sdk/agent-runtime"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; import { updateAuthProfileStoreWithLock } from "openclaw/plugin-sdk/provider-auth"; +import { + applyAuthProfileConfigWithConflictCheck, + hasAuthProfileConfigConflict, + hasCurrentAuthProfileConfigConflict, + type HermesAuthProfileConfig, +} from "./auth-config.js"; import { parseEnv, readText } from "./helpers.js"; import { createHermesSecretItem, @@ -34,6 +40,15 @@ const SECRET_MAPPINGS: readonly SecretMapping[] = [ { envVar: "DEEPSEEK_API_KEY", provider: "deepseek", profileId: "deepseek:hermes-import" }, ] as const; +function secretAuthProfileConfig(details: SecretMapping): HermesAuthProfileConfig { + return { + profileId: details.profileId, + provider: details.provider, + mode: "api_key", + displayName: "Hermes import", + }; +} + export async function buildSecretItems(params: { ctx: MigrationProviderContext; source: HermesSource; @@ -50,13 +65,18 @@ export async function buildSecretItems(params: { } seenProfiles.add(mapping.profileId); const existsAlready = Boolean(store.profiles[mapping.profileId]); + const configConflict = hasAuthProfileConfigConflict( + params.ctx.config, + secretAuthProfileConfig(mapping), + Boolean(params.ctx.overwrite), + ); items.push( createHermesSecretItem({ id: `secret:${mapping.provider}`, source: params.source.envPath, target: `${params.targets.agentDir}/auth-profiles.json#${mapping.profileId}`, includeSecrets: params.ctx.includeSecrets, - existsAlready: existsAlready && !params.ctx.overwrite, + existsAlready: (existsAlready && !params.ctx.overwrite) || configConflict, details: { envVar: mapping.envVar, provider: mapping.provider, @@ -86,6 +106,10 @@ export async function applySecretItem( if (!key) { return hermesItemSkipped(item, HERMES_REASON_SECRET_NO_LONGER_PRESENT); } + const configProfile = secretAuthProfileConfig(details); + if (hasCurrentAuthProfileConfigConflict(ctx, configProfile)) { + return hermesItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS); + } let conflicted = false; let wrote = false; const store = await updateAuthProfileStoreWithLock({ @@ -114,5 +138,19 @@ export async function applySecretItem( if (!wrote && !ctx.overwrite) { return hermesItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS); } - return { ...item, status: "migrated" }; + const configResult = await applyAuthProfileConfigWithConflictCheck({ + ctx, + profile: configProfile, + }); + if (configResult === "conflict") { + return hermesItemConflict(item, HERMES_REASON_AUTH_PROFILE_EXISTS); + } + return { + ...item, + status: "migrated", + details: { + ...item.details, + configUpdated: configResult === "configured", + }, + }; } diff --git a/extensions/migrate-hermes/source.ts b/extensions/migrate-hermes/source.ts index 4a3cf5315a1..2da24e0d8ab 100644 --- a/extensions/migrate-hermes/source.ts +++ b/extensions/migrate-hermes/source.ts @@ -5,6 +5,7 @@ export type HermesSource = { root: string; configPath?: string; envPath?: string; + authPath?: string; soulPath?: string; agentsPath?: string; memoryPath?: string; @@ -20,7 +21,7 @@ type HermesArchivePath = { }; const HERMES_ARCHIVE_DIRS = ["plugins", "sessions", "logs", "cron", "mcp-tokens"] as const; -const HERMES_ARCHIVE_FILES = ["auth.json", "state.db"] as const; +const HERMES_ARCHIVE_FILES = ["state.db"] as const; export async function discoverHermesSource(input?: string): Promise { const root = resolveHomePath(input?.trim() || "~/.hermes"); @@ -44,6 +45,9 @@ export async function discoverHermesSource(input?: string): Promise", "Source directory to migrate from") - .option("--include-secrets", "Import supported credentials and secrets", false) + .option("--include-secrets", "Import supported credentials and secrets") + .option("--no-auth-credentials", "Skip auth credential migration") .option( "--overwrite", "Overwrite conflicting target files after item-level backups", @@ -93,7 +94,8 @@ export function registerMigrateCommand(program: Command) { .description("Import state from another agent system") .argument("[provider]", "Migration provider id, for example hermes") .option("--from ", "Source directory to migrate from") - .option("--include-secrets", "Import supported credentials and secrets", false) + .option("--include-secrets", "Import supported credentials and secrets") + .option("--no-auth-credentials", "Skip auth credential migration") .option("--overwrite", "Overwrite conflicting target files after item-level backups", false) .option("--dry-run", "Preview only; do not apply changes", false) .option("--yes", "Apply without prompting after preview", false) @@ -124,8 +126,8 @@ export function registerMigrateCommand(program: Command) { "Apply Hermes migration non-interactively after writing a verified backup.", ], [ - "openclaw migrate apply hermes --include-secrets --yes", - "Include supported credentials in the migration.", + "openclaw migrate hermes --no-auth-credentials", + "Preview and apply Hermes migration while skipping auth credential import.", ], ])}`, ) @@ -134,7 +136,8 @@ export function registerMigrateCommand(program: Command) { await migrateDefaultCommand(defaultRuntime, { provider: provider as string | undefined, source: opts.from as string | undefined, - includeSecrets: Boolean(opts.includeSecrets), + includeSecrets: opts.includeSecrets === true ? true : undefined, + authCredentials: opts.authCredentials as boolean | undefined, overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), plugins: readMigrationPlugins(opts.plugin), @@ -168,7 +171,8 @@ export function registerMigrateCommand(program: Command) { await migratePlanCommand(defaultRuntime, { provider: provider as string, source: opts.from as string | undefined, - includeSecrets: Boolean(opts.includeSecrets), + includeSecrets: opts.includeSecrets === true ? true : undefined, + authCredentials: opts.authCredentials as boolean | undefined, overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), plugins: readMigrationPlugins(opts.plugin), @@ -190,7 +194,8 @@ export function registerMigrateCommand(program: Command) { await migrateApplyCommand(defaultRuntime, { provider: provider as string, source: opts.from as string | undefined, - includeSecrets: Boolean(opts.includeSecrets), + includeSecrets: opts.includeSecrets === true ? true : undefined, + authCredentials: opts.authCredentials as boolean | undefined, overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), plugins: readMigrationPlugins(opts.plugin), diff --git a/src/commands/migrate.test.ts b/src/commands/migrate.test.ts index 7edb844b8f3..3a81998e524 100644 --- a/src/commands/migrate.test.ts +++ b/src/commands/migrate.test.ts @@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({ backupCreateCommand: vi.fn(), cancelSymbol: Symbol("cancel"), clackCancel: vi.fn(), + clackConfirm: vi.fn(), clackIsCancel: vi.fn(), + clackLogMessage: vi.fn(), multiselect: vi.fn(), progress: { setLabel: vi.fn(), @@ -47,8 +49,9 @@ vi.mock("../cli/progress.js", () => ({ vi.mock("@clack/prompts", () => ({ cancel: mocks.clackCancel, + confirm: mocks.clackConfirm, isCancel: mocks.clackIsCancel, - log: { message: vi.fn() }, + log: { message: mocks.clackLogMessage }, })); vi.mock("./migrate/skill-selection-prompt.js", () => ({ @@ -92,6 +95,30 @@ function plan(overrides: Partial = {}): MigrationPlan { }; } +function authPlan(status: MigrationPlan["items"][number]["status"] = "skipped"): MigrationPlan { + return plan({ + summary: { + total: 1, + planned: status === "planned" ? 1 : 0, + migrated: 0, + skipped: status === "skipped" ? 1 : 0, + conflicts: 0, + errors: 0, + sensitive: 1, + }, + items: [ + { + id: "auth:openai-codex", + kind: "auth", + action: status === "planned" ? "create" : "skip", + status, + sensitive: true, + reason: status === "skipped" ? "auth credential migration not selected" : undefined, + }, + ], + }); +} + function codexSkillPlan(overrides: Partial = {}): MigrationPlan { const items: MigrationPlan["items"] = [ { @@ -275,8 +302,10 @@ describe("migrateApplyCommand", () => { mocks.progress.tick.mockClear(); mocks.multiselect.mockReset(); mocks.clackCancel.mockReset(); + mocks.clackConfirm.mockReset(); mocks.clackIsCancel.mockReset(); mocks.clackIsCancel.mockImplementation((value) => value === mocks.cancelSymbol); + mocks.clackLogMessage.mockReset(); mocks.promptYesNo.mockReset(); mocks.backupCreateCommand.mockReset(); mocks.backupCreateCommand.mockResolvedValue({ archivePath: "/tmp/openclaw-backup.tgz" }); @@ -457,6 +486,137 @@ describe("migrateApplyCommand", () => { expect(firstAppliedPlan()).toBe(planned); }); + it("asks before including auth credentials in interactive root migrations", async () => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + const skippedAuthPlan = authPlan("skipped"); + const plannedAuthPlan = authPlan("planned"); + mocks.provider.plan + .mockImplementationOnce(async (ctx) => { + expect(ctx.includeSecrets).toBe(false); + return skippedAuthPlan; + }) + .mockImplementationOnce(async (ctx) => { + expect(ctx.includeSecrets).toBe(true); + return plannedAuthPlan; + }); + mocks.clackConfirm.mockResolvedValue(true); + + const result = await migrateDefaultCommand(runtime, { provider: "hermes", dryRun: true }); + + expect(result).toBe(plannedAuthPlan); + expect(mocks.clackConfirm).toHaveBeenCalledWith({ + message: "Do you want to migrate your auth credentials as well?", + initialValue: true, + }); + expect(mocks.provider.plan).toHaveBeenCalledTimes(2); + }); + + it("does not replan auth credentials when the interactive auth prompt is declined", async () => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + const skippedAuthPlan = authPlan("skipped"); + mocks.provider.plan.mockResolvedValue(skippedAuthPlan); + mocks.clackConfirm.mockResolvedValue(false); + + const result = await migrateDefaultCommand(runtime, { provider: "hermes", dryRun: true }); + + expect(result).toBe(skippedAuthPlan); + expect(mocks.provider.plan).toHaveBeenCalledTimes(1); + expect(mocks.clackConfirm).toHaveBeenCalledWith({ + message: "Do you want to migrate your auth credentials as well?", + initialValue: true, + }); + }); + + it("cancels the migration when the interactive auth prompt is canceled", async () => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + const skippedAuthPlan = authPlan("skipped"); + mocks.provider.plan.mockResolvedValue(skippedAuthPlan); + mocks.clackConfirm.mockResolvedValue(mocks.cancelSymbol); + + await expect( + migrateDefaultCommand(runtime, { provider: "hermes", dryRun: true }), + ).rejects.toThrow("exit 0"); + + expect(mocks.clackCancel).toHaveBeenCalledWith("Migration cancelled."); + expect(mocks.provider.plan).toHaveBeenCalledTimes(1); + expect(mocks.provider.apply).not.toHaveBeenCalled(); + expect(mocks.promptYesNo).not.toHaveBeenCalled(); + }); + + it("honors suppressPlanLog while using the interactive auth prompt path", async () => { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + const skippedAuthPlan = authPlan("skipped"); + const plannedAuthPlan = authPlan("planned"); + mocks.provider.plan + .mockResolvedValueOnce(skippedAuthPlan) + .mockResolvedValueOnce(plannedAuthPlan); + mocks.clackConfirm.mockResolvedValue(true); + + const result = await migrateDefaultCommand(runtime, { + provider: "hermes", + dryRun: true, + suppressPlanLog: true, + }); + + expect(result).toBe(plannedAuthPlan); + expect(mocks.clackLogMessage).not.toHaveBeenCalled(); + }); + + it("keeps auth credentials skipped by default for non-interactive --yes apply", async () => { + const planned = authPlan("skipped"); + const applied: MigrationApplyResult = { + ...planned, + items: planned.items, + }; + mocks.provider.plan.mockImplementation(async (ctx) => { + expect(ctx.includeSecrets).toBe(false); + return planned; + }); + mocks.provider.apply.mockImplementation(async (ctx) => { + expect(ctx.includeSecrets).toBe(false); + return applied; + }); + + await migrateApplyCommand(runtime, { provider: "hermes", yes: true }); + + expect(mocks.provider.plan).toHaveBeenCalledTimes(1); + expect(mocks.provider.apply).toHaveBeenCalledTimes(1); + }); + + it("includes auth credentials when --yes is paired with explicit secret import", async () => { + const planned = authPlan("planned"); + const applied: MigrationApplyResult = { + ...planned, + summary: { ...planned.summary, planned: 0, migrated: 1 }, + items: planned.items.map((item) => ({ ...item, status: "migrated" })), + }; + mocks.provider.plan.mockImplementation(async (ctx) => { + expect(ctx.includeSecrets).toBe(true); + return planned; + }); + mocks.provider.apply.mockImplementation(async (ctx) => { + expect(ctx.includeSecrets).toBe(true); + return applied; + }); + + await migrateApplyCommand(runtime, { provider: "hermes", yes: true, includeSecrets: true }); + + expect(mocks.provider.plan).toHaveBeenCalledTimes(1); + expect(mocks.provider.apply).toHaveBeenCalledTimes(1); + }); + it("prompts for Codex skills before interactive default apply", async () => { Object.defineProperty(process.stdin, "isTTY", { configurable: true, diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 4d0f3034b1e..84aca86571a 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,4 +1,4 @@ -import { cancel, isCancel, log } from "@clack/prompts"; +import { cancel, confirm, isCancel, log } from "@clack/prompts"; import { formatCliCommand } from "../cli/command-format.js"; import { withProgress } from "../cli/progress.js"; import { promptYesNo } from "../cli/prompt.js"; @@ -52,6 +52,42 @@ function selectMigrationItems(plan: MigrationPlan, opts: MigrateCommonOptions): ); } +function hasAuthCredentialCandidate(plan: MigrationPlan): boolean { + return plan.items.some( + (item) => item.kind === "auth" || item.kind === "secret" || item.sensitive === true, + ); +} + +function hasPlannedAuthCredentialItem(plan: MigrationPlan): boolean { + return plan.items.some( + (item) => + item.status === "planned" && + (item.kind === "auth" || item.kind === "secret" || item.sensitive === true), + ); +} + +function resolveDefaultIncludeSecrets( + opts: T, +): T { + if (opts.includeSecrets !== undefined) { + return opts; + } + if (opts.authCredentials === false) { + return { ...opts, includeSecrets: false }; + } + return opts; +} + +function shouldPromptForAuthCredentials(opts: MigrateCommonOptions & { yes?: boolean }): boolean { + return ( + opts.includeSecrets === undefined && + opts.authCredentials !== false && + !opts.yes && + !opts.json && + process.stdin.isTTY + ); +} + async function createMigrationPlanWithProgress( runtime: RuntimeEnv, opts: MigrateCommonOptions & { provider: string }, @@ -72,6 +108,46 @@ async function createMigrationPlanWithProgress( return selectMigrationItems(plan, opts); } +async function createInteractiveMigrationPlanWithAuthPrompt( + runtime: RuntimeEnv, + opts: MigrateCommonOptions & { provider: string; yes?: boolean }, +): Promise { + if (!shouldPromptForAuthCredentials(opts)) { + return await migratePlanCommand(runtime, resolveDefaultIncludeSecrets(opts)); + } + const initialPlan = await migratePlanCommand(runtime, { + ...opts, + includeSecrets: false, + suppressPlanLog: true, + }); + if (!hasAuthCredentialCandidate(initialPlan)) { + if (!opts.suppressPlanLog) { + log.message(formatMigrationPreview(initialPlan).join("\n")); + } + return initialPlan; + } + const includeSecrets = await confirm({ + message: stylePromptMessage("Do you want to migrate your auth credentials as well?"), + initialValue: true, + }); + if (isCancel(includeSecrets)) { + cancel(stylePromptTitle("Migration cancelled.") ?? "Migration cancelled."); + runtime.exit(0); + throw new Error("unreachable"); + } + const finalPlan = includeSecrets + ? await migratePlanCommand(runtime, { + ...opts, + includeSecrets: true, + suppressPlanLog: true, + }) + : initialPlan; + if (!opts.suppressPlanLog) { + log.message(formatMigrationPreview(finalPlan).join("\n")); + } + return finalPlan; +} + function assertVerifyPluginAppsProvider(providerId: string, opts: MigrateCommonOptions): void { if (opts.verifyPluginApps && providerId !== "codex") { throw new Error("--verify-plugin-apps is only supported for Codex migrations."); @@ -216,7 +292,9 @@ function hasSelectedCodexMigrationWork(plan: MigrationPlan): boolean { return plan.items.some( (item) => item.status === "planned" && - ((item.kind === "skill" && item.action === "copy") || + (item.kind === "auth" || + item.kind === "secret" || + (item.kind === "skill" && item.action === "copy") || (item.kind === "plugin" && item.action === "install")), ); } @@ -330,7 +408,7 @@ export async function migrateApplyCommand( } const provider = resolveMigrationProvider(providerId, opts.configOverride); if (!opts.yes) { - const plan = await migratePlanCommand(runtime, { + const plan = await createInteractiveMigrationPlanWithAuthPrompt(runtime, { ...opts, provider: providerId, json: opts.json, @@ -353,12 +431,23 @@ export async function migrateApplyCommand( } return await runMigrationApply({ runtime, - opts: { ...opts, provider: providerId, yes: true, preflightPlan: selectedPlan }, + opts: { + ...opts, + provider: providerId, + yes: true, + includeSecrets: opts.includeSecrets ?? hasPlannedAuthCredentialItem(selectedPlan), + preflightPlan: selectedPlan, + }, providerId, provider, }); } - return await runMigrationApply({ runtime, opts, providerId, provider }); + return await runMigrationApply({ + runtime, + opts: resolveDefaultIncludeSecrets(opts), + providerId, + provider, + }); } export async function migrateDefaultCommand( @@ -384,17 +473,24 @@ export async function migrateDefaultCommand( }; } assertVerifyPluginAppsProvider(providerId, opts); + const resolvedOpts = resolveDefaultIncludeSecrets(opts); const plan = opts.json && opts.yes && !opts.dryRun ? selectMigrationItems( - await createMigrationPlan(runtime, { ...opts, provider: providerId }), - opts, + await createMigrationPlan(runtime, { ...resolvedOpts, provider: providerId }), + resolvedOpts, ) - : await migratePlanCommand(runtime, { - ...opts, - provider: providerId, - json: opts.json && (opts.dryRun || !opts.yes), - }); + : !opts.yes && process.stdin.isTTY + ? await createInteractiveMigrationPlanWithAuthPrompt(runtime, { + ...opts, + provider: providerId, + json: opts.json && (opts.dryRun || !opts.yes), + }) + : await migratePlanCommand(runtime, { + ...resolvedOpts, + provider: providerId, + json: opts.json && (opts.dryRun || !opts.yes), + }); if (opts.dryRun) { return plan; } @@ -423,12 +519,13 @@ export async function migrateDefaultCommand( ...opts, provider: providerId, yes: true, + includeSecrets: opts.includeSecrets ?? hasPlannedAuthCredentialItem(selectedPlan), json: opts.json, preflightPlan: selectedPlan, }); } return await migrateApplyCommand(runtime, { - ...opts, + ...resolvedOpts, provider: providerId, yes: true, json: opts.json, diff --git a/src/commands/migrate/output.ts b/src/commands/migrate/output.ts index 8b3d0e33055..3e5e7d3c1e6 100644 --- a/src/commands/migrate/output.ts +++ b/src/commands/migrate/output.ts @@ -32,6 +32,7 @@ type ItemGroup = { }; const ITEM_GROUPS: ItemGroup[] = [ + { kind: "auth", heading: "Auth credentials:" }, { kind: "skill", heading: "Skills:" }, { kind: "plugin", heading: "Plugins:" }, { kind: "memory", heading: "Memory:" }, diff --git a/src/commands/migrate/types.ts b/src/commands/migrate/types.ts index df9f3c0b1f6..377b675575b 100644 --- a/src/commands/migrate/types.ts +++ b/src/commands/migrate/types.ts @@ -7,6 +7,7 @@ export type MigrateCommonOptions = { provider?: string; source?: string; includeSecrets?: boolean; + authCredentials?: boolean; overwrite?: boolean; skills?: string[]; plugins?: string[]; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index c47a6e2ee79..1701a059496 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -81,6 +81,7 @@ export { type ApiKeyStorageOptions, type WriteOAuthCredentialsOptions, } from "../plugins/provider-auth-helpers.js"; +export { applyProviderAuthConfigPatch } from "../plugins/provider-auth-choice-helpers.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; export { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js"; export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 45c7d665c90..1479392bfb5 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -2388,6 +2388,7 @@ export type MigrationItemStatus = | "conflict" | "error"; export type MigrationItemKind = + | "auth" | "config" | "secret" | "memory"