mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:04:47 +00:00
fix: carry codex migration config through onboarding
This commit is contained in:
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
|
||||
- ACP: preserve redacted numeric JSON-RPC `RequestError` details in runtime failure text, so backend diagnostics are visible instead of only `Internal error`. Fixes #81126. (#81188) Thanks @vyctorbrzezowski.
|
||||
- Agents: cache unchanged PI model discovery stores and model lookups, reducing repeated model-resolution startup latency under large model configs. Fixes #78851.
|
||||
- Onboarding: carry returned Codex plugin migration config through the OpenAI model wizard so accepted plugin migrations are saved with the final config write.
|
||||
- Security/Windows ACL audit: classify Anonymous Logon, Guests, Interactive, Local, and Network SIDs as world-equivalent principals so broadly writable paths stay critical instead of being downgraded to group-writable. Fixes #74350. (#74383) Thanks @dwc1997.
|
||||
- Media-understanding: retry transient remote attachment fetch failures before audio or vision processing, so Discord voice notes are not lost after one network/CDN blip. Fixes #74316. Thanks @vyctorbrzezowski and @gabrielexito-stack.
|
||||
- Control UI: order timestamped live stream and tool items before untimestamped history fallbacks, keeping chat history in visible time order. Fixes #80759. (#81016) Thanks @akrimm702.
|
||||
|
||||
@@ -41,6 +41,13 @@ but new code should not add imports from them: `agent-runtime-test-contracts`,
|
||||
`text-runtime`, and `zod`. Import `zod` directly from `zod` in new plugin code.
|
||||
`plugin-test-runtime` is still an active focused test helper subpath.
|
||||
|
||||
### Reserved bundled plugin helper subpaths
|
||||
|
||||
These subpaths are plugin-owned compatibility surfaces reserved for their owning
|
||||
bundled plugin, not general SDK APIs: `plugin-sdk/codex-mcp-projection` and
|
||||
`plugin-sdk/codex-native-task-runtime`. Cross-owner extension imports are blocked
|
||||
by package contract guardrails.
|
||||
|
||||
### Deprecated unused public subpaths
|
||||
|
||||
These public subpaths existed for at least one month and currently have no
|
||||
|
||||
@@ -52,6 +52,7 @@ import { resolveCodexMigrationTargets } from "./targets.js";
|
||||
|
||||
const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required";
|
||||
const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration";
|
||||
const CODEX_CONFIG_PATCH_MODE_RETURN = "return";
|
||||
|
||||
class CodexPluginConfigConflictError extends Error {
|
||||
constructor(readonly reason: string) {
|
||||
@@ -60,6 +61,10 @@ class CodexPluginConfigConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldReturnCodexPluginConfigPatch(ctx: MigrationProviderContext): boolean {
|
||||
return ctx.providerOptions?.configPatchMode === CODEX_CONFIG_PATCH_MODE_RETURN;
|
||||
}
|
||||
|
||||
export async function applyCodexMigrationPlan(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
plan?: MigrationPlan;
|
||||
@@ -220,15 +225,33 @@ async function applyCodexPluginConfigItem(
|
||||
if (entries.length === 0) {
|
||||
return markMigrationItemSkipped(item, "no selected Codex plugins");
|
||||
}
|
||||
const returnPatch = shouldReturnCodexPluginConfigPatch(ctx);
|
||||
const configApi = ctx.runtime?.config;
|
||||
if (!configApi?.current || !configApi.mutateConfigFile) {
|
||||
const currentConfig = returnPatch
|
||||
? ctx.config
|
||||
: (configApi?.current?.() as MigrationProviderContext["config"] | undefined);
|
||||
if (!currentConfig) {
|
||||
return markMigrationItemError(item, "config runtime unavailable");
|
||||
}
|
||||
const currentConfig = configApi.current() as MigrationProviderContext["config"];
|
||||
const value = buildCodexPluginsConfigValue(entries, { config: currentConfig });
|
||||
if (!ctx.overwrite && hasCodexPluginConfigConflict(currentConfig, value)) {
|
||||
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
|
||||
}
|
||||
const migratedItem: MigrationItem = {
|
||||
...item,
|
||||
status: "migrated",
|
||||
details: {
|
||||
...item.details,
|
||||
path: [...CODEX_PLUGIN_CONFIG_PATH],
|
||||
value,
|
||||
},
|
||||
};
|
||||
if (returnPatch) {
|
||||
return migratedItem;
|
||||
}
|
||||
if (!configApi?.mutateConfigFile) {
|
||||
return markMigrationItemError(item, "config runtime unavailable");
|
||||
}
|
||||
try {
|
||||
await configApi.mutateConfigFile({
|
||||
base: "runtime",
|
||||
@@ -240,15 +263,7 @@ async function applyCodexPluginConfigItem(
|
||||
writeMigrationConfigPath(draft as Record<string, unknown>, CODEX_PLUGIN_CONFIG_PATH, value);
|
||||
},
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
status: "migrated",
|
||||
details: {
|
||||
...item.details,
|
||||
path: [...CODEX_PLUGIN_CONFIG_PATH],
|
||||
value,
|
||||
},
|
||||
};
|
||||
return migratedItem;
|
||||
} catch (error) {
|
||||
if (error instanceof CodexPluginConfigConflictError) {
|
||||
return markMigrationItemConflict(item, error.reason);
|
||||
|
||||
@@ -41,6 +41,7 @@ function makeContext(params: {
|
||||
workspaceDir: string;
|
||||
overwrite?: boolean;
|
||||
verifyPluginApps?: boolean;
|
||||
providerOptions?: MigrationProviderContext["providerOptions"];
|
||||
reportDir?: string;
|
||||
config?: MigrationProviderContext["config"];
|
||||
runtime?: MigrationProviderContext["runtime"];
|
||||
@@ -59,7 +60,8 @@ function makeContext(params: {
|
||||
source: params.source,
|
||||
stateDir: params.stateDir,
|
||||
overwrite: params.overwrite,
|
||||
providerOptions: params.verifyPluginApps ? { verifyPluginApps: true } : undefined,
|
||||
providerOptions:
|
||||
params.providerOptions ?? (params.verifyPluginApps ? { verifyPluginApps: true } : undefined),
|
||||
reportDir: params.reportDir,
|
||||
logger,
|
||||
};
|
||||
@@ -210,9 +212,6 @@ describe("buildCodexMigrationProvider", () => {
|
||||
status: "planned",
|
||||
});
|
||||
expect(plan.items.some((item) => item.id === "skill:system-skill")).toBe(false);
|
||||
expect((plan.warnings ?? []).some((warning) => warning.includes("cached plugin bundles"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("plans source-installed curated plugins without installing during dry-run", async () => {
|
||||
@@ -377,7 +376,6 @@ describe("buildCodexMigrationProvider", () => {
|
||||
expect(plan.warnings).toEqual([
|
||||
"Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.",
|
||||
"Codex app-backed plugins were planned without source app accessibility verification. Re-run with --verify-plugin-apps to force a fresh source app/list check before planning native plugin activation.",
|
||||
"Codex cached plugin bundles remain manual-review only.",
|
||||
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
|
||||
]);
|
||||
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
|
||||
@@ -434,7 +432,6 @@ describe("buildCodexMigrationProvider", () => {
|
||||
},
|
||||
]);
|
||||
expect(plan.warnings).toEqual([
|
||||
"Codex cached plugin bundles remain manual-review only.",
|
||||
"Codex app-backed plugin migration requires the Codex app-server source account to be logged in with a ChatGPT subscription account. Log in to the Codex app with subscription auth; OpenClaw auth or API-key auth does not satisfy Codex app connector access.",
|
||||
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
|
||||
]);
|
||||
@@ -1079,6 +1076,93 @@ describe("buildCodexMigrationProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns Codex plugin config patches without mutating config in return mode", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const configState: MigrationProviderContext["config"] = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: { sandbox: "workspace-write" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
|
||||
}
|
||||
if (method === "plugin/read") {
|
||||
return pluginRead("google-calendar");
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
|
||||
}
|
||||
if (method === "skills/list") {
|
||||
return { data: [] } satisfies v2.SkillsListResponse;
|
||||
}
|
||||
if (method === "hooks/list") {
|
||||
return { data: [] } satisfies v2.HooksListResponse;
|
||||
}
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return {};
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsList([]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
});
|
||||
const mutateConfigFile = vi.fn(async () => {
|
||||
throw new Error("mutateConfigFile should not be called in return mode");
|
||||
});
|
||||
const provider = buildCodexMigrationProvider({
|
||||
runtime: {
|
||||
config: {
|
||||
current: () => configState,
|
||||
mutateConfigFile,
|
||||
},
|
||||
} as unknown as MigrationProviderContext["runtime"],
|
||||
});
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
config: configState,
|
||||
providerOptions: { configPatchMode: "return" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mutateConfigFile).not.toHaveBeenCalled();
|
||||
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toBeUndefined();
|
||||
const configItem = findItem(result.items, "config:codex-plugins");
|
||||
expectRecordFields(configItem, { status: "migrated" });
|
||||
const configDetails = configItem.details as Record<string, unknown>;
|
||||
expectRecordFields(configDetails, {
|
||||
path: ["plugins", "entries", "codex"],
|
||||
});
|
||||
expect(configDetails.value).toEqual({
|
||||
enabled: true,
|
||||
config: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: true,
|
||||
plugins: {
|
||||
"google-calendar": {
|
||||
enabled: true,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges migrated plugin config with existing Codex plugins when entries do not conflict", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const sourceKey = sourceAppCacheKey(fixture);
|
||||
|
||||
@@ -397,6 +397,42 @@ describe("migrateApplyCommand", () => {
|
||||
expect(mocks.provider.apply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses embedded config override and return patch mode for Codex planning and apply", async () => {
|
||||
const configOverride = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const planned = codexPluginPlan();
|
||||
const applied: MigrationApplyResult = {
|
||||
...planned,
|
||||
summary: { ...planned.summary, planned: 0, migrated: planned.summary.planned },
|
||||
items: planned.items.map((item) => ({ ...item, status: "migrated" as const })),
|
||||
};
|
||||
mocks.provider.plan.mockImplementation(async (ctx) => {
|
||||
expect(ctx.config).toBe(configOverride);
|
||||
expect(ctx.providerOptions).toEqual({ configPatchMode: "return" });
|
||||
return planned;
|
||||
});
|
||||
mocks.provider.apply.mockImplementation(async (ctx) => {
|
||||
expect(ctx.config).toBe(configOverride);
|
||||
expect(ctx.providerOptions).toEqual({ configPatchMode: "return" });
|
||||
return applied;
|
||||
});
|
||||
|
||||
await migrateApplyCommand(runtime, {
|
||||
provider: "codex",
|
||||
yes: true,
|
||||
configOverride,
|
||||
configPatchMode: "return",
|
||||
});
|
||||
|
||||
expect(mocks.provider.plan).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.provider.apply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("previews and prompts before interactive apply without --yes", async () => {
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
|
||||
@@ -363,7 +363,7 @@ export async function migrateApplyCommand(
|
||||
`openclaw migrate apply requires --yes in non-interactive mode. Preview first with ${formatCliCommand("openclaw migrate plan --provider <provider>")}.`,
|
||||
);
|
||||
}
|
||||
const provider = resolveMigrationProvider(providerId);
|
||||
const provider = resolveMigrationProvider(providerId, opts.configOverride);
|
||||
if (!opts.yes) {
|
||||
const plan = await migratePlanCommand(runtime, {
|
||||
...opts,
|
||||
|
||||
@@ -68,6 +68,7 @@ export async function runMigrationApply(params: {
|
||||
source: params.opts.source,
|
||||
includeSecrets: params.opts.includeSecrets,
|
||||
overwrite: params.opts.overwrite,
|
||||
configOverride: params.opts.configOverride,
|
||||
providerOptions: buildMigrationProviderOptions(params.opts),
|
||||
runtime: params.runtime,
|
||||
json: params.opts.json,
|
||||
@@ -97,6 +98,7 @@ export async function runMigrationApply(params: {
|
||||
source: params.opts.source,
|
||||
includeSecrets: params.opts.includeSecrets,
|
||||
overwrite: params.opts.overwrite,
|
||||
configOverride: params.opts.configOverride,
|
||||
providerOptions: buildMigrationProviderOptions(params.opts),
|
||||
runtime: params.runtime,
|
||||
backupPath,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { MigrationProviderContext } from "../../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
@@ -33,11 +34,12 @@ export function buildMigrationContext(params: {
|
||||
overwrite?: boolean;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
backupPath?: string;
|
||||
configOverride?: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
reportDir?: string;
|
||||
json?: boolean;
|
||||
}): MigrationProviderContext {
|
||||
const config = getRuntimeConfig();
|
||||
const config = params.configOverride ?? getRuntimeConfig();
|
||||
const stateDir = resolveStateDir();
|
||||
return {
|
||||
config,
|
||||
|
||||
@@ -9,8 +9,10 @@ import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { buildMigrationContext } from "./context.js";
|
||||
import type { MigrateCommonOptions } from "./types.js";
|
||||
|
||||
export function resolveMigrationProvider(providerId: string): MigrationProviderPlugin {
|
||||
const config = getRuntimeConfig();
|
||||
export function resolveMigrationProvider(
|
||||
providerId: string,
|
||||
config = getRuntimeConfig(),
|
||||
): MigrationProviderPlugin {
|
||||
ensureStandaloneMigrationProviderRegistryLoaded({ cfg: config });
|
||||
const provider = resolvePluginMigrationProvider({ providerId, cfg: config });
|
||||
if (!provider) {
|
||||
@@ -27,10 +29,14 @@ export function resolveMigrationProvider(providerId: string): MigrationProviderP
|
||||
export function buildMigrationProviderOptions(
|
||||
opts: MigrateCommonOptions,
|
||||
): Record<string, unknown> | undefined {
|
||||
const options: Record<string, unknown> = {};
|
||||
if (opts.provider === "codex" && opts.verifyPluginApps === true) {
|
||||
return { verifyPluginApps: true };
|
||||
options.verifyPluginApps = true;
|
||||
}
|
||||
return undefined;
|
||||
if (opts.provider === "codex" && opts.configPatchMode) {
|
||||
options.configPatchMode = opts.configPatchMode;
|
||||
}
|
||||
return Object.keys(options).length > 0 ? options : undefined;
|
||||
}
|
||||
|
||||
export async function createMigrationPlan(
|
||||
@@ -40,11 +46,12 @@ export async function createMigrationPlan(
|
||||
if (opts.verifyPluginApps && opts.provider !== "codex") {
|
||||
throw new Error("--verify-plugin-apps is only supported for Codex migrations.");
|
||||
}
|
||||
const provider = resolveMigrationProvider(opts.provider);
|
||||
const provider = resolveMigrationProvider(opts.provider, opts.configOverride);
|
||||
const ctx = buildMigrationContext({
|
||||
source: opts.source,
|
||||
includeSecrets: opts.includeSecrets,
|
||||
overwrite: opts.overwrite,
|
||||
configOverride: opts.configOverride,
|
||||
providerOptions: buildMigrationProviderOptions(opts),
|
||||
runtime,
|
||||
json: opts.json,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { MigrationPlan } from "../../plugins/types.js";
|
||||
|
||||
export type MigrationConfigPatchMode = "return";
|
||||
|
||||
export type MigrateCommonOptions = {
|
||||
provider?: string;
|
||||
source?: string;
|
||||
@@ -14,6 +17,12 @@ export type MigrateCommonOptions = {
|
||||
// already secured user consent and do not want to re-render the plan.
|
||||
// The interactive selection picker and apply confirmation still run.
|
||||
suppressPlanLog?: boolean;
|
||||
// Internal embedded migration source of truth. Standalone CLI callers should
|
||||
// omit this so migration uses the current runtime config from disk.
|
||||
configOverride?: OpenClawConfig;
|
||||
// Internal embedded mode for config patch items. Default CLI behavior persists
|
||||
// patches when this is omitted; onboarding can request returned patch details.
|
||||
configPatchMode?: MigrationConfigPatchMode;
|
||||
};
|
||||
|
||||
export type MigrateApplyOptions = MigrateCommonOptions & {
|
||||
|
||||
137
src/plugins/provider-auth-choice.test.ts
Normal file
137
src/plugins/provider-auth-choice.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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 { ProviderPlugin } from "./types.js";
|
||||
|
||||
const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../commands/codex-runtime-plugin-install.js", () => ({
|
||||
CODEX_RUNTIME_PLUGIN_ID: "codex",
|
||||
ensureCodexRuntimePluginForModelSelection,
|
||||
}));
|
||||
|
||||
const offerPostInstallMigrations = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../wizard/setup.post-install-migration.js", () => ({
|
||||
offerPostInstallMigrations,
|
||||
}));
|
||||
|
||||
const { __testing, applyAuthChoicePluginProvider } = await import("./provider-auth-choice.js");
|
||||
|
||||
function buildProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
label: "API key",
|
||||
kind: "api_key",
|
||||
run: vi.fn(async () => ({
|
||||
profiles: [],
|
||||
notes: [],
|
||||
defaultModel: "gpt-5.5",
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyAuthChoicePluginProvider", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetDepsForTest();
|
||||
ensureCodexRuntimePluginForModelSelection.mockReset();
|
||||
offerPostInstallMigrations.mockReset();
|
||||
});
|
||||
|
||||
it("returns post-install Codex migration config when setting an OpenAI default model", async () => {
|
||||
const provider = buildProvider();
|
||||
const runProviderModelSelectedHook = vi.fn(async () => undefined);
|
||||
__testing.setDepsForTest({
|
||||
loadPluginProviderRuntime: async () =>
|
||||
({
|
||||
resolvePluginProviders: () => [provider],
|
||||
runProviderModelSelectedHook,
|
||||
}) as never,
|
||||
});
|
||||
ensureCodexRuntimePluginForModelSelection.mockImplementation(
|
||||
async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
installed: true,
|
||||
cfg: {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
codex: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
offerPostInstallMigrations.mockImplementation(
|
||||
async ({ config }: { config: OpenClawConfig }) => ({
|
||||
config: {
|
||||
...config,
|
||||
plugins: {
|
||||
...config.plugins,
|
||||
entries: {
|
||||
...config.plugins?.entries,
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: true,
|
||||
plugins: {
|
||||
gmail: {
|
||||
enabled: true,
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "gmail",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await applyAuthChoicePluginProvider(
|
||||
{
|
||||
authChoice: "openai-api-key",
|
||||
config: {},
|
||||
runtime: createNonExitingRuntime(),
|
||||
prompter: createWizardPrompter(),
|
||||
setDefaultModel: true,
|
||||
},
|
||||
{
|
||||
authChoice: "openai-api-key",
|
||||
pluginId: "openai",
|
||||
providerId: "openai",
|
||||
methodId: "api-key",
|
||||
label: "OpenAI",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runProviderModelSelectedHook).toHaveBeenCalledOnce();
|
||||
expect(offerPostInstallMigrations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
installedPluginIds: ["codex"],
|
||||
}),
|
||||
);
|
||||
const resultConfig = result?.config;
|
||||
expect(resultConfig?.agents?.defaults?.model).toEqual({ primary: "gpt-5.5" });
|
||||
const codexConfig = resultConfig?.plugins?.entries?.codex?.config as
|
||||
| { codexPlugins?: { plugins?: unknown } }
|
||||
| undefined;
|
||||
expect(codexConfig?.codexPlugins?.plugins).toEqual({
|
||||
gmail: {
|
||||
enabled: true,
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "gmail",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -172,12 +172,13 @@ async function applyDefaultModelFromAuthChoice(params: {
|
||||
// migratable state to find.
|
||||
const { offerPostInstallMigrations } =
|
||||
await import("../wizard/setup.post-install-migration.js");
|
||||
await offerPostInstallMigrations({
|
||||
const migrationResult = await offerPostInstallMigrations({
|
||||
config: nextConfig,
|
||||
runtime: params.runtime,
|
||||
prompter: params.prompter,
|
||||
installedPluginIds: [CODEX_RUNTIME_PLUGIN_ID],
|
||||
});
|
||||
nextConfig = migrationResult.config;
|
||||
}
|
||||
}
|
||||
await noteDefaultModelResult({
|
||||
|
||||
@@ -74,12 +74,13 @@ function setTTY(isTTY: boolean): void {
|
||||
}
|
||||
|
||||
function buildBaseArgs(overrides: {
|
||||
config?: OpenClawConfig;
|
||||
prompter?: WizardPrompter;
|
||||
installedPluginIds?: readonly string[];
|
||||
nonInteractive?: boolean;
|
||||
}) {
|
||||
return {
|
||||
config: {} as OpenClawConfig,
|
||||
config: overrides.config ?? ({} as OpenClawConfig),
|
||||
runtime: createNonExitingRuntime(),
|
||||
prompter: overrides.prompter ?? createWizardPrompter(),
|
||||
installedPluginIds: overrides.installedPluginIds ?? ["codex"],
|
||||
@@ -109,9 +110,13 @@ describe("offerPostInstallMigrations", () => {
|
||||
});
|
||||
|
||||
it("returns early when no plugins were installed in this onboarding step", async () => {
|
||||
await offerPostInstallMigrations(buildBaseArgs({ installedPluginIds: [] }));
|
||||
const config = { plugins: { entries: { codex: { enabled: true } } } } as OpenClawConfig;
|
||||
const result = await offerPostInstallMigrations(
|
||||
buildBaseArgs({ config, installedPluginIds: [] }),
|
||||
);
|
||||
expect(resolvePluginMigrationProviders).not.toHaveBeenCalled();
|
||||
expect(migrateDefaultCommand).not.toHaveBeenCalled();
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
|
||||
it("skips providers not owned by any plugin in installedPluginIds", async () => {
|
||||
@@ -165,14 +170,110 @@ describe("offerPostInstallMigrations", () => {
|
||||
confirm: confirm as WizardPrompter["confirm"],
|
||||
});
|
||||
|
||||
await offerPostInstallMigrations(buildBaseArgs({ prompter }));
|
||||
const result = await offerPostInstallMigrations(buildBaseArgs({ prompter }));
|
||||
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false }));
|
||||
expect(migrateDefaultCommand).toHaveBeenCalledOnce();
|
||||
expect(migrateDefaultCommand).toHaveBeenCalledWith(expect.anything(), {
|
||||
provider: "codex",
|
||||
suppressPlanLog: true,
|
||||
expect(migrateDefaultCommand).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
configPatchMode: "return",
|
||||
suppressPlanLog: true,
|
||||
}),
|
||||
);
|
||||
expect(result.config).toEqual({});
|
||||
});
|
||||
|
||||
it("returns config patched from migrated config items without mutating the input config", async () => {
|
||||
const provider = buildProvider();
|
||||
setProviders([provider]);
|
||||
setOwnership("codex", ["codex"]);
|
||||
const inputConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: { sandbox: "workspace-write" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
migrateDefaultCommand.mockResolvedValueOnce({
|
||||
providerId: "codex",
|
||||
source: "/home/user/.codex",
|
||||
summary: {
|
||||
total: 1,
|
||||
planned: 0,
|
||||
migrated: 1,
|
||||
skipped: 0,
|
||||
conflicts: 0,
|
||||
errors: 0,
|
||||
sensitive: 0,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: "config:codex-plugins",
|
||||
kind: "config",
|
||||
action: "merge",
|
||||
status: "migrated",
|
||||
details: {
|
||||
path: ["plugins", "entries", "codex"],
|
||||
value: {
|
||||
enabled: true,
|
||||
config: {
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: true,
|
||||
plugins: {
|
||||
gmail: {
|
||||
enabled: true,
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "gmail",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
const prompter = createWizardPrompter({
|
||||
confirm: vi.fn(async () => true) as WizardPrompter["confirm"],
|
||||
});
|
||||
|
||||
const result = await offerPostInstallMigrations(
|
||||
buildBaseArgs({ config: inputConfig, prompter }),
|
||||
);
|
||||
|
||||
expect(migrateDefaultCommand).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
configOverride: inputConfig,
|
||||
configPatchMode: "return",
|
||||
}),
|
||||
);
|
||||
expect(result.config).not.toBe(inputConfig);
|
||||
expect(result.config.plugins?.entries?.codex?.config).toEqual({
|
||||
appServer: { sandbox: "workspace-write" },
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
allow_destructive_actions: true,
|
||||
plugins: {
|
||||
gmail: {
|
||||
enabled: true,
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "gmail",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(inputConfig.plugins?.entries?.codex?.config).toEqual({
|
||||
appServer: { sandbox: "workspace-write" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,7 +324,9 @@ describe("offerPostInstallMigrations", () => {
|
||||
confirm: vi.fn(async () => true) as WizardPrompter["confirm"],
|
||||
});
|
||||
|
||||
await expect(offerPostInstallMigrations(buildBaseArgs({ prompter }))).resolves.toBeUndefined();
|
||||
await expect(offerPostInstallMigrations(buildBaseArgs({ prompter }))).resolves.toEqual({
|
||||
config: {},
|
||||
});
|
||||
expect(migrateDefaultCommand).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import {
|
||||
readMigrationConfigPatchDetails,
|
||||
writeMigrationConfigPath,
|
||||
} from "../plugin-sdk/migration.js";
|
||||
import type { MigrationProviderPlugin } from "../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "./prompts.js";
|
||||
@@ -20,6 +24,10 @@ export type PostInstallMigrationOptions = {
|
||||
nonInteractive?: boolean;
|
||||
};
|
||||
|
||||
export type PostInstallMigrationResult = {
|
||||
config: OpenClawConfig;
|
||||
};
|
||||
|
||||
type ResolvedProviderCandidate = {
|
||||
provider: MigrationProviderPlugin;
|
||||
source?: string;
|
||||
@@ -99,6 +107,39 @@ function logMigrationHint(runtime: RuntimeEnv, candidate: ResolvedProviderCandid
|
||||
runtime.log(`Detected ${describeCandidate(candidate)}. Preview migration with ${command}.`);
|
||||
}
|
||||
|
||||
function applyMigrationConfigPatches(
|
||||
config: OpenClawConfig,
|
||||
result: { items?: readonly unknown[] } | undefined,
|
||||
): OpenClawConfig {
|
||||
const items = result?.items ?? [];
|
||||
const patches = items
|
||||
.filter((item): item is Parameters<typeof readMigrationConfigPatchDetails>[0] =>
|
||||
Boolean(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"kind" in item &&
|
||||
item.kind === "config" &&
|
||||
"action" in item &&
|
||||
item.action === "merge" &&
|
||||
"status" in item &&
|
||||
item.status === "migrated",
|
||||
),
|
||||
)
|
||||
.map(readMigrationConfigPatchDetails)
|
||||
.filter(
|
||||
(patch): patch is NonNullable<ReturnType<typeof readMigrationConfigPatchDetails>> =>
|
||||
patch !== undefined,
|
||||
);
|
||||
if (patches.length === 0) {
|
||||
return config;
|
||||
}
|
||||
const nextConfig = structuredClone(config);
|
||||
for (const patch of patches) {
|
||||
writeMigrationConfigPath(nextConfig as Record<string, unknown>, patch.path, patch.value);
|
||||
}
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer interactive migration for any migration provider owned by a plugin
|
||||
* that was just installed during onboarding. In non-interactive mode this is
|
||||
@@ -109,15 +150,16 @@ function logMigrationHint(runtime: RuntimeEnv, candidate: ResolvedProviderCandid
|
||||
*/
|
||||
export async function offerPostInstallMigrations(
|
||||
params: PostInstallMigrationOptions,
|
||||
): Promise<void> {
|
||||
): Promise<PostInstallMigrationResult> {
|
||||
const candidates = await resolveCandidates({
|
||||
config: params.config,
|
||||
runtime: params.runtime,
|
||||
installedPluginIds: params.installedPluginIds,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
return { config: params.config };
|
||||
}
|
||||
let nextConfig = params.config;
|
||||
const prompter = params.prompter;
|
||||
const interactive =
|
||||
params.nonInteractive !== true && process.stdin.isTTY && prompter !== undefined;
|
||||
@@ -148,10 +190,13 @@ export async function offerPostInstallMigrations(
|
||||
}
|
||||
try {
|
||||
const { migrateDefaultCommand } = await import("../commands/migrate.js");
|
||||
await migrateDefaultCommand(params.runtime, {
|
||||
const result = await migrateDefaultCommand(params.runtime, {
|
||||
provider: candidate.provider.id,
|
||||
configOverride: nextConfig,
|
||||
configPatchMode: "return",
|
||||
suppressPlanLog: true,
|
||||
});
|
||||
nextConfig = applyMigrationConfigPatches(nextConfig, result);
|
||||
} catch (error) {
|
||||
params.runtime.log(
|
||||
`${candidate.provider.label} migration failed: ${formatErrorMessage(error)}. ` +
|
||||
@@ -159,4 +204,5 @@ export async function offerPostInstallMigrations(
|
||||
);
|
||||
}
|
||||
}
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user