mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
Isolate Codex app-server state per agent (#74556)
* fix(codex): isolate app-server home per agent * fix(codex): isolate native Codex assets per agent * fix(channels): mark inbound system events untrusted * fix(doctor): warn on personal Codex agent skills * test(doctor): cover personal Codex agent skills warning * fix(codex): forward auth profiles to harness runs * fix(codex): preserve auto auth for harness runs * fix(codex): auto-select harness auth profiles * test(codex): type harness auth mock * feat(codex): select migrated skills * fix(codex): satisfy migration selection lint * docs: add codex isolation changelog
This commit is contained in:
@@ -17,6 +17,7 @@ describe("codex plugin", () => {
|
||||
const registerAgentHarness = vi.fn();
|
||||
const registerCommand = vi.fn();
|
||||
const registerMediaUnderstandingProvider = vi.fn();
|
||||
const registerMigrationProvider = vi.fn();
|
||||
const registerProvider = vi.fn();
|
||||
const on = vi.fn();
|
||||
const onConversationBindingResolved = vi.fn();
|
||||
@@ -32,6 +33,7 @@ describe("codex plugin", () => {
|
||||
registerAgentHarness,
|
||||
registerCommand,
|
||||
registerMediaUnderstandingProvider,
|
||||
registerMigrationProvider,
|
||||
registerProvider,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
@@ -55,6 +57,10 @@ describe("codex plugin", () => {
|
||||
name: "codex",
|
||||
description: "Inspect and control the Codex app-server harness",
|
||||
});
|
||||
expect(registerMigrationProvider.mock.calls[0]?.[0]).toMatchObject({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
});
|
||||
expect(on).toHaveBeenCalledWith("inbound_claim", expect.any(Function));
|
||||
expect(onConversationBindingResolved).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
handleCodexConversationBindingResolved,
|
||||
handleCodexConversationInboundClaim,
|
||||
} from "./src/conversation-binding.js";
|
||||
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "codex",
|
||||
@@ -28,6 +29,7 @@ export default definePluginEntry({
|
||||
api.registerMediaUnderstandingProvider(
|
||||
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
|
||||
);
|
||||
api.registerMigrationProvider(buildCodexMigrationProvider());
|
||||
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
|
||||
api.on("inbound_claim", (event, ctx) =>
|
||||
handleCodexConversationInboundClaim(event, ctx, {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"description": "Codex app-server harness and Codex-managed GPT model catalog.",
|
||||
"providers": ["codex"],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["codex"]
|
||||
"mediaUnderstandingProviders": ["codex"],
|
||||
"migrationProviders": ["codex"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"codex": {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
applyCodexAppServerAuthProfile,
|
||||
bridgeCodexAppServerStartOptions,
|
||||
refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerHomeDir,
|
||||
resolveCodexAppServerNativeHomeDir,
|
||||
} from "./auth-bridge.js";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
|
||||
@@ -115,6 +117,64 @@ function createStartOptions(
|
||||
}
|
||||
|
||||
describe("bridgeCodexAppServerStartOptions", () => {
|
||||
it("sets agent-owned CODEX_HOME and HOME for local app-server launches", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const startOptions = createStartOptions();
|
||||
try {
|
||||
const codexHome = resolveCodexAppServerHomeDir(agentDir);
|
||||
const nativeHome = resolveCodexAppServerNativeHomeDir(agentDir);
|
||||
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
startOptions,
|
||||
agentDir,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: nativeHome,
|
||||
},
|
||||
});
|
||||
await expect(fs.access(codexHome)).resolves.toBeUndefined();
|
||||
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
|
||||
expect(startOptions.env).toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit CODEX_HOME and HOME overrides", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const codexHome = path.join(agentDir, "custom-codex-home");
|
||||
const nativeHome = path.join(agentDir, "custom-native-home");
|
||||
const startOptions = createStartOptions({
|
||||
env: { CODEX_HOME: codexHome, HOME: nativeHome, EXISTING: "1" },
|
||||
clearEnv: ["CODEX_HOME", "HOME", "FOO"],
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
startOptions,
|
||||
agentDir,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: nativeHome,
|
||||
EXISTING: "1",
|
||||
},
|
||||
clearEnv: ["FOO"],
|
||||
});
|
||||
await expect(fs.access(codexHome)).resolves.toBeUndefined();
|
||||
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
|
||||
expect(startOptions.clearEnv).toEqual(["CODEX_HOME", "HOME", "FOO"]);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("clears inherited API-key env vars when the default Codex profile is subscription auth", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const startOptions = createStartOptions({
|
||||
@@ -142,6 +202,11 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
EXISTING: "1",
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
expect(startOptions.clearEnv).toEqual(["FOO"]);
|
||||
@@ -178,6 +243,10 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
} finally {
|
||||
@@ -207,6 +276,10 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
} finally {
|
||||
@@ -234,7 +307,13 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
agentDir,
|
||||
authProfileId: "openai-codex:work",
|
||||
}),
|
||||
).resolves.toBe(startOptions);
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
loadAuthProfileStoreForSecretsRuntime,
|
||||
@@ -17,9 +19,14 @@ import { resolveCodexAppServerSpawnEnv } from "./transport-stdio.js";
|
||||
|
||||
const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex";
|
||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
const CODEX_HOME_ENV_VAR = "CODEX_HOME";
|
||||
const HOME_ENV_VAR = "HOME";
|
||||
const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home";
|
||||
const CODEX_APP_SERVER_NATIVE_HOME_DIRNAME = "home";
|
||||
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
|
||||
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
|
||||
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
|
||||
const CODEX_APP_SERVER_ISOLATION_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR];
|
||||
|
||||
export async function bridgeCodexAppServerStartOptions(params: {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
@@ -29,14 +36,64 @@ export async function bridgeCodexAppServerStartOptions(params: {
|
||||
if (params.startOptions.transport !== "stdio") {
|
||||
return params.startOptions;
|
||||
}
|
||||
const isolatedStartOptions = await withAgentCodexHomeEnvironment(
|
||||
params.startOptions,
|
||||
params.agentDir,
|
||||
);
|
||||
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
|
||||
const shouldClearInheritedOpenAiApiKey = shouldClearOpenAiApiKeyForCodexAuthProfile({
|
||||
store,
|
||||
authProfileId: params.authProfileId,
|
||||
});
|
||||
return shouldClearInheritedOpenAiApiKey
|
||||
? withClearedEnvironmentVariables(params.startOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS)
|
||||
: params.startOptions;
|
||||
? withClearedEnvironmentVariables(isolatedStartOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS)
|
||||
: isolatedStartOptions;
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerHomeDir(agentDir: string): string {
|
||||
return path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME);
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerNativeHomeDir(agentDir: string): string {
|
||||
return path.join(resolveCodexAppServerHomeDir(agentDir), CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
|
||||
}
|
||||
|
||||
async function withAgentCodexHomeEnvironment(
|
||||
startOptions: CodexAppServerStartOptions,
|
||||
agentDir: string,
|
||||
): Promise<CodexAppServerStartOptions> {
|
||||
const codexHome = startOptions.env?.[CODEX_HOME_ENV_VAR]?.trim()
|
||||
? startOptions.env[CODEX_HOME_ENV_VAR]
|
||||
: resolveCodexAppServerHomeDir(agentDir);
|
||||
const nativeHome = startOptions.env?.[HOME_ENV_VAR]?.trim()
|
||||
? startOptions.env[HOME_ENV_VAR]
|
||||
: path.join(codexHome, CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.mkdir(nativeHome, { recursive: true });
|
||||
const nextStartOptions: CodexAppServerStartOptions = {
|
||||
...startOptions,
|
||||
env: {
|
||||
...startOptions.env,
|
||||
[CODEX_HOME_ENV_VAR]: codexHome,
|
||||
[HOME_ENV_VAR]: nativeHome,
|
||||
},
|
||||
};
|
||||
const clearEnv = withoutClearedCodexIsolationEnv(startOptions.clearEnv);
|
||||
if (clearEnv) {
|
||||
nextStartOptions.clearEnv = clearEnv;
|
||||
} else {
|
||||
delete nextStartOptions.clearEnv;
|
||||
}
|
||||
return nextStartOptions;
|
||||
}
|
||||
|
||||
function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string[] | undefined {
|
||||
if (!clearEnv) {
|
||||
return undefined;
|
||||
}
|
||||
const reserved = new Set(CODEX_APP_SERVER_ISOLATION_ENV_VARS);
|
||||
const filtered = clearEnv.filter((envVar) => !reserved.has(envVar.trim().toUpperCase()));
|
||||
return filtered.length === clearEnv.length ? clearEnv : filtered;
|
||||
}
|
||||
|
||||
export async function applyCodexAppServerAuthProfile(params: {
|
||||
|
||||
43
extensions/codex/src/migration/apply.ts
Normal file
43
extensions/codex/src/migration/apply.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import path from "node:path";
|
||||
import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
|
||||
import {
|
||||
archiveMigrationItem,
|
||||
copyMigrationFileItem,
|
||||
writeMigrationReport,
|
||||
} from "openclaw/plugin-sdk/migration-runtime";
|
||||
import type {
|
||||
MigrationApplyResult,
|
||||
MigrationItem,
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
|
||||
export async function applyCodexMigrationPlan(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
plan?: MigrationPlan;
|
||||
}): Promise<MigrationApplyResult> {
|
||||
const plan = params.plan ?? (await buildCodexMigrationPlan(params.ctx));
|
||||
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "codex");
|
||||
const items: MigrationItem[] = [];
|
||||
for (const item of plan.items) {
|
||||
if (item.status !== "planned") {
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item.action === "archive") {
|
||||
items.push(await archiveMigrationItem(item, reportDir));
|
||||
} else {
|
||||
items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite }));
|
||||
}
|
||||
}
|
||||
const result: MigrationApplyResult = {
|
||||
...plan,
|
||||
items,
|
||||
summary: summarizeMigrationItems(items),
|
||||
backupPath: params.ctx.backupPath,
|
||||
reportDir,
|
||||
};
|
||||
await writeMigrationReport(result, { title: "Codex Migration Report" });
|
||||
return result;
|
||||
}
|
||||
60
extensions/codex/src/migration/helpers.ts
Normal file
60
extensions/codex/src/migration/helpers.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDirectory(filePath: string | undefined): Promise<boolean> {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (await fs.stat(filePath)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveUserHomeDir(): string {
|
||||
return process.env.HOME?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
export function resolveHomePath(value: string): string {
|
||||
if (value === "~") {
|
||||
return resolveUserHomeDir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(resolveUserHomeDir(), value.slice(2));
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
export function sanitizeName(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._-]+/gu, "-")
|
||||
.replaceAll(/^-+|-+$/gu, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export async function readJsonObject(
|
||||
filePath: string | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
148
extensions/codex/src/migration/plan.ts
Normal file
148
extensions/codex/src/migration/plan.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
createMigrationItem,
|
||||
createMigrationManualItem,
|
||||
MIGRATION_REASON_TARGET_EXISTS,
|
||||
summarizeMigrationItems,
|
||||
} from "openclaw/plugin-sdk/migration";
|
||||
import type {
|
||||
MigrationItem,
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { exists, sanitizeName } from "./helpers.js";
|
||||
import { discoverCodexSource, hasCodexSource, type CodexSkillSource } from "./source.js";
|
||||
import { resolveCodexMigrationTargets } from "./targets.js";
|
||||
|
||||
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
|
||||
const base = sanitizeName(skill.name) || "codex-skill";
|
||||
if ((counts.get(base) ?? 0) <= 1) {
|
||||
return base;
|
||||
}
|
||||
const parent = sanitizeName(path.basename(path.dirname(skill.source)));
|
||||
return sanitizeName(["codex", parent, base].filter(Boolean).join("-")) || base;
|
||||
}
|
||||
|
||||
async function buildSkillItems(params: {
|
||||
skills: CodexSkillSource[];
|
||||
workspaceDir: string;
|
||||
overwrite?: boolean;
|
||||
}): Promise<MigrationItem[]> {
|
||||
const baseCounts = new Map<string, number>();
|
||||
for (const skill of params.skills) {
|
||||
const base = sanitizeName(skill.name) || "codex-skill";
|
||||
baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
|
||||
}
|
||||
const resolvedCounts = new Map<string, number>();
|
||||
const planned = params.skills.map((skill) => {
|
||||
const name = uniqueSkillName(skill, baseCounts);
|
||||
resolvedCounts.set(name, (resolvedCounts.get(name) ?? 0) + 1);
|
||||
return { skill, name, target: path.join(params.workspaceDir, "skills", name) };
|
||||
});
|
||||
const items: MigrationItem[] = [];
|
||||
for (const item of planned) {
|
||||
const collides = (resolvedCounts.get(item.name) ?? 0) > 1;
|
||||
const targetExists = await exists(item.target);
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: `skill:${item.name}`,
|
||||
kind: "skill",
|
||||
action: "copy",
|
||||
source: item.skill.source,
|
||||
target: item.target,
|
||||
status: collides ? "conflict" : targetExists && !params.overwrite ? "conflict" : "planned",
|
||||
reason: collides
|
||||
? `multiple Codex skills normalize to "${item.name}"`
|
||||
: targetExists && !params.overwrite
|
||||
? MIGRATION_REASON_TARGET_EXISTS
|
||||
: undefined,
|
||||
message: `Copy ${item.skill.sourceLabel} into this OpenClaw agent workspace.`,
|
||||
details: {
|
||||
skillName: item.name,
|
||||
sourceLabel: item.skill.sourceLabel,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function buildCodexMigrationPlan(
|
||||
ctx: MigrationProviderContext,
|
||||
): Promise<MigrationPlan> {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
if (!hasCodexSource(source)) {
|
||||
throw new Error(
|
||||
`Codex state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
|
||||
);
|
||||
}
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
const items: MigrationItem[] = [];
|
||||
items.push(
|
||||
...(await buildSkillItems({
|
||||
skills: source.skills,
|
||||
workspaceDir: targets.workspaceDir,
|
||||
overwrite: ctx.overwrite,
|
||||
})),
|
||||
);
|
||||
for (const [index, plugin] of source.plugins.entries()) {
|
||||
items.push(
|
||||
createMigrationManualItem({
|
||||
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${index + 1}`,
|
||||
source: plugin.source,
|
||||
message: `Codex native plugin "${plugin.name}" was found but not activated automatically.`,
|
||||
recommendation:
|
||||
"Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install <path>.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const archivePath of source.archivePaths) {
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: archivePath.id,
|
||||
kind: "archive",
|
||||
action: "archive",
|
||||
source: archivePath.path,
|
||||
message:
|
||||
archivePath.message ??
|
||||
"Archived in the migration report for manual review; not imported into live config.",
|
||||
details: { archiveRelativePath: archivePath.relativePath },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const warnings = [
|
||||
...(items.some((item) => item.status === "conflict")
|
||||
? [
|
||||
"Conflicts were found. Re-run with --overwrite to replace conflicting skill targets after item-level backups.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.length > 0
|
||||
? [
|
||||
"Codex native plugins are reported for manual review only. OpenClaw does not auto-activate plugin bundles, hooks, MCP servers, or apps from another Codex home.",
|
||||
]
|
||||
: []),
|
||||
...(source.archivePaths.length > 0
|
||||
? [
|
||||
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return {
|
||||
providerId: "codex",
|
||||
source: source.root,
|
||||
target: targets.workspaceDir,
|
||||
summary: summarizeMigrationItems(items),
|
||||
items,
|
||||
warnings,
|
||||
nextSteps: [
|
||||
"Run openclaw doctor after applying the migration.",
|
||||
"Review skipped Codex plugin/config/hook items before installing or recreating them in OpenClaw.",
|
||||
],
|
||||
metadata: {
|
||||
agentDir: targets.agentDir,
|
||||
codexHome: source.codexHome,
|
||||
codexSkillsDir: source.codexSkillsDir,
|
||||
personalAgentsSkillsDir: source.personalAgentsSkillsDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
219
extensions/codex/src/migration/provider.test.ts
Normal file
219
extensions/codex/src/migration/provider.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
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, describe, expect, it, vi } from "vitest";
|
||||
import { buildCodexMigrationProvider } from "./provider.js";
|
||||
|
||||
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;
|
||||
}): MigrationProviderContext {
|
||||
return {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: params.workspaceDir,
|
||||
},
|
||||
},
|
||||
} as MigrationProviderContext["config"],
|
||||
source: params.source,
|
||||
stateDir: params.stateDir,
|
||||
overwrite: params.overwrite,
|
||||
reportDir: params.reportDir,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
for (const root of tempRoots) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
tempRoots.clear();
|
||||
});
|
||||
|
||||
describe("buildCodexMigrationProvider", () => {
|
||||
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);
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "skill:tweet-helper",
|
||||
kind: "skill",
|
||||
action: "copy",
|
||||
status: "planned",
|
||||
target: path.join(fixture.workspaceDir, "skills", "tweet-helper"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "skill:personal-style",
|
||||
kind: "skill",
|
||||
action: "copy",
|
||||
status: "planned",
|
||||
target: path.join(fixture.workspaceDir, "skills", "personal-style"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "plugin:documents:1",
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
status: "skipped",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "archive:config.toml",
|
||||
kind: "archive",
|
||||
action: "archive",
|
||||
status: "planned",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "archive:hooks/hooks.json",
|
||||
kind: "archive",
|
||||
action: "archive",
|
||||
status: "planned",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(plan.items).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "skill:system-skill" })]),
|
||||
);
|
||||
expect(plan.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Codex native plugins are reported for manual review only"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "plugin:documents:1", status: "skipped" }),
|
||||
expect.objectContaining({ id: "skill:tweet-helper", status: "migrated" }),
|
||||
expect.objectContaining({ id: "archive:config.toml", status: "migrated" }),
|
||||
]),
|
||||
);
|
||||
await expect(fs.access(path.join(reportDir, "report.json"))).resolves.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,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "skill:tweet-helper", status: "conflict" }),
|
||||
]),
|
||||
);
|
||||
expect(overwritePlan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "skill:tweet-helper", status: "planned" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
28
extensions/codex/src/migration/provider.ts
Normal file
28
extensions/codex/src/migration/provider.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { MigrationPlan, MigrationProviderPlugin } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { applyCodexMigrationPlan } from "./apply.js";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
import { discoverCodexSource, hasCodexSource } from "./source.js";
|
||||
|
||||
export function buildCodexMigrationProvider(): MigrationProviderPlugin {
|
||||
return {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description:
|
||||
"Inventory and promote Codex CLI skills while keeping Codex native plugins and hooks explicit.",
|
||||
async detect(ctx) {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
const found = hasCodexSource(source);
|
||||
return {
|
||||
found,
|
||||
source: source.root,
|
||||
label: "Codex",
|
||||
confidence: found ? source.confidence : "low",
|
||||
message: found ? "Codex state found." : "Codex state not found.",
|
||||
};
|
||||
},
|
||||
plan: buildCodexMigrationPlan,
|
||||
async apply(ctx, plan?: MigrationPlan) {
|
||||
return await applyCodexMigrationPlan({ ctx, plan });
|
||||
},
|
||||
};
|
||||
}
|
||||
176
extensions/codex/src/migration/source.ts
Normal file
176
extensions/codex/src/migration/source.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
exists,
|
||||
isDirectory,
|
||||
readJsonObject,
|
||||
resolveHomePath,
|
||||
resolveUserHomeDir,
|
||||
} from "./helpers.js";
|
||||
|
||||
const SKILL_FILENAME = "SKILL.md";
|
||||
const MAX_SCAN_DEPTH = 6;
|
||||
const MAX_DISCOVERED_DIRS = 2000;
|
||||
|
||||
export type CodexSkillSource = {
|
||||
name: string;
|
||||
source: string;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
export type CodexPluginSource = {
|
||||
name: string;
|
||||
source: string;
|
||||
manifestPath: string;
|
||||
};
|
||||
|
||||
export type CodexArchiveSource = {
|
||||
id: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type CodexSource = {
|
||||
root: string;
|
||||
confidence: "low" | "medium" | "high";
|
||||
codexHome: string;
|
||||
codexSkillsDir?: string;
|
||||
personalAgentsSkillsDir?: string;
|
||||
configPath?: string;
|
||||
hooksPath?: string;
|
||||
skills: CodexSkillSource[];
|
||||
plugins: CodexPluginSource[];
|
||||
archivePaths: CodexArchiveSource[];
|
||||
};
|
||||
|
||||
function defaultCodexHome(): string {
|
||||
return resolveHomePath(process.env.CODEX_HOME?.trim() || "~/.codex");
|
||||
}
|
||||
|
||||
function personalAgentsSkillsDir(): string {
|
||||
return path.join(resolveUserHomeDir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
async function safeReadDir(dir: string): Promise<Dirent[]> {
|
||||
return await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
}
|
||||
|
||||
async function discoverSkillDirs(params: {
|
||||
root: string | undefined;
|
||||
sourceLabel: string;
|
||||
excludeSystem?: boolean;
|
||||
}): Promise<CodexSkillSource[]> {
|
||||
if (!params.root || !(await isDirectory(params.root))) {
|
||||
return [];
|
||||
}
|
||||
const discovered: CodexSkillSource[] = [];
|
||||
async function visit(dir: string, depth: number): Promise<void> {
|
||||
if (discovered.length >= MAX_DISCOVERED_DIRS || depth > MAX_SCAN_DEPTH) {
|
||||
return;
|
||||
}
|
||||
const name = path.basename(dir);
|
||||
if (params.excludeSystem && depth === 1 && name === ".system") {
|
||||
return;
|
||||
}
|
||||
if (await exists(path.join(dir, SKILL_FILENAME))) {
|
||||
discovered.push({ name, source: dir, sourceLabel: params.sourceLabel });
|
||||
return;
|
||||
}
|
||||
for (const entry of await safeReadDir(dir)) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
await visit(path.join(dir, entry.name), depth + 1);
|
||||
}
|
||||
}
|
||||
await visit(params.root, 0);
|
||||
return discovered;
|
||||
}
|
||||
|
||||
async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[]> {
|
||||
const root = path.join(codexHome, "plugins", "cache");
|
||||
if (!(await isDirectory(root))) {
|
||||
return [];
|
||||
}
|
||||
const discovered = new Map<string, CodexPluginSource>();
|
||||
async function visit(dir: string, depth: number): Promise<void> {
|
||||
if (discovered.size >= MAX_DISCOVERED_DIRS || depth > MAX_SCAN_DEPTH) {
|
||||
return;
|
||||
}
|
||||
const manifestPath = path.join(dir, ".codex-plugin", "plugin.json");
|
||||
if (await exists(manifestPath)) {
|
||||
const manifest = await readJsonObject(manifestPath);
|
||||
const manifestName = typeof manifest.name === "string" ? manifest.name.trim() : "";
|
||||
const name = manifestName || path.basename(dir);
|
||||
discovered.set(dir, { name, source: dir, manifestPath });
|
||||
return;
|
||||
}
|
||||
for (const entry of await safeReadDir(dir)) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
await visit(path.join(dir, entry.name), depth + 1);
|
||||
}
|
||||
}
|
||||
await visit(root, 0);
|
||||
return [...discovered.values()].toSorted((a, b) => a.source.localeCompare(b.source));
|
||||
}
|
||||
|
||||
export async function discoverCodexSource(input?: string): Promise<CodexSource> {
|
||||
const codexHome = resolveHomePath(input?.trim() || defaultCodexHome());
|
||||
const codexSkillsDir = path.join(codexHome, "skills");
|
||||
const agentsSkillsDir = personalAgentsSkillsDir();
|
||||
const configPath = path.join(codexHome, "config.toml");
|
||||
const hooksPath = path.join(codexHome, "hooks", "hooks.json");
|
||||
const codexSkills = await discoverSkillDirs({
|
||||
root: codexSkillsDir,
|
||||
sourceLabel: "Codex CLI skill",
|
||||
excludeSystem: true,
|
||||
});
|
||||
const personalAgentSkills = await discoverSkillDirs({
|
||||
root: agentsSkillsDir,
|
||||
sourceLabel: "personal AgentSkill",
|
||||
});
|
||||
const plugins = await discoverPluginDirs(codexHome);
|
||||
const archivePaths: CodexArchiveSource[] = [];
|
||||
if (await exists(configPath)) {
|
||||
archivePaths.push({
|
||||
id: "archive:config.toml",
|
||||
path: configPath,
|
||||
relativePath: "config.toml",
|
||||
message: "Codex config is archived for manual review; it is not activated automatically.",
|
||||
});
|
||||
}
|
||||
if (await exists(hooksPath)) {
|
||||
archivePaths.push({
|
||||
id: "archive:hooks/hooks.json",
|
||||
path: hooksPath,
|
||||
relativePath: "hooks/hooks.json",
|
||||
message:
|
||||
"Codex native hooks are archived for manual review because they can execute commands.",
|
||||
});
|
||||
}
|
||||
const skills = [...codexSkills, ...personalAgentSkills].toSorted((a, b) =>
|
||||
a.source.localeCompare(b.source),
|
||||
);
|
||||
const high = Boolean(codexSkills.length || plugins.length || archivePaths.length);
|
||||
const medium = personalAgentSkills.length > 0;
|
||||
return {
|
||||
root: codexHome,
|
||||
confidence: high ? "high" : medium ? "medium" : "low",
|
||||
codexHome,
|
||||
...((await isDirectory(codexSkillsDir)) ? { codexSkillsDir } : {}),
|
||||
...((await isDirectory(agentsSkillsDir)) ? { personalAgentsSkillsDir: agentsSkillsDir } : {}),
|
||||
...((await exists(configPath)) ? { configPath } : {}),
|
||||
...((await exists(hooksPath)) ? { hooksPath } : {}),
|
||||
skills,
|
||||
plugins,
|
||||
archivePaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasCodexSource(source: CodexSource): boolean {
|
||||
return source.confidence !== "low";
|
||||
}
|
||||
25
extensions/codex/src/migration/targets.ts
Normal file
25
extensions/codex/src/migration/targets.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resolveHomePath } from "./helpers.js";
|
||||
|
||||
export type CodexMigrationTargets = {
|
||||
workspaceDir: string;
|
||||
agentDir: string;
|
||||
};
|
||||
|
||||
export function resolveCodexMigrationTargets(ctx: MigrationProviderContext): CodexMigrationTargets {
|
||||
const cfg = ctx.config;
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const configuredAgentDir = resolveAgentConfig(cfg, agentId)?.agentDir?.trim();
|
||||
const agentDir =
|
||||
ctx.runtime?.agent?.resolveAgentDir(cfg, agentId) ??
|
||||
(configuredAgentDir ? resolveHomePath(configuredAgentDir) : undefined) ??
|
||||
path.join(ctx.stateDir, "agents", agentId, "agent");
|
||||
return { workspaceDir, agentDir };
|
||||
}
|
||||
Reference in New Issue
Block a user