mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 12:21:05 +00:00
792 lines
25 KiB
TypeScript
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,
|
|
};
|
|
}
|