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:
pashpashpash
2026-04-30 12:49:02 -07:00
committed by GitHub
parent 7d77680d9f
commit 027ea5f08b
35 changed files with 2299 additions and 49 deletions

View File

@@ -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));
});

View File

@@ -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, {

View File

@@ -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": {

View File

@@ -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 });
}

View File

@@ -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: {

View 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;
}

View 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 {};
}
}

View 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,
},
};
}

View 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" }),
]),
);
});
});

View 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 });
},
};
}

View 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";
}

View 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 };
}