From 20ac22f647a4212fb801930603ae7e98103208cf Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Sat, 1 Aug 2026 14:08:02 +0100 Subject: [PATCH] fix(codex): guide recovery from unimported agent auth (#116807) * fix(codex): diagnose unimported agent auth Add agent-scoped Codex migration guidance and expose a validated --agent target so non-default credentials cannot be imported into the default agent store. Fixes #116763 * fix(codex): surface auth migration before route planning * fix(codex): scope auth migration recovery * fix(codex): preserve API key auth fallback * test: repair memory importer routing expectation * test: drop superseded memory routing repair * test(tui): report PTY progress verbosely --------- Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --- docs/cli/migrate.md | 19 +++++ docs/plugins/codex-harness-reference.md | 25 ++++-- docs/providers/openai.md | 10 +++ .../src/app-server/attempt-startup.test.ts | 23 ++++++ .../codex/src/app-server/attempt-startup.ts | 1 + .../codex/src/app-server/auth-bridge.test.ts | 82 +++++++++++++++++++ .../codex/src/app-server/auth-bridge.ts | 69 ++++++++++++++-- .../src/app-server/shared-client.test.ts | 3 + .../codex/src/app-server/shared-client.ts | 3 + .../codex/src/migration/provider.test.ts | 15 ++-- src/cli/program/register.migrate.test.ts | 72 ++++++++++++++++ src/cli/program/register.migrate.ts | 75 +++++++++++++---- src/commands/migrate.ts | 7 +- src/commands/migrate/context.test.ts | 22 ++++- src/commands/migrate/context.ts | 27 +++++- src/commands/migrate/item-selection.test.ts | 46 +++++++++++ src/commands/migrate/types.ts | 2 +- test/vitest/vitest.tui-pty.config.ts | 1 + 18 files changed, 464 insertions(+), 38 deletions(-) create mode 100644 src/cli/program/register.migrate.test.ts create mode 100644 src/commands/migrate/item-selection.test.ts diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index e8cb2bf67191..3b6917fef662 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -47,6 +47,9 @@ Running `openclaw migrate ` with no other flags plans, previews, and ( Override the source state directory. Hermes follows `$HERMES_HOME` and the active profile, then uses the platform default (`~/.hermes` or `%LOCALAPPDATA%\hermes`). Codex defaults to `~/.codex` (or `$CODEX_HOME`), Claude defaults to `~/.claude`. + + Import into a configured agent. Omit this only when the configured default agent is the intended owner. Invalid and unknown agent IDs are rejected. + 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. @@ -65,6 +68,9 @@ Running `openclaw migrate ` with no other flags plans, previews, and ( Select one Codex plugin install item by plugin name or item id. Repeat the flag to migrate multiple Codex plugins. When omitted, interactive Codex migrations show a native Codex plugin checkbox selector and non-interactive migrations keep all planned plugins. Applies only to source-installed `openai-curated` Codex plugins discovered by the Codex app-server inventory. + + Select one exact migration item by its plan ID. Repeat the flag to migrate multiple items. For example, `--item auth:openai` limits a Codex migration to the detected OpenAI credential item. + Codex only. Forces a fresh source Codex app-server `app/installed` snapshot read before planning native plugin activation. Off by default to keep migration planning fast. @@ -132,6 +138,16 @@ The bundled Codex provider detects Codex CLI state at `~/.codex` by default, or Use this provider when moving to the OpenClaw Codex harness and you want to promote useful personal Codex CLI assets deliberately. Local Codex app-server launches use a per-agent `CODEX_HOME`, so they do not read your personal `~/.codex` by default. The normal process `HOME` is still inherited, so Codex can see shared `$HOME/.agents/*` skills/plugin marketplace entries and subprocesses can find user-home config and tokens. +Codex `auth.json` credentials are sensitive migration inputs. The default +agent-scoped runtime does not consume a copied or mounted `auth.json` directly; +import those credentials into the owning agent's OpenClaw auth store explicitly. +Replace `` with that configured agent's ID: + +```bash +openclaw migrate plan codex --from --agent --include-secrets --item auth:openai +openclaw migrate apply codex --from --agent --include-secrets --item auth:openai --yes +``` + Running `openclaw migrate codex` in an interactive terminal previews the full plan, then opens checkbox selectors before the final apply confirmation. Skill copy items are prompted first. Use `Toggle all on` or `Toggle all off` for bulk selection. Press Space to toggle rows, or Enter to activate the highlighted row and continue. Planned skills start checked, conflict skills start unchecked, and `Skip for now` skips skill copies for this run while still continuing to plugin selection. When source-installed curated Codex plugins are migratable and `--plugin` was not supplied, migration then prompts for native Codex plugin activation by plugin name. Plugin items start checked unless the target OpenClaw Codex plugin config already has that plugin. Existing target plugins start unchecked and show a conflict hint such as `conflict: plugin exists`; choose `Toggle all off` to migrate no native Codex plugins in that run, or `Skip for now` to stop before applying. For scripted or exact runs, select one or more skills or plugins explicitly: @@ -145,6 +161,9 @@ openclaw migrate apply codex --yes --plugin google-calendar ### What Codex imports +- ChatGPT OAuth or OpenAI API-key credentials from `$CODEX_HOME/auth.json`, + imported into the agent's OpenClaw auth store only when `--include-secrets` + is set. - Consolidated Codex `MEMORY.md` and `memory_summary.md` from `$CODEX_HOME/memories`, copied under `memory/imports/codex/` for indexed recall. Raw rollout memory is not imported. diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index fe6938745b59..cb011cadd703 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -418,13 +418,17 @@ rejects that combination. ## Auth and environment isolation -In the default per-agent home, auth is selected in this order: +In the default per-agent home, managed stdio launches use Codex's ephemeral +credential store. OpenClaw supplies auth in this order: -1. An explicit OpenClaw Codex auth profile for the agent. -2. The app-server's existing account in that agent's Codex home. -3. For local stdio app-server launches only, `CODEX_API_KEY`, then - `OPENAI_API_KEY`, when no app-server account is present and OpenAI auth is - still required. +1. An explicit or ordered OpenClaw auth profile for the agent. +2. For an API-key route only, a prepared key or local stdio fallback from + `CODEX_API_KEY`, then `OPENAI_API_KEY`. + +The managed app-server does not read an existing `codex-home/auth.json` in +this mode. Import that file explicitly as described below. Set +`appServer.homeScope: "user"` only when the app-server should instead own and +use the operator's native Codex account. When OpenClaw sees a ChatGPT subscription-style Codex auth profile (OAuth or token credential type), it removes `CODEX_API_KEY` and `OPENAI_API_KEY` from @@ -485,6 +489,15 @@ openclaw migrate codex --dry-run openclaw migrate apply codex --yes ``` +Credentials need the sensitive migration path because the default agent scope +does not consume a copied or mounted `codex-home/auth.json` directly. Replace +`` with the configured agent that owns this Codex home: + +```bash +openclaw migrate plan codex --from --agent --include-secrets --item auth:openai +openclaw migrate apply codex --from --agent --include-secrets --item auth:openai --yes +``` + If a deployment needs additional environment isolation, add those variables to `appServer.clearEnv`: diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 1adf890822f3..8d9c58c5331a 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -569,6 +569,16 @@ account-based. OpenClaw selects auth in this order: 3. For local stdio app-server launches only, and only when the app-server reports no account: `CODEX_API_KEY`, then `OPENAI_API_KEY`. +The default per-agent `codex-home/auth.json` is not a runtime auth store. If +you copied or mounted Codex CLI credentials there, import them into the agent's +OpenClaw auth store before starting a native Codex turn. Replace `` +with the configured agent that owns this Codex home: + +```bash +openclaw migrate plan codex --from --agent --include-secrets --item auth:openai +openclaw migrate apply codex --from --agent --include-secrets --item auth:openai --yes +``` + A local ChatGPT/Codex subscription sign-in is not replaced just because the gateway process also has `OPENAI_API_KEY` for direct OpenAI models or embeddings. The env API-key fallback applies only to the local stdio no-account diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index 3fd434c01aec..d64d15750561 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -290,6 +290,29 @@ describe("startCodexAttemptThread", () => { expect(harness.process.stdin.destroyed).toBe(true); }); + it("carries the session agent id into the startup client factory", async () => { + const clientFactory = vi.fn( + async (options: Parameters[0]) => + await getLeasedSharedCodexAppServerClient(options), + ); + const { harness, run } = startThreadWithHarness(5_000, new AbortController().signal, { + attemptClientFactory: () => clientFactory, + }); + await answerInitialize(harness); + const threadStart = await waitForThreadStart(harness); + harness.send({ + id: threadStart.id, + error: { code: -32000, message: "stop after startup" }, + }); + + await expect(run).rejects.toThrow("stop after startup"); + expect(clientFactory).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent-1", + }), + ); + }); + it("rejects an expected artifact mismatch before any native thread request", async () => { const paths = createAttemptPaths(); await fs.mkdir(paths.workspaceDir, { recursive: true }); diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index f348bc7b9169..1d3fac961262 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -242,6 +242,7 @@ export async function startCodexAttemptThread(params: { : {}), } : {}), + agentId: params.sessionAgentId, agentDir: params.agentDir, config: params.config, onStartedClient: (client) => { diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index fbcae8fc4383..3019b09624af 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -253,6 +253,86 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it.each(["subscription", "api-key"] as const)( + "rejects an unimported agent-scoped Codex auth file for a %s route without fallback", + async (authRequirement) => { + await withTempDir("openclaw-codex-unimported-auth-", async (agentDir) => { + const codexHome = resolveCodexAppServerHomeDir(agentDir); + await writeCodexCliAuthFile(codexHome); + vi.stubEnv("CODEX_API_KEY", ""); + vi.stubEnv("OPENAI_API_KEY", ""); + + await expect( + bridgeCodexAppServerStartOptions({ + startOptions: createStartOptions(), + agentDir, + agentId: "research", + authRequirement, + }), + ).rejects.toMatchObject({ + name: "AgentHarnessPreflightError", + message: expect.stringContaining( + "openclaw migrate apply codex --from --agent research --include-secrets --item auth:openai --yes", + ), + }); + }); + }, + ); + + it.each(["CODEX_API_KEY", "OPENAI_API_KEY"] as const)( + "preserves the %s fallback when a stale agent auth file remains", + async (envVar) => { + await withTempDir("openclaw-codex-stale-auth-api-key-", async (agentDir) => { + await writeCodexCliAuthFile(resolveCodexAppServerHomeDir(agentDir)); + vi.stubEnv("CODEX_API_KEY", ""); + vi.stubEnv("OPENAI_API_KEY", ""); + vi.stubEnv(envVar, "platform-api-key"); + + const startOptions = await bridgeCodexAppServerStartOptions({ + startOptions: createStartOptions(), + agentDir, + agentId: "research", + authRequirement: "api-key", + }); + expect(startOptions).toMatchObject({ + args: EPHEMERAL_AUTH_ARGS, + env: { CODEX_HOME: resolveCodexAppServerHomeDir(agentDir) }, + }); + + const request = vi.fn(async (method: string) => + method === "account/read" + ? { account: null, requiresOpenaiAuth: true } + : { type: "apiKey" }, + ); + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authRequirement: "api-key", + startOptions, + }); + expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false }); + expect(request).toHaveBeenNthCalledWith(2, "account/login/start", { + type: "apiKey", + apiKey: "platform-api-key", + }); + }); + }, + ); + + it.each(["websocket", "unix"] as const)( + "ignores an agent-scoped auth file for %s transports", + async (transport) => { + await withTempDir("openclaw-codex-remote-auth-", async (agentDir) => { + await writeCodexCliAuthFile(resolveCodexAppServerHomeDir(agentDir)); + const startOptions = createStartOptions({ transport }); + + await expect( + bridgeCodexAppServerStartOptions({ startOptions, agentDir, agentId: "research" }), + ).resolves.toBe(startOptions); + }); + }, + ); + it("provisions the native Computer Use client before auto-install startup", async () => { await withTempDir("openclaw-codex-computer-use-service-", async (agentDir) => { const startOptions = createStartOptions(); @@ -357,6 +437,7 @@ describe("bridgeCodexAppServerStartOptions", () => { commandSource: "config", args: [], }); + await writeCodexCliAuthFile(resolveCodexAppServerHomeDir(agentDir)); const bridged = await bridgeCodexAppServerStartOptions({ startOptions, agentDir }); @@ -478,6 +559,7 @@ describe("bridgeCodexAppServerStartOptions", () => { accountId: "account-123", }, }); + await writeCodexCliAuthFile(resolveCodexAppServerHomeDir(agentDir)); await expect( bridgeCodexAppServerStartOptions({ diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 2c904560fd01..18ae489ee738 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -68,29 +68,34 @@ const scopedOAuthRefreshQueues = new WeakMap< export async function bridgeCodexAppServerStartOptions(params: { startOptions: CodexAppServerStartOptions; + agentId?: string; agentDir: string; authProfileId?: string | null; authProfileStore?: AuthProfileStore; preparedAuth?: CodexAppServerPreparedAuth; + authRequirement?: CodexAppServerAuthRequirement; config?: AuthProfileOrderConfig; pluginConfig?: unknown; }): Promise { if (params.startOptions.transport !== "stdio") { return params.startOptions; } - const scopedStartOptions = await withCodexHomeEnvironment( - withEphemeralCodexAuthStore(params), - params.agentDir, - params.pluginConfig, - ); + const scopeStartOptions = () => + withCodexHomeEnvironment( + withEphemeralCodexAuthStore(params), + params.agentDir, + params.pluginConfig, + ); + if (params.preparedAuth) { + const scopedStartOptions = await scopeStartOptions(); return withClearedEnvironmentVariables( scopedStartOptions, CODEX_APP_SERVER_PREPARED_AUTH_ENV_VARS, ); } if (params.authProfileId === null) { - return scopedStartOptions; + return scopeStartOptions(); } const store = resolveCodexAppServerAuthProfileStore({ agentDir: params.agentDir, @@ -103,6 +108,11 @@ export async function bridgeCodexAppServerStartOptions(params: { store, config: params.config, }); + if (!authProfileId) { + assertNoUnimportedAgentCodexAuthFile(params); + } + + const scopedStartOptions = await scopeStartOptions(); const shouldClearInheritedOpenAiApiKey = shouldClearOpenAiApiKeyForCodexAuthProfile({ store, authProfileId, @@ -112,6 +122,53 @@ export async function bridgeCodexAppServerStartOptions(params: { : scopedStartOptions; } +function assertNoUnimportedAgentCodexAuthFile(params: { + startOptions: CodexAppServerStartOptions; + agentId?: string; + agentDir: string; + authRequirement?: CodexAppServerAuthRequirement; +}): void { + // Ephemeral managed starts cannot load this stale file, and the shared-client key + // separates auth requirements plus fallback identities. Preserve the supported + // stdio API-key login instead of turning a leftover file into a hard failure. + if ( + params.authRequirement === "api-key" && + resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions: params.startOptions }) + ) { + return; + } + const message = resolveUnimportedAgentCodexAuthMessage(params); + if (message) { + throw new AgentHarnessPreflightError(message); + } +} + +function resolveUnimportedAgentCodexAuthMessage(params: { + startOptions: CodexAppServerStartOptions; + agentId?: string; + agentDir: string; +}): string | undefined { + const managedCodexCli = + params.startOptions.commandSource === "managed" || + params.startOptions.commandSource === "resolved-managed"; + if ( + params.startOptions.transport !== "stdio" || + !managedCodexCli || + params.startOptions.homeScope === "user" + ) { + return undefined; + } + const codexHome = resolveCodexAppServerHomeDir(params.agentDir); + const authPath = path.join(codexHome, CODEX_AUTH_JSON_FILENAME); + // Managed starts force ephemeral Codex auth, so this file would otherwise be + // ignored and the operator would receive only the downstream authentication error. + if (!fsSync.existsSync(authPath)) { + return undefined; + } + const targetAgentId = params.agentId?.trim() || ""; + return `A Codex auth file exists at ${authPath}, but agent-scoped Codex runs use OpenClaw's auth store and do not read that file. Preview only that credential import with \`openclaw migrate plan codex --from --agent ${targetAgentId} --include-secrets --item auth:openai\`, then run \`openclaw migrate apply codex --from --agent ${targetAgentId} --include-secrets --item auth:openai --yes\`. If the plan finds no credentials, remove the stale auth file.`; +} + export function resolveCodexAppServerAuthProfileId(params: { authProfileId?: string; store: ReturnType; diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index c14e6432777f..1b18040e7518 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -129,6 +129,7 @@ function firstMockArg(mock: unknown, label: string): unknown { function bridgeStartOptionsCall() { return firstMockArg(mocks.bridgeCodexAppServerStartOptions, "bridge start options") as { agentDir?: string; + agentId?: string; authProfileId?: string; authProfileStore?: unknown; preparedAuth?: @@ -1282,6 +1283,7 @@ describe("shared Codex app-server client", () => { timeoutMs: 1000, authProfileId: null, agentDir: "/tmp/openclaw-target-agent", + agentId: "research", config, }); await sendInitializeResult(harness, "openclaw/0.146.0 (macOS; test)"); @@ -1290,6 +1292,7 @@ describe("shared Codex app-server client", () => { expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled(); const bridgeCall = bridgeStartOptionsCall(); expect(bridgeCall.agentDir).toBe("/tmp/openclaw-target-agent"); + expect(bridgeCall.agentId).toBe("research"); expect(bridgeCall.authProfileId).toBeNull(); expect(bridgeCall.config).toBe(config); const applyCall = applyAuthProfileCall(); diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 7f45285c10dd..a23f88bbf974 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -265,6 +265,7 @@ export type CodexAppServerClientOptions = { expectedRuntimeArtifact?: AgentHarnessRuntimeArtifactBinding; preparedAuth?: CodexAppServerPreparedAuth; authRequirement?: CodexAppServerAuthRequirement; + agentId?: string; agentDir?: string; config?: Parameters[0]["config"]; onStartedClient?: (client: CodexAppServerClient) => void; @@ -388,9 +389,11 @@ async function resolveCodexAppServerClientStartContext( const managedStartOptions = await resolveManagedCodexAppServerStartOptions(agentStartOptions); const startOptions = await bridgeCodexAppServerStartOptions({ startOptions: managedStartOptions, + agentId: options?.agentId, agentDir, authProfileId: usesNativeAuth || preparedAuth?.kind === "api-key" ? null : authProfileId, ...(resolvedPreparedAuth ? { preparedAuth: resolvedPreparedAuth } : {}), + authRequirement, config: options?.config, pluginConfig: options?.pluginConfig, ...(authProfileStore ? { authProfileStore } : {}), diff --git a/extensions/codex/src/migration/provider.test.ts b/extensions/codex/src/migration/provider.test.ts index 7f6fab8f1698..58f5a2e03eec 100644 --- a/extensions/codex/src/migration/provider.test.ts +++ b/extensions/codex/src/migration/provider.test.ts @@ -124,12 +124,12 @@ function mockCallArg(mock: ReturnType, callIndex = 0, argIndex = 0 return call[argIndex]; } -function targetAgentDir(fixture: { stateDir: string }): string { - return path.join(fixture.stateDir, "agents", "main", "agent"); +function targetAgentDir(fixture: { stateDir: string }, agentId = "main"): string { + return path.join(fixture.stateDir, "agents", agentId, "agent"); } -function loadTargetAuthStore(fixture: { stateDir: string }) { - return loadAuthProfileStoreForSecretsRuntime(targetAgentDir(fixture)); +function loadTargetAuthStore(fixture: { stateDir: string }, agentId = "main") { + return loadAuthProfileStoreForSecretsRuntime(targetAgentDir(fixture, agentId)); } async function createCodexFixture(): Promise<{ @@ -799,7 +799,7 @@ describe("buildCodexMigrationProvider", () => { expect(sourceAppServerClientScope).toHaveBeenCalledTimes(1); }); - it("imports Codex auth.json OAuth and seeds cached OpenAI Codex models", async () => { + it("imports Codex auth.json OAuth into the selected agent and seeds cached models", async () => { const fixture = await createCodexFixture(); const reportDir = path.join(fixture.root, "report"); const configState: MigrationProviderContext["config"] = { @@ -808,6 +808,7 @@ describe("buildCodexMigrationProvider", () => { model: { fallbacks: [] }, workspace: fixture.workspaceDir, }, + list: [{ id: "main", default: true }, { id: "research" }], }, } as MigrationProviderContext["config"]; const accessToken = fakeJwt({ @@ -857,6 +858,7 @@ describe("buildCodexMigrationProvider", () => { runtime: createConfigRuntime(configState), reportDir, includeSecrets: true, + targetAgentId: "research", }); const plan = await provider.plan(ctx); expectRecordFields(findItem(plan.items, "auth:openai"), { @@ -868,7 +870,7 @@ describe("buildCodexMigrationProvider", () => { const result = await provider.apply(ctx, plan); expectRecordFields(findItem(result.items, "auth:openai"), { status: "migrated" }); - const authStore = loadTargetAuthStore(fixture); + const authStore = loadTargetAuthStore(fixture, "research"); expect(authStore.profiles?.["openai:account-acct_test"]).toEqual( expect.objectContaining({ type: "oauth", @@ -877,6 +879,7 @@ describe("buildCodexMigrationProvider", () => { refresh: "refresh-test-token", }), ); + expect(loadTargetAuthStore(fixture).profiles?.["openai:account-acct_test"]).toBeUndefined(); expect(configState.auth?.profiles?.["openai:account-acct_test"]).toEqual( expect.objectContaining({ provider: "openai", diff --git a/src/cli/program/register.migrate.test.ts b/src/cli/program/register.migrate.test.ts new file mode 100644 index 000000000000..944db7072bad --- /dev/null +++ b/src/cli/program/register.migrate.test.ts @@ -0,0 +1,72 @@ +// Migrate CLI registration tests cover public option forwarding. +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerMigrateCommand } from "./register.migrate.js"; + +const mocks = vi.hoisted(() => ({ + migrateApplyCommand: vi.fn(), + migrateDefaultCommand: vi.fn(), + migrateListCommand: vi.fn(), + migratePlanCommand: vi.fn(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, +})); + +vi.mock("../../commands/migrate.js", () => ({ + migrateApplyCommand: mocks.migrateApplyCommand, + migrateDefaultCommand: mocks.migrateDefaultCommand, + migrateListCommand: mocks.migrateListCommand, + migratePlanCommand: mocks.migratePlanCommand, +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: mocks.runtime, +})); + +async function runCli(args: string[]): Promise { + const program = new Command(); + registerMigrateCommand(program); + await program.parseAsync(args, { from: "user" }); +} + +describe("registerMigrateCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.migrateApplyCommand.mockResolvedValue(undefined); + mocks.migrateDefaultCommand.mockResolvedValue(undefined); + mocks.migrateListCommand.mockResolvedValue(undefined); + mocks.migratePlanCommand.mockResolvedValue(undefined); + }); + + it("forwards --agent through default, plan, and apply flows", async () => { + await runCli(["migrate", "codex", "--agent", "research", "--item", "auth:openai", "--dry-run"]); + expect(mocks.migrateDefaultCommand).toHaveBeenCalledWith( + mocks.runtime, + expect.objectContaining({ targetAgentId: "research", itemIds: ["auth:openai"] }), + ); + + await runCli(["migrate", "plan", "codex", "--agent", "research", "--item", "auth:openai"]); + expect(mocks.migratePlanCommand).toHaveBeenCalledWith( + mocks.runtime, + expect.objectContaining({ targetAgentId: "research", itemIds: ["auth:openai"] }), + ); + + await runCli([ + "migrate", + "apply", + "codex", + "--agent", + "research", + "--item", + "auth:openai", + "--yes", + ]); + expect(mocks.migrateApplyCommand).toHaveBeenCalledWith( + mocks.runtime, + expect.objectContaining({ targetAgentId: "research", itemIds: ["auth:openai"] }), + ); + }); +}); diff --git a/src/cli/program/register.migrate.ts b/src/cli/program/register.migrate.ts index 487221cb54eb..97c60b8efef2 100644 --- a/src/cli/program/register.migrate.ts +++ b/src/cli/program/register.migrate.ts @@ -19,6 +19,10 @@ function collectMigrationPlugin(value: string, previous: string[] | undefined): return [...(previous ?? []), value]; } +function collectMigrationItem(value: string, previous: string[] | undefined): string[] { + return [...(previous ?? []), value]; +} + function readMigrationSkills(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -41,6 +45,18 @@ function readMigrationPlugins(value: unknown): string[] | undefined { return plugins.length > 0 ? plugins : undefined; } +function readMigrationItems(value: unknown, command?: Command): string[] | undefined { + const selected = Array.isArray(value) ? value : command?.parent?.opts().item; + if (!Array.isArray(selected)) { + return undefined; + } + const items = selected + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return items.length > 0 ? items : undefined; +} + function addMigrationSkillOption(command: Command): Command { return command.option( "--skill ", @@ -65,20 +81,31 @@ function addVerifyPluginAppsOption(command: Command): Command { ); } +function addMigrationItemOption(command: Command): Command { + return command.option( + "--item ", + "Select one exact migration item id; repeat for multiple items", + collectMigrationItem, + ); +} + function addMigrationOptions(command: Command): Command { return addVerifyPluginAppsOption( - addMigrationPluginOption( - addMigrationSkillOption( - command - .option("--from ", "Source directory to migrate from") - .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("--json", "Output JSON", false), + addMigrationItemOption( + addMigrationPluginOption( + addMigrationSkillOption( + command + .option("--from ", "Source directory to migrate from") + .option("--agent ", "Target agent (default: configured default agent)") + .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("--json", "Output JSON", false), + ), ), ), ); @@ -88,6 +115,14 @@ function readVerifyPluginApps(value: unknown): boolean { return value === true; } +function readMigrationTargetAgentId(value: unknown, command: Command): string | undefined { + if (typeof value === "string") { + return value; + } + const parentValue = command.parent?.opts().agent; + return typeof parentValue === "string" ? parentValue : undefined; +} + /** Register migration commands and shared provider/item selection flags. */ export function registerMigrateCommand(program: Command) { const migrate = addVerifyPluginAppsOption( @@ -96,6 +131,7 @@ 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("--agent ", "Target agent (default: configured default agent)") .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) @@ -111,6 +147,11 @@ export function registerMigrateCommand(program: Command) { "Select one Codex plugin to migrate by name or item id; repeat for multiple plugins", collectMigrationPlugin, ) + .option( + "--item ", + "Select one exact migration item id; repeat for multiple items", + collectMigrationItem, + ) .option("--backup-output ", "Pre-migration backup archive path or directory") .option("--no-backup", "Skip the pre-migration OpenClaw backup") .option("--force", "Allow dangerous options such as --no-backup", false) @@ -138,11 +179,13 @@ export function registerMigrateCommand(program: Command) { await migrateDefaultCommand(defaultRuntime, { provider: provider as string | undefined, source: opts.from as string | undefined, + targetAgentId: opts.agent as string | undefined, includeSecrets: opts.includeSecrets === true ? true : undefined, authCredentials: opts.authCredentials as boolean | undefined, overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), plugins: readMigrationPlugins(opts.plugin), + itemIds: readMigrationItems(opts.item), verifyPluginApps: readVerifyPluginApps(opts.verifyPluginApps), dryRun: Boolean(opts.dryRun), yes: Boolean(opts.yes), @@ -168,16 +211,18 @@ export function registerMigrateCommand(program: Command) { migrate .command("plan ") .description("Preview a migration without changing OpenClaw state"), - ).action(async (provider, opts) => { + ).action(async (provider, opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { await migratePlanCommand(defaultRuntime, { provider: provider as string, source: opts.from as string | undefined, + targetAgentId: readMigrationTargetAgentId(opts.agent, command), includeSecrets: opts.includeSecrets === true ? true : undefined, authCredentials: opts.authCredentials as boolean | undefined, overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), plugins: readMigrationPlugins(opts.plugin), + itemIds: readMigrationItems(opts.item, command), verifyPluginApps: readVerifyPluginApps(opts.verifyPluginApps), json: Boolean(opts.json), }); @@ -191,16 +236,18 @@ export function registerMigrateCommand(program: Command) { .option("--backup-output ", "Pre-migration backup archive path or directory") .option("--no-backup", "Skip the pre-migration OpenClaw backup") .option("--force", "Allow dangerous options such as --no-backup", false) - .action(async (provider, opts) => { + .action(async (provider, opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { await migrateApplyCommand(defaultRuntime, { provider: provider as string, source: opts.from as string | undefined, + targetAgentId: readMigrationTargetAgentId(opts.agent, command), includeSecrets: opts.includeSecrets === true ? true : undefined, authCredentials: opts.authCredentials as boolean | undefined, overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), plugins: readMigrationPlugins(opts.plugin), + itemIds: readMigrationItems(opts.item, command), verifyPluginApps: readVerifyPluginApps(opts.verifyPluginApps), yes: Boolean(opts.yes), backupOutput: opts.backupOutput as string | undefined, diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 30f4ff5512f6..d1019840c68d 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -18,6 +18,7 @@ import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { writeRuntimeJson } from "../runtime.js"; import { runMigrationApply } from "./migrate/apply.js"; +import { applyMigrationItemSelection } from "./migrate/item-selection.js"; import { formatMigrationPreview } from "./migrate/output.js"; import { createMigrationPlan, resolveMigrationProvider } from "./migrate/providers.js"; import { @@ -49,9 +50,9 @@ import type { } from "./migrate/types.js"; function selectMigrationItems(plan: MigrationPlan, opts: MigrateCommonOptions): MigrationPlan { - return applyMigrationPluginSelection( - applyMigrationSkillSelection(plan, opts.skills), - opts.plugins, + return applyMigrationItemSelection( + applyMigrationPluginSelection(applyMigrationSkillSelection(plan, opts.skills), opts.plugins), + opts.itemIds, ); } diff --git a/src/commands/migrate/context.test.ts b/src/commands/migrate/context.test.ts index b02bc65360cf..042dca852660 100644 --- a/src/commands/migrate/context.test.ts +++ b/src/commands/migrate/context.test.ts @@ -1,7 +1,7 @@ // Migration context tests cover report directory naming and timestamp fallback behavior. import path from "node:path"; import { describe, expect, it } from "vitest"; -import { buildMigrationReportDir } from "./context.js"; +import { buildMigrationReportDir, resolveMigrationTargetAgentId } from "./context.js"; describe("migration context helpers", () => { it("builds report directories with filename-safe timestamps", () => { @@ -16,4 +16,24 @@ describe("migration context helpers", () => { /[/\\]migration[/\\]codex[/\\]\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z$/, ); }); + + it("normalizes and validates an explicit migration target agent", () => { + const config = { + agents: { + list: [{ id: "main", default: true }, { id: "research" }], + }, + }; + + expect(resolveMigrationTargetAgentId(config, "Research")).toBe("research"); + expect(() => resolveMigrationTargetAgentId(config, "research/../main")).toThrow( + 'Invalid agent id "research/../main"', + ); + expect(() => resolveMigrationTargetAgentId(config, "missing")).toThrow( + 'Unknown agent id "missing"', + ); + }); + + it("keeps the configured default when no migration target is supplied", () => { + expect(resolveMigrationTargetAgentId({}, undefined)).toBeUndefined(); + }); }); diff --git a/src/commands/migrate/context.ts b/src/commands/migrate/context.ts index 0dd2a0f4d917..ed1d19e6576e 100644 --- a/src/commands/migrate/context.ts +++ b/src/commands/migrate/context.ts @@ -1,6 +1,9 @@ /** Migration provider context and report-directory helpers. */ import path from "node:path"; +import { isValidAgentId, normalizeAgentId } from "@openclaw/normalization-core/agent-id"; import { timestampMsToIsoFileStamp } from "@openclaw/normalization-core/number-coercion"; +import { listAgentIds } from "../../agents/agent-scope.js"; +import { formatCliCommand } from "../../cli/command-format.js"; import { getRuntimeConfig } from "../../config/config.js"; import { resolveStateDir } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -32,6 +35,28 @@ export function buildMigrationReportDir( return path.join(stateDir, "migration", providerId, stamp); } +/** Resolves an explicit migration owner without allowing typo-created agent stores. */ +export function resolveMigrationTargetAgentId( + config: OpenClawConfig, + rawAgentId: string | undefined, +): string | undefined { + const raw = rawAgentId?.trim(); + if (!raw) { + return undefined; + } + if (!isValidAgentId(raw)) { + throw new Error(`Invalid agent id "${raw}".`); + } + const agentId = normalizeAgentId(raw); + const knownAgentIds = new Set(listAgentIds(config).map(normalizeAgentId)); + if (!knownAgentIds.has(agentId)) { + throw new Error( + `Unknown agent id "${raw}". Use "${formatCliCommand("openclaw agents list")}" to see configured agents.`, + ); + } + return agentId; +} + /** Builds the provider-facing migration context from CLI options and runtime state. */ export function buildMigrationContext(params: { source?: string; @@ -51,7 +76,7 @@ export function buildMigrationContext(params: { return { config, stateDir, - targetAgentId: params.targetAgentId, + targetAgentId: resolveMigrationTargetAgentId(config, params.targetAgentId), itemKinds: params.itemKinds, source: params.source, includeSecrets: Boolean(params.includeSecrets), diff --git a/src/commands/migrate/item-selection.test.ts b/src/commands/migrate/item-selection.test.ts new file mode 100644 index 000000000000..7263efa3f36f --- /dev/null +++ b/src/commands/migrate/item-selection.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import type { MigrationPlan } from "../../plugins/types.js"; +import { applyMigrationItemSelection } from "./item-selection.js"; + +function plan(): MigrationPlan { + const items: MigrationPlan["items"] = [ + { id: "auth:openai", kind: "auth", action: "copy", status: "planned", sensitive: true }, + { id: "memory:codex", kind: "memory", action: "copy", status: "planned" }, + ]; + return { + providerId: "codex", + source: "/tmp/codex-home", + items, + summary: { + total: 2, + planned: 2, + migrated: 0, + skipped: 0, + conflicts: 0, + errors: 0, + sensitive: 1, + }, + }; +} + +describe("applyMigrationItemSelection", () => { + it("limits a plan to the exact selected auth item", () => { + const selected = applyMigrationItemSelection(plan(), ["auth:openai"]); + + expect(selected.items).toEqual([ + expect.objectContaining({ id: "auth:openai", status: "planned" }), + expect.objectContaining({ + id: "memory:codex", + status: "skipped", + reason: "not selected for migration", + }), + ]); + expect(selected.summary).toMatchObject({ planned: 1, skipped: 1 }); + }); + + it("rejects an unavailable item instead of widening the migration", () => { + expect(() => applyMigrationItemSelection(plan(), ["auth:missing"])).toThrow( + 'Unknown or unavailable migration item ids: "auth:missing".', + ); + }); +}); diff --git a/src/commands/migrate/types.ts b/src/commands/migrate/types.ts index a1811cf1f8d5..873f83f01d0c 100644 --- a/src/commands/migrate/types.ts +++ b/src/commands/migrate/types.ts @@ -31,7 +31,7 @@ export type MigrateCommonOptions = { targetAgentId?: string; // Internal embedded scope. Providers may skip unrelated discovery when this is set. itemKinds?: string[]; - // Internal exact item selection used by reviewed UI migration plans. + // Exact item selection used by reviewed UI plans and the standalone CLI. itemIds?: string[]; }; diff --git a/test/vitest/vitest.tui-pty.config.ts b/test/vitest/vitest.tui-pty.config.ts index 5df35696073f..e153462864bd 100644 --- a/test/vitest/vitest.tui-pty.config.ts +++ b/test/vitest/vitest.tui-pty.config.ts @@ -43,6 +43,7 @@ function createTuiPtyVitestConfig(env?: Record) { exclude, fileParallelism: false, maxWorkers: 1, + reporters: ["verbose"], setupFiles: [ ...new Set( [...(baseTest.setupFiles ?? []), "test/setup-openclaw-runtime.ts"].map(