Files
openclaw/extensions/codex/src/migration/provider.test.ts
2026-05-10 07:14:08 +01:00

792 lines
25 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import type { v2 } from "../app-server/protocol.js";
import { buildCodexMigrationProvider } from "./provider.js";
const appServerRequest = vi.hoisted(() => vi.fn());
vi.mock("../app-server/request.js", () => ({
requestCodexAppServerJson: appServerRequest,
}));
const tempRoots = new Set<string>();
const logger = {
info() {},
warn() {},
error() {},
debug() {},
};
async function makeTempRoot(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-migrate-codex-"));
tempRoots.add(root);
return root;
}
async function writeFile(filePath: string, content = ""): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
}
function makeContext(params: {
source: string;
stateDir: string;
workspaceDir: string;
overwrite?: boolean;
reportDir?: string;
config?: MigrationProviderContext["config"];
runtime?: MigrationProviderContext["runtime"];
}): MigrationProviderContext {
return {
config:
params.config ??
({
agents: {
defaults: {
workspace: params.workspaceDir,
},
},
} as MigrationProviderContext["config"]),
runtime: params.runtime,
source: params.source,
stateDir: params.stateDir,
overwrite: params.overwrite,
reportDir: params.reportDir,
logger,
};
}
function findItem(items: readonly { id?: string }[], id: string) {
const item = items.find((entry) => entry.id === id);
if (!item) {
throw new Error(`Expected migration item ${id}`);
}
return item as Record<string, unknown>;
}
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
expect(record).toBeDefined();
const actual = record as Record<string, unknown>;
for (const [key, value] of Object.entries(expected)) {
expect(actual[key]).toEqual(value);
}
return actual;
}
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0) {
const call = mock.mock.calls[callIndex];
if (!call) {
throw new Error(`Expected mock call ${callIndex}`);
}
return call[argIndex];
}
async function createCodexFixture(): Promise<{
root: string;
homeDir: string;
codexHome: string;
stateDir: string;
workspaceDir: string;
}> {
const root = await makeTempRoot();
const homeDir = path.join(root, "home");
const codexHome = path.join(root, ".codex");
const stateDir = path.join(root, "state");
const workspaceDir = path.join(root, "workspace");
vi.stubEnv("HOME", homeDir);
await writeFile(path.join(codexHome, "skills", "tweet-helper", "SKILL.md"), "# Tweet helper\n");
await writeFile(path.join(codexHome, "skills", ".system", "system-skill", "SKILL.md"));
await writeFile(path.join(homeDir, ".agents", "skills", "personal-style", "SKILL.md"));
await writeFile(
path.join(
codexHome,
"plugins",
"cache",
"openai-primary-runtime",
"documents",
"1.0.0",
".codex-plugin",
"plugin.json",
),
JSON.stringify({ name: "documents" }),
);
await writeFile(path.join(codexHome, "config.toml"), 'model = "gpt-5.5"\n');
await writeFile(path.join(codexHome, "hooks", "hooks.json"), "{}\n");
return { root, homeDir, codexHome, stateDir, workspaceDir };
}
afterEach(async () => {
vi.unstubAllEnvs();
appServerRequest.mockReset();
for (const root of tempRoots) {
await fs.rm(root, { recursive: true, force: true });
}
tempRoots.clear();
});
describe("buildCodexMigrationProvider", () => {
beforeEach(() => {
appServerRequest.mockRejectedValue(new Error("codex app-server unavailable"));
});
it("plans Codex skills while keeping plugins and native config explicit", async () => {
const fixture = await createCodexFixture();
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expect(plan.providerId).toBe("codex");
expect(plan.source).toBe(fixture.codexHome);
expectRecordFields(findItem(plan.items, "skill:tweet-helper"), {
kind: "skill",
action: "copy",
status: "planned",
target: path.join(fixture.workspaceDir, "skills", "tweet-helper"),
});
expectRecordFields(findItem(plan.items, "skill:personal-style"), {
kind: "skill",
action: "copy",
status: "planned",
target: path.join(fixture.workspaceDir, "skills", "personal-style"),
});
expectRecordFields(findItem(plan.items, "plugin:documents:1"), {
kind: "manual",
action: "manual",
status: "skipped",
});
expectRecordFields(findItem(plan.items, "archive:config.toml"), {
kind: "archive",
action: "archive",
status: "planned",
});
expectRecordFields(findItem(plan.items, "archive:hooks/hooks.json"), {
kind: "archive",
action: "archive",
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 () => {
const fixture = await createCodexFixture();
appServerRequest.mockResolvedValueOnce(
pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]),
);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expect(appServerRequest).toHaveBeenCalledTimes(1);
expectRecordFields(mockCallArg(appServerRequest), {
method: "plugin/list",
requestParams: { cwds: [] },
});
expect(
appServerRequest.mock.calls.some(
([arg]) => (arg as { method?: string }).method === "plugin/install",
),
).toBe(false);
const pluginItem = findItem(plan.items, "plugin:google-calendar");
expectRecordFields(pluginItem, {
kind: "plugin",
action: "install",
status: "planned",
});
expectRecordFields(pluginItem.details, {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
});
expectRecordFields(findItem(plan.items, "config:codex-plugins"), {
kind: "config",
action: "merge",
status: "planned",
});
});
it("copies planned skills and archives native config during apply", async () => {
const fixture = await createCodexFixture();
const reportDir = path.join(fixture.root, "report");
const provider = buildCodexMigrationProvider();
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
reportDir,
}),
);
await expect(
fs.access(path.join(fixture.workspaceDir, "skills", "tweet-helper", "SKILL.md")),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(fixture.workspaceDir, "skills", "personal-style", "SKILL.md")),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(reportDir, "archive", "config.toml")),
).resolves.toBeUndefined();
expectRecordFields(findItem(result.items, "plugin:documents:1"), { status: "skipped" });
expectRecordFields(findItem(result.items, "skill:tweet-helper"), { status: "migrated" });
expectRecordFields(findItem(result.items, "archive:config.toml"), { status: "migrated" });
await expect(fs.access(path.join(reportDir, "report.json"))).resolves.toBeUndefined();
});
it("installs selected curated plugins during apply and writes codexPlugins config", async () => {
const fixture = await createCodexFixture();
const reportDir = path.join(fixture.root, "report");
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/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 {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
reportDir,
config: configState,
}),
);
const installCall = appServerRequest.mock.calls.find(
([arg]) => (arg as { method?: string }).method === "plugin/install",
)?.[0] as Record<string, unknown>;
expectRecordFields(installCall, {
method: "plugin/install",
requestParams: {
marketplacePath: "/marketplaces/openai-curated",
pluginName: "google-calendar",
},
});
const pluginItem = findItem(result.items, "plugin:google-calendar");
expectRecordFields(pluginItem, {
status: "migrated",
reason: "already active",
});
expectRecordFields(pluginItem.details, {
code: "already_active",
installAttempted: true,
});
expectRecordFields(findItem(result.items, "config:codex-plugins"), {
status: "migrated",
});
expect(configState.plugins?.entries?.codex?.enabled).toBe(true);
expect(configState.plugins?.entries?.codex?.config?.appServer).toEqual({
sandbox: "workspace-write",
});
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
enabled: true,
allow_destructive_actions: false,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
});
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).not.toHaveProperty("*");
});
it("plans already configured target Codex plugins as plugin-level conflicts", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: false,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
},
},
},
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 }),
pluginSummary("gmail", { installed: true, enabled: true }),
]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const result = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expectRecordFields(findItem(result.items, "plugin:google-calendar"), {
status: "conflict",
reason: "plugin exists",
});
expectRecordFields(findItem(result.items, "plugin:gmail"), { status: "planned" });
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "planned" });
});
it("preserves explicit app-server settings during plugin migration", 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/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 {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expect(configState.plugins?.entries?.codex?.config?.appServer).toEqual({
sandbox: "workspace-write",
});
});
it("merges migrated plugin config with existing Codex plugins when entries do not conflict", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {
slack: {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "slack",
},
},
},
},
},
},
},
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/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 {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
allow_destructive_actions: true,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
slack: {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "slack",
},
},
enabled: true,
});
});
it("preserves existing destructive plugin policy when overwrite is explicit", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {},
},
},
},
},
},
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/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 {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
overwrite: true,
}),
);
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
enabled: true,
allow_destructive_actions: true,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
});
});
it("records auth-required plugin installs as disabled explicit config entries", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
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/install") {
return {
authPolicy: "ON_USE",
appsNeedingAuth: [
{
id: "google-calendar",
name: "Google Calendar",
description: "Calendar",
installUrl: "https://example.invalid/auth",
needsAuth: true,
},
],
} 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 {};
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
const pluginItem = findItem(result.items, "plugin:google-calendar");
expectRecordFields(pluginItem, {
status: "skipped",
reason: "auth_required",
});
expectRecordFields(pluginItem.details, {
code: "auth_required",
appsNeedingAuth: [
{
id: "google-calendar",
name: "Google Calendar",
needsAuth: true,
},
],
});
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
enabled: true,
allow_destructive_actions: false,
plugins: {
"google-calendar": {
enabled: false,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
});
});
it("does not write config entries for failed plugin installs", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
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/install") {
throw new Error("install failed");
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expectRecordFields(findItem(result.items, "plugin:google-calendar"), {
status: "error",
reason: "install failed",
});
expectRecordFields(findItem(result.items, "config:codex-plugins"), {
status: "skipped",
reason: "no selected Codex plugins",
});
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toBeUndefined();
});
it("reports existing skill targets as conflicts unless overwrite is set", async () => {
const fixture = await createCodexFixture();
await writeFile(path.join(fixture.workspaceDir, "skills", "tweet-helper", "SKILL.md"));
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
const overwritePlan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
overwrite: true,
}),
);
expectRecordFields(findItem(plan.items, "skill:tweet-helper"), { status: "conflict" });
expectRecordFields(findItem(overwritePlan.items, "skill:tweet-helper"), {
status: "planned",
});
});
});
function createConfigRuntime(
configState: MigrationProviderContext["config"],
): MigrationProviderContext["runtime"] {
type Runtime = NonNullable<MigrationProviderContext["runtime"]>;
type MutateConfigFileParams = Parameters<Runtime["config"]["mutateConfigFile"]>[0];
type MutateConfigFileResult = Awaited<ReturnType<Runtime["config"]["mutateConfigFile"]>>;
return {
config: {
current: () => configState,
mutateConfigFile: async (params: MutateConfigFileParams): Promise<MutateConfigFileResult> => {
const result = await params.mutate(configState, {
snapshot: {} as never,
previousHash: null,
});
return {
path: "/tmp/openclaw.json",
previousHash: null,
snapshot: {} as never,
nextConfig: configState,
afterWrite: { mode: "auto" },
followUp: { mode: "auto", requiresRestart: false },
result,
};
},
},
} as unknown as MigrationProviderContext["runtime"];
}
function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
return {
marketplaces: [
{
name: CODEX_PLUGINS_MARKETPLACE_NAME,
path: "/marketplaces/openai-curated",
interface: null,
plugins,
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
return {
id,
name: id,
source: { type: "remote" },
installed: false,
enabled: false,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
...overrides,
};
}