feat(onboard): offer codex migration after harness install (#81192)

Add a post-install seam so the wizard can prompt the user to import their
existing Codex CLI state (skills, archived config/hooks, advisory cached
plugins) through the existing `openclaw migrate codex` flow once the
harness plugin is in place. Fires on both fresh installs and repair runs;
the user can decline at any time.

Trigger sites, both routing through one helper:

- src/plugins/provider-auth-choice.ts: after
  `ensureCodexRuntimePluginForModelSelection` reports `installed: true`,
  dynamically import `offerPostInstallMigrations` and call it before the
  wizard moves on.
- src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts:
  same call shape with `nonInteractive: true`, so the helper emits a hint
  line only and never mutates state.

Helper (src/wizard/setup.post-install-migration.ts) is generic, not
Codex-hardcoded — it resolves migration providers via the manifest
`migrationProviders` contract, filters to providers owned by plugins the
caller flags as installed in this onboarding step, runs `provider.detect`,
and on TTY hands accepted runs to `migrateDefaultCommand`. All detect,
prompt, and migrate failures are swallowed so onboarding never aborts on
this optional offer.

Also harden the Codex app-server subprocess lifecycle now that `detect()`
runs from a hotter onboarding path: isolate the plugin-install
`plugin/read` call (extensions/codex/src/migration/apply.ts) and have the
isolated request wait for child exit with a SIGKILL fallback
(extensions/codex/src/app-server/request.ts) so parents are not held open
by an orphaned codex binary.

Tests:

- src/wizard/setup.post-install-migration.test.ts (new, 10 cases)
- src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts
  extended with hint-call assertions and a not-required-no-offer case.
This commit is contained in:
Sarah Fortune
2026-05-12 16:51:27 -07:00
committed by GitHub
parent 37237a5129
commit 48529f1a96
8 changed files with 505 additions and 22 deletions

View File

@@ -58,7 +58,12 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
} finally {
if (params.isolated) {
client.close();
// Wait for the child to actually exit (with a SIGKILL fallback) so
// the parent process doesn't hang on an orphaned codex app-server.
// The stdio bin shim does not always propagate stdin EOF to the
// underlying codex binary, so the unref'd close() path can leave
// the child running and keep the parent's event loop alive.
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
}
}
})(),

View File

@@ -126,6 +126,7 @@ async function applyCodexPluginInstallItem(
startOptions: appServer.start,
agentDir: resolveCodexMigrationTargets(ctx).agentDir,
config: ctx.config,
isolated: true,
}),
appCache: defaultCodexAppInventoryCache,
appCacheKey,

View File

@@ -9,7 +9,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import type { WizardPrompter } from "../wizard/prompts.js";
const CODEX_RUNTIME_PLUGIN_ID = "codex";
export const CODEX_RUNTIME_PLUGIN_ID = "codex";
const CODEX_RUNTIME_PLUGIN_LABEL = "Codex";
const CODEX_RUNTIME_PLUGIN_NPM_SPEC = "@openclaw/codex";
@@ -49,7 +49,11 @@ export async function ensureCodexRuntimePluginForModelSelection(params: {
workspaceDir?: string;
}): Promise<CodexRuntimePluginInstallResult> {
if (!selectedModelShouldEnsureCodexRuntimePlugin({ cfg: params.cfg, model: params.model })) {
return { cfg: params.cfg, required: false, installed: false };
return {
cfg: params.cfg,
required: false,
installed: false,
};
}
const existingRecords = await loadInstalledPluginIndexInstallRecords({ env: process.env });
if (isInstalledRecordPresentOnDisk(existingRecords[CODEX_RUNTIME_PLUGIN_ID], process.env)) {

View File

@@ -13,8 +13,13 @@ const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() =>
),
);
vi.mock("../../codex-runtime-plugin-install.js", () => ({
CODEX_RUNTIME_PLUGIN_ID: "codex",
ensureCodexRuntimePluginForModelSelection,
}));
const offerPostInstallMigrations = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../../../wizard/setup.post-install-migration.js", () => ({
offerPostInstallMigrations,
}));
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../../../plugins/provider-auth-choice-preference.js", () => ({
resolvePreferredProviderForAuthChoice,
@@ -47,6 +52,7 @@ beforeEach(() => {
required: false,
installed: false,
}));
offerPostInstallMigrations.mockClear();
});
function createRuntime() {
@@ -270,5 +276,40 @@ describe("applyNonInteractivePluginProviderChoice", () => {
expect(ensureInput.runtime).toBe(runtime);
expectWorkspaceDir(ensureInput.workspaceDir);
expect(result).toBe(installedConfig);
expect(offerPostInstallMigrations).toHaveBeenCalledOnce();
const migrationInput = mockArg(offerPostInstallMigrations);
expect(migrationInput.config).toBe(installedConfig);
expect(migrationInput.installedPluginIds).toEqual(["codex"]);
expect(migrationInput.nonInteractive).toBe(true);
});
it("does not offer post-install migration when Codex is not required for the selected model", async () => {
const runtime = createRuntime();
const selectedConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
} as OpenClawConfig;
const runNonInteractive = vi.fn(async () => selectedConfig);
ensureCodexRuntimePluginForModelSelection.mockResolvedValue({
cfg: selectedConfig,
required: false,
installed: false,
});
resolvePluginProviders.mockReturnValue([{ id: "openai", pluginId: "openai" }] as never);
resolveProviderPluginChoice.mockReturnValue({
provider: { id: "openai", pluginId: "openai", label: "OpenAI" },
method: { runNonInteractive },
});
await applyNonInteractivePluginProviderChoice({
nextConfig: { agents: { defaults: {} } } as OpenClawConfig,
authChoice: "openai-api-key",
opts: {} as never,
runtime: runtime as never,
baseConfig: { agents: { defaults: {} } } as OpenClawConfig,
resolveApiKey: vi.fn(),
toApiKeyCredential: vi.fn(),
});
expect(offerPostInstallMigrations).not.toHaveBeenCalled();
});
});

View File

@@ -18,7 +18,10 @@ import type {
import type { RuntimeEnv } from "../../../runtime.js";
import { createLazyRuntimeSurface } from "../../../shared/lazy-runtime.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import { ensureCodexRuntimePluginForModelSelection } from "../../codex-runtime-plugin-install.js";
import {
CODEX_RUNTIME_PLUGIN_ID,
ensureCodexRuntimePluginForModelSelection,
} from "../../codex-runtime-plugin-install.js";
import type { OnboardOptions } from "../../onboard-types.js";
const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:";
@@ -201,13 +204,27 @@ export async function applyNonInteractivePluginProviderChoice(params: {
if (!selectedModel) {
return result;
}
return (
await ensureCodexRuntimePluginForModelSelection({
cfg: result,
model: selectedModel,
prompter: createNonInteractivePluginInstallPrompter(params.runtime),
const nonInteractivePrompter = createNonInteractivePluginInstallPrompter(params.runtime);
const codexInstall = await ensureCodexRuntimePluginForModelSelection({
cfg: result,
model: selectedModel,
prompter: nonInteractivePrompter,
runtime: params.runtime,
workspaceDir,
});
if (codexInstall.installed) {
// Non-interactive onboarding never auto-applies migration; emit a hint so
// the operator knows Codex CLI state is available to import deliberately.
// Gated on installed (not freshlyInstalled) so repair runs against an
// already-present harness still surface the hint.
const { offerPostInstallMigrations } =
await import("../../../wizard/setup.post-install-migration.js");
await offerPostInstallMigrations({
config: codexInstall.cfg,
runtime: params.runtime,
workspaceDir,
})
).cfg;
installedPluginIds: [CODEX_RUNTIME_PLUGIN_ID],
nonInteractive: true,
});
}
return codexInstall.cfg;
}

View File

@@ -153,18 +153,32 @@ async function applyDefaultModelFromAuthChoice(params: {
preserveExistingPrimary: params.preserveExistingDefaultModel === true,
});
if (!preservesDifferentPrimary) {
const { ensureCodexRuntimePluginForModelSelection } =
const { CODEX_RUNTIME_PLUGIN_ID, ensureCodexRuntimePluginForModelSelection } =
await import("../commands/codex-runtime-plugin-install.js");
nextConfig = (
await ensureCodexRuntimePluginForModelSelection({
cfg: nextConfig,
model: params.selectedModel,
prompter: params.prompter,
runtime: params.runtime,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
})
).cfg;
const codexInstall = await ensureCodexRuntimePluginForModelSelection({
cfg: nextConfig,
model: params.selectedModel,
prompter: params.prompter,
runtime: params.runtime,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
});
nextConfig = codexInstall.cfg;
await params.runSelectedModelHook(nextConfig);
if (codexInstall.installed) {
// Offer Codex CLI state migration whenever the harness is in place for
// the selected model, regardless of whether this run was a fresh install
// or a repair against an already-present harness. The user can always
// decline the prompt; surfacing it again costs nothing if there is no
// migratable state to find.
const { offerPostInstallMigrations } =
await import("../wizard/setup.post-install-migration.js");
await offerPostInstallMigrations({
config: nextConfig,
runtime: params.runtime,
prompter: params.prompter,
installedPluginIds: [CODEX_RUNTIME_PLUGIN_ID],
});
}
}
await noteDefaultModelResult({
previousPrimary,

View File

@@ -0,0 +1,242 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createNonExitingRuntime } from "../runtime.js";
import type { WizardPrompter } from "./prompts.js";
const ensureStandaloneMigrationProviderRegistryLoaded = vi.hoisted(() => vi.fn());
const resolvePluginMigrationProviders = vi.hoisted(() => vi.fn(() => [] as unknown[]));
vi.mock("../plugins/migration-provider-runtime.js", () => ({
ensureStandaloneMigrationProviderRegistryLoaded,
resolvePluginMigrationProviders,
}));
const resolveManifestContractRuntimePluginResolution = vi.hoisted(() =>
vi.fn((_params: { contract: string; value?: string }) => ({
pluginIds: [] as string[],
bundledCompatPluginIds: [] as string[],
})),
);
vi.mock("../plugins/manifest-contract-runtime.js", () => ({
resolveManifestContractRuntimePluginResolution,
}));
const createMigrationLogger = vi.hoisted(() =>
vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
);
vi.mock("../commands/migrate/context.js", () => ({ createMigrationLogger }));
const resolveStateDir = vi.hoisted(() => vi.fn(() => "/tmp/state"));
vi.mock("../config/paths.js", () => ({ resolveStateDir }));
const migrateDefaultCommand = vi.hoisted(() =>
vi.fn(async (_runtime: unknown, _opts: { provider: string }) => undefined),
);
vi.mock("../commands/migrate.js", () => ({ migrateDefaultCommand }));
import { offerPostInstallMigrations } from "./setup.post-install-migration.js";
type ProviderMock = {
id: string;
label: string;
detect: ReturnType<typeof vi.fn>;
};
function buildProvider(overrides: Partial<ProviderMock> = {}): ProviderMock {
return {
id: "codex",
label: "Codex",
detect: vi.fn(async () => ({ found: true, source: "/home/user/.codex" })),
...overrides,
};
}
function setOwnership(providerId: string, owningPluginIds: string[]): void {
resolveManifestContractRuntimePluginResolution.mockImplementation((params) => {
if (params.value === providerId) {
return { pluginIds: owningPluginIds, bundledCompatPluginIds: [] };
}
return { pluginIds: [], bundledCompatPluginIds: [] };
});
}
function setProviders(providers: ProviderMock[]): void {
resolvePluginMigrationProviders.mockReturnValue(providers as unknown[]);
}
function setTTY(isTTY: boolean): void {
Object.defineProperty(process.stdin, "isTTY", { value: isTTY, configurable: true });
}
function buildBaseArgs(overrides: {
prompter?: WizardPrompter;
installedPluginIds?: readonly string[];
nonInteractive?: boolean;
}) {
return {
config: {} as OpenClawConfig,
runtime: createNonExitingRuntime(),
prompter: overrides.prompter ?? createWizardPrompter(),
installedPluginIds: overrides.installedPluginIds ?? ["codex"],
...(overrides.nonInteractive === undefined ? {} : { nonInteractive: overrides.nonInteractive }),
};
}
describe("offerPostInstallMigrations", () => {
beforeEach(() => {
// clearAllMocks only resets call history; reset the implementations each
// test would customize so prior cases don't leak across this suite.
ensureStandaloneMigrationProviderRegistryLoaded.mockReset();
resolvePluginMigrationProviders.mockReset().mockReturnValue([]);
resolveManifestContractRuntimePluginResolution.mockReset().mockReturnValue({
pluginIds: [],
bundledCompatPluginIds: [],
});
createMigrationLogger.mockReset().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
});
resolveStateDir.mockReset().mockReturnValue("/tmp/state");
migrateDefaultCommand.mockReset().mockResolvedValue(undefined);
setTTY(true);
});
it("returns early when no plugins were installed in this onboarding step", async () => {
await offerPostInstallMigrations(buildBaseArgs({ installedPluginIds: [] }));
expect(resolvePluginMigrationProviders).not.toHaveBeenCalled();
expect(migrateDefaultCommand).not.toHaveBeenCalled();
});
it("skips providers not owned by any plugin in installedPluginIds", async () => {
const provider = buildProvider({ id: "codex" });
setProviders([provider]);
// Ownership map reports the provider lives on the "codex" plugin, but only
// "diagnostics-otel" was installed in this run — so no offer should fire.
setOwnership("codex", ["codex"]);
await offerPostInstallMigrations(buildBaseArgs({ installedPluginIds: ["diagnostics-otel"] }));
expect(provider.detect).not.toHaveBeenCalled();
expect(migrateDefaultCommand).not.toHaveBeenCalled();
});
it("skips providers whose detect reports nothing found", async () => {
const provider = buildProvider({
detect: vi.fn(async () => ({ found: false })),
});
setProviders([provider]);
setOwnership("codex", ["codex"]);
const prompter = createWizardPrompter();
await offerPostInstallMigrations(buildBaseArgs({ prompter }));
expect(provider.detect).toHaveBeenCalledOnce();
expect(prompter.confirm).not.toHaveBeenCalled();
expect(migrateDefaultCommand).not.toHaveBeenCalled();
});
it("skips providers whose detect confidence is low", async () => {
const provider = buildProvider({
detect: vi.fn(async () => ({ found: true, confidence: "low" as const })),
});
setProviders([provider]);
setOwnership("codex", ["codex"]);
const prompter = createWizardPrompter();
await offerPostInstallMigrations(buildBaseArgs({ prompter }));
expect(prompter.confirm).not.toHaveBeenCalled();
expect(migrateDefaultCommand).not.toHaveBeenCalled();
});
it("invokes migrateDefaultCommand when the user accepts in interactive mode", async () => {
const provider = buildProvider();
setProviders([provider]);
setOwnership("codex", ["codex"]);
const confirm = vi.fn(async (_params: { message: string; initialValue?: boolean }) => true);
const prompter = createWizardPrompter({
confirm: confirm as WizardPrompter["confirm"],
});
await offerPostInstallMigrations(buildBaseArgs({ prompter }));
expect(confirm).toHaveBeenCalledOnce();
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false }));
expect(migrateDefaultCommand).toHaveBeenCalledOnce();
expect(migrateDefaultCommand).toHaveBeenCalledWith(expect.anything(), { provider: "codex" });
});
it("does not invoke migrateDefaultCommand when the user declines", async () => {
const provider = buildProvider();
setProviders([provider]);
setOwnership("codex", ["codex"]);
const prompter = createWizardPrompter({
confirm: vi.fn(async () => false) as WizardPrompter["confirm"],
});
await offerPostInstallMigrations(buildBaseArgs({ prompter }));
expect(migrateDefaultCommand).not.toHaveBeenCalled();
});
it("never prompts or applies in non-interactive mode", async () => {
const provider = buildProvider();
setProviders([provider]);
setOwnership("codex", ["codex"]);
const prompter = createWizardPrompter();
await offerPostInstallMigrations(buildBaseArgs({ prompter, nonInteractive: true }));
expect(prompter.confirm).not.toHaveBeenCalled();
expect(migrateDefaultCommand).not.toHaveBeenCalled();
});
it("treats a non-TTY stdin as non-interactive even when nonInteractive flag is unset", async () => {
setTTY(false);
const provider = buildProvider();
setProviders([provider]);
setOwnership("codex", ["codex"]);
const prompter = createWizardPrompter();
await offerPostInstallMigrations(buildBaseArgs({ prompter }));
expect(prompter.confirm).not.toHaveBeenCalled();
expect(migrateDefaultCommand).not.toHaveBeenCalled();
});
it("swallows migrateDefaultCommand failures so onboarding can continue", async () => {
const provider = buildProvider();
setProviders([provider]);
setOwnership("codex", ["codex"]);
migrateDefaultCommand.mockRejectedValueOnce(new Error("boom"));
const prompter = createWizardPrompter({
confirm: vi.fn(async () => true) as WizardPrompter["confirm"],
});
await expect(offerPostInstallMigrations(buildBaseArgs({ prompter }))).resolves.toBeUndefined();
expect(migrateDefaultCommand).toHaveBeenCalledOnce();
});
it("falls back to a hint when detect throws", async () => {
const provider = buildProvider({
detect: vi.fn(async () => {
throw new Error("detect failure");
}),
});
setProviders([provider]);
setOwnership("codex", ["codex"]);
const prompter = createWizardPrompter();
await offerPostInstallMigrations(buildBaseArgs({ prompter }));
expect(prompter.confirm).not.toHaveBeenCalled();
expect(migrateDefaultCommand).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,159 @@
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { MigrationProviderPlugin } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "./prompts.js";
export type PostInstallMigrationOptions = {
config: OpenClawConfig;
runtime: RuntimeEnv;
// Required only on interactive paths; non-interactive callers can omit it
// since the helper only emits hint lines in that mode.
prompter?: WizardPrompter;
// Plugin ids that were just newly installed. Migration offers are gated to
// providers owned by these plugins so existing on-disk plugins don't trigger
// a surprise prompt every onboarding run.
installedPluginIds: readonly string[];
// When true, the helper only emits hint lines and never prompts or applies.
// Wire this from non-interactive onboarding paths.
nonInteractive?: boolean;
};
type ResolvedProviderCandidate = {
provider: MigrationProviderPlugin;
source?: string;
};
async function resolveCandidates(params: {
config: OpenClawConfig;
runtime: RuntimeEnv;
installedPluginIds: readonly string[];
}): Promise<ResolvedProviderCandidate[]> {
if (params.installedPluginIds.length === 0) {
return [];
}
const [
{ ensureStandaloneMigrationProviderRegistryLoaded, resolvePluginMigrationProviders },
{ resolveManifestContractRuntimePluginResolution },
{ createMigrationLogger },
{ resolveStateDir },
] = await Promise.all([
import("../plugins/migration-provider-runtime.js"),
import("../plugins/manifest-contract-runtime.js"),
import("../commands/migrate/context.js"),
import("../config/paths.js"),
]);
ensureStandaloneMigrationProviderRegistryLoaded({ cfg: params.config });
const installedIds = new Set(params.installedPluginIds);
const providers = resolvePluginMigrationProviders({ cfg: params.config });
const stateDir = resolveStateDir();
const logger = createMigrationLogger(params.runtime);
const candidates: ResolvedProviderCandidate[] = [];
for (const provider of providers) {
if (!provider.detect) {
continue;
}
// Ownership check: only offer migration for providers declared by a plugin
// that was just installed in this onboarding step.
const ownership = resolveManifestContractRuntimePluginResolution({
cfg: params.config,
contract: "migrationProviders",
value: provider.id,
});
if (!ownership.pluginIds.some((pluginId) => installedIds.has(pluginId))) {
continue;
}
try {
const detection = await provider.detect({
config: params.config,
stateDir,
logger,
});
if (!detection.found || detection.confidence === "low") {
continue;
}
candidates.push({
provider,
...(detection.source ? { source: detection.source } : {}),
});
} catch (error) {
logger.debug?.(
`Post-install migration detect for ${provider.id} failed: ${formatErrorMessage(error)}`,
);
}
}
return candidates;
}
function describeCandidate(candidate: ResolvedProviderCandidate): string {
const parts = [candidate.provider.label];
if (candidate.source) {
parts.push(`at ${candidate.source}`);
}
return parts.join(" ");
}
function logMigrationHint(runtime: RuntimeEnv, candidate: ResolvedProviderCandidate): void {
const command = formatCliCommand(`openclaw migrate ${candidate.provider.id} --dry-run`);
runtime.log(`Detected ${describeCandidate(candidate)}. Preview migration with ${command}.`);
}
/**
* Offer interactive migration for any migration provider owned by a plugin
* that was just installed during onboarding. In non-interactive mode this is
* a no-op apart from a hint line so scripted setups never mutate state
* unexpectedly. The actual migration UI (skill/plugin checkboxes, confirm
* prompt) is owned by `openclaw migrate <provider>`; this helper only owns
* the gate prompt.
*/
export async function offerPostInstallMigrations(
params: PostInstallMigrationOptions,
): Promise<void> {
const candidates = await resolveCandidates({
config: params.config,
runtime: params.runtime,
installedPluginIds: params.installedPluginIds,
});
if (candidates.length === 0) {
return;
}
const prompter = params.prompter;
const interactive =
params.nonInteractive !== true && process.stdin.isTTY && prompter !== undefined;
for (const candidate of candidates) {
if (!interactive || !prompter) {
logMigrationHint(params.runtime, candidate);
continue;
}
const description = describeCandidate(candidate);
let accepted = false;
try {
accepted = await prompter.confirm({
message: `Migrate ${description} into this agent now?`,
initialValue: false,
});
} catch (error) {
// Prompt cancellations / non-TTY refusals fall back to the hint path so
// onboarding never aborts on an optional offer.
params.runtime.log(
`Skipping ${candidate.provider.label} migration prompt: ${formatErrorMessage(error)}`,
);
logMigrationHint(params.runtime, candidate);
continue;
}
if (!accepted) {
logMigrationHint(params.runtime, candidate);
continue;
}
try {
const { migrateDefaultCommand } = await import("../commands/migrate.js");
await migrateDefaultCommand(params.runtime, { provider: candidate.provider.id });
} catch (error) {
params.runtime.log(
`${candidate.provider.label} migration failed: ${formatErrorMessage(error)}. ` +
`Re-run with ${formatCliCommand(`openclaw migrate ${candidate.provider.id} --dry-run`)} to inspect.`,
);
}
}
}