From f036bac14423de4249df6513cbe5aac977b85dea Mon Sep 17 00:00:00 2001
From: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com>
Date: Sat, 23 May 2026 01:47:08 -0600
Subject: [PATCH] migrate auth credentials
---
.../.generated/plugin-sdk-api-baseline.sha256 | 4 +-
docs/cli/migrate.md | 11 +-
docs/install/migrating-hermes.md | 17 +-
extensions/codex/src/migration/apply.ts | 31 +
extensions/codex/src/migration/auth.ts | 709 ++++++++++++++++++
extensions/codex/src/migration/plan.ts | 7 +
.../codex/src/migration/provider.test.ts | 563 ++++++++++++++
extensions/codex/src/migration/source.ts | 11 +-
extensions/migrate-hermes/apply.ts | 5 +-
extensions/migrate-hermes/auth-config.ts | 97 +++
extensions/migrate-hermes/auth.ts | 447 +++++++++++
.../migrate-hermes/files-and-skills.test.ts | 11 +-
extensions/migrate-hermes/items.ts | 2 +-
extensions/migrate-hermes/plan.ts | 6 +-
.../provider.secret-failure.test.ts | 68 +-
extensions/migrate-hermes/provider.test.ts | 2 +-
extensions/migrate-hermes/secrets.test.ts | 555 +++++++++++++-
extensions/migrate-hermes/secrets.ts | 42 +-
extensions/migrate-hermes/source.ts | 7 +-
src/cli/program/register.migrate.ts | 19 +-
src/commands/migrate.test.ts | 162 +++-
src/commands/migrate.ts | 123 ++-
src/commands/migrate/output.ts | 1 +
src/commands/migrate/types.ts | 1 +
src/plugin-sdk/provider-auth.ts | 1 +
src/plugins/types.ts | 1 +
26 files changed, 2848 insertions(+), 55 deletions(-)
create mode 100644 extensions/codex/src/migration/auth.ts
create mode 100644 extensions/migrate-hermes/auth-config.ts
create mode 100644 extensions/migrate-hermes/auth.ts
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"