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

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when `contextTokens` is larger than native `contextWindow`. Fixes #74917. Thanks @kAIborg24.
- Gateway/systemd: exit with sysexits 78 for supervised lock and `EADDRINUSE` conflicts so `RestartPreventExitStatus=78` stops `Restart=always` restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt.
- Agents/runtime: skip blank visible user prompts at the embedded-runner boundary before provider submission while still allowing internal runtime-only turns and media-only prompts, so Telegram/group sessions no longer leak raw empty-input provider errors when replay history exists. Fixes #74137. Thanks @yelog, @Gracker, and @nhaener.
- Agents/Codex: isolate local Codex app-server `CODEX_HOME` and `HOME` per agent and add a deliberate Codex migration path with selectable skill copies, so personal Codex CLI skills, plugins, config, and hooks no longer leak into OpenClaw agents unless the operator migrates them into the workspace. Thanks @pashpashpash.
- Plugins/runtime-deps: replace stale symlinked mirror target roots before writing runtime-mirror temp files and skip rewriting already materialized hardlinks, so cross-version container upgrades no longer crash-loop on read-only image-layer paths while warm mirrors do less churn. Fixes #75108; refs #75069. Thanks @coletebou and @xiaohuaxi.
- Auto-reply/group chats: fall back to automatic source delivery when a channel precomputes message-tool-only replies but the `message` tool is unavailable, so Discord/Slack-style group turns do not silently complete without a visible reply. Fixes #74868. Thanks @kagura-agent.
- Browser/gateway: share one browser control runtime across the HTTP control server and `browser.request`, and refresh browser profile config from the source snapshot, so CLI status/start honors configured `browser.executablePath`, `headless`, and `noSandbox` instead of falling back to stale auto-detection. Fixes #75087; repairs #73617. Thanks @civiltox and @martingarramon.

View File

@@ -52,6 +52,7 @@ Notes:
- Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order.
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
- Doctor warns when no command owner is configured. The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions. DM pairing only lets someone talk to the bot; if you approved a sender before first-owner bootstrap existed, set `commands.ownerAllowFrom` explicitly.
- Doctor warns when Codex-mode agents are configured and personal Codex CLI assets exist in the operator's Codex home. Local Codex app-server launches use isolated per-agent homes, so use `openclaw migrate codex --dry-run` to inventory assets that should be promoted deliberately.
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early.

View File

@@ -8,7 +8,7 @@ title: "Migrate"
# `openclaw migrate`
Import state from another agent system through a plugin-owned migration provider. Bundled providers cover [Claude](/install/migrating-claude) and [Hermes](/install/migrating-hermes); third-party plugins can register additional providers.
Import state from another agent system through a plugin-owned migration provider. Bundled providers cover Codex CLI state, [Claude](/install/migrating-claude), and [Hermes](/install/migrating-hermes); third-party plugins can register additional providers.
<Tip>
For user-facing walkthroughs, see [Migrating from Claude](/install/migrating-claude) and [Migrating from Hermes](/install/migrating-hermes). The [migration hub](/install/migrating) lists all paths.
@@ -19,8 +19,12 @@ For user-facing walkthroughs, see [Migrating from Claude](/install/migrating-cla
```bash
openclaw migrate list
openclaw migrate claude --dry-run
openclaw migrate codex --dry-run
openclaw migrate codex --skill gog-vault77-google-workspace
openclaw migrate hermes --dry-run
openclaw migrate hermes
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
openclaw migrate apply codex --yes
openclaw migrate apply claude --yes
openclaw migrate apply hermes --yes
openclaw migrate apply hermes --include-secrets --yes
@@ -47,6 +51,9 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
<ParamField path="--yes" type="boolean">
Skip the confirmation prompt. Required in non-interactive mode.
</ParamField>
<ParamField path="--skill <name>" type="string">
Select one skill copy item by skill name or item id. Repeat the flag to migrate multiple skills. When omitted, interactive Codex migrations show a checkbox selector and non-interactive migrations keep all planned skills.
</ParamField>
<ParamField path="--no-backup" type="boolean">
Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists.
</ParamField>
@@ -99,6 +106,43 @@ For a user-facing walkthrough, see [Migrating from Claude](/install/migrating-cl
Claude hooks, permissions, environment defaults, local memory, path-scoped rules, subagents, caches, plans, and project history are preserved in the migration report or reported as manual-review items. OpenClaw does not execute hooks, copy broad allowlists, or import OAuth/Desktop credential state automatically.
## Codex provider
The bundled Codex provider detects Codex CLI state at `~/.codex` by default, or
at `CODEX_HOME` when that environment variable is set. Use `--from <path>` to
inventory a specific Codex home.
Use this provider when moving to the OpenClaw Codex harness and you want to
promote useful personal Codex CLI assets deliberately. Local Codex app-server
launches use per-agent `CODEX_HOME` and `HOME` directories, so they do not read
your personal Codex CLI state by default.
Running `openclaw migrate codex` in an interactive terminal previews the full
plan, then opens a checkbox selector for skill copy items before the final
apply confirmation. All skills start selected; uncheck any skill you do not want
copied into this agent. For scripted or exact runs, pass `--skill <name>` once
per skill, for example:
```bash
openclaw migrate codex --dry-run --skill gog-vault77-google-workspace
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
```
### What Codex imports
- Codex CLI skill directories under `$CODEX_HOME/skills`, excluding Codex's
`.system` cache.
- Personal AgentSkills under `$HOME/.agents/skills`, copied into the current
OpenClaw agent workspace when you want per-agent ownership.
### Manual-review Codex state
Codex native plugins, `config.toml`, and native `hooks/hooks.json` are not
activated automatically. Plugins may expose MCP servers, apps, hooks, or other
executable behavior, so the provider reports them for review instead of loading
them into OpenClaw. Config and hook files are copied into the migration report
for manual review.
## Hermes provider
The bundled Hermes provider detects state at `~/.hermes` by default. Use `--from <path>` when Hermes lives elsewhere.

View File

@@ -108,6 +108,7 @@ These live under `~/.openclaw/` and should NOT be committed to the workspace rep
- `~/.openclaw/openclaw.json` (config)
- `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (model auth profiles: OAuth + API keys)
- `~/.openclaw/agents/<agentId>/agent/codex-home/` (per-agent Codex runtime account, config, skills, plugins, and native thread state)
- `~/.openclaw/credentials/` (channel/provider state plus legacy OAuth import data)
- `~/.openclaw/agents/<agentId>/sessions/` (session transcripts + metadata)
- `~/.openclaw/skills/` (managed skills)

View File

@@ -236,6 +236,7 @@ Use this when auditing access or deciding what to back up:
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
- **Codex runtime state**: `~/.openclaw/agents/<agentId>/agent/codex-home/`
- **File-backed secrets payload (optional)**: `~/.openclaw/secrets.json`
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
@@ -965,6 +966,7 @@ Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain sec
- `openclaw.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
- `agents/<agentId>/agent/auth-profiles.json`: API keys, token profiles, OAuth tokens, and optional `keyRef`/`tokenRef`.
- `agents/<agentId>/agent/codex-home/**`: per-agent Codex app-server account, config, skills, plugins, native thread state, and diagnostics.
- `secrets.json` (optional): file-backed secret payload used by `file` SecretRef providers (`secrets.providers`).
- `agents/<agentId>/agent/auth.json`: legacy compatibility file. Static `api_key` entries are scrubbed when discovered.
- `agents/<agentId>/sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output.

View File

@@ -180,7 +180,10 @@ Codex after changing config.
Codex app-server binary by default, so local `codex` commands on `PATH` do
not affect normal harness startup.
- Codex auth available to the app-server process or to OpenClaw's Codex auth
bridge.
bridge. Local app-server launches use an OpenClaw-managed Codex home for each
agent and an isolated child `HOME`, so they do not read your personal
`~/.codex` account, skills, plugins, config, thread state, or native
`$HOME/.agents/skills` by default.
The plugin blocks older or unversioned app-server handshakes. That keeps
OpenClaw on the protocol surface it has been tested against.
@@ -511,11 +514,33 @@ For an already-running app-server, use WebSocket transport:
```
Stdio app-server launches inherit OpenClaw's process environment by default,
but OpenClaw owns the Codex app-server account bridge. Auth is selected in this
order:
but OpenClaw owns the Codex app-server account bridge and sets both
`CODEX_HOME` and `HOME` to per-agent directories under that agent's OpenClaw
state. Codex's own skill loader reads `$CODEX_HOME/skills` and
`$HOME/.agents/skills`, so both values are isolated for local app-server
launches. That keeps Codex-native skills, plugins, config, accounts, and thread
state scoped to the OpenClaw agent instead of leaking in from the operator's
personal Codex CLI home.
OpenClaw plugins and OpenClaw skill snapshots still flow through OpenClaw's own
plugin registry and skill loader. Personal Codex CLI assets do not. If you have
useful Codex CLI skills or plugins that should become part of an OpenClaw agent,
inventory them explicitly:
```bash
openclaw migrate codex --dry-run
openclaw migrate apply codex --yes
```
The Codex migration provider copies skills into the current OpenClaw agent
workspace. Codex native plugins, hooks, and config files are reported or archived
for manual review instead of being activated automatically, because they can
execute commands, expose MCP servers, or carry credentials.
Auth is selected in this order:
1. An explicit OpenClaw Codex auth profile for the agent.
2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in.
2. The app-server's existing account in that agent's Codex home.
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
`OPENAI_API_KEY`, when no app-server account is present and OpenAI auth is
still required.
@@ -553,21 +578,21 @@ If a deployment needs additional environment isolation, add those variables to
Supported `appServer` fields:
| Field | Default | Meaning |
| ------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
| `approvalsReviewer` | `"user"` | Use `"auto_review"` to let Codex review native approval prompts. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
| Field | Default | Meaning |
| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. `CODEX_HOME` and `HOME` are reserved for OpenClaw's per-agent Codex isolation on local launches. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
| `approvalsReviewer` | `"user"` | Use `"auto_review"` to let Codex review native approval prompts. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: each Codex `item/tool/call` request must receive

View File

@@ -29,6 +29,14 @@ OpenClaw loads skills from these sources, **highest precedence first**:
If a skill name conflicts, the highest source wins.
Codex CLI's native `$CODEX_HOME/skills` directory is not one of these OpenClaw
skill roots. In Codex harness mode, local app-server launches use isolated
per-agent Codex homes, so personal Codex CLI skills are not loaded implicitly.
Use `openclaw migrate codex --dry-run` to inventory them and
`openclaw migrate codex` to choose skill directories with an interactive
checkbox prompt before copying them into the current OpenClaw agent workspace.
For non-interactive runs, repeat `--skill <name>` for the exact skills to copy.
## Per-agent vs shared skills
In **multi-agent** setups each agent has its own workspace:

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

View File

@@ -78,7 +78,8 @@ function hasQueuedReactionEventFor(sender: string) {
typeof options === "object" &&
options !== null &&
"sessionKey" in options &&
(options as { sessionKey?: string }).sessionKey === route.sessionKey
(options as { sessionKey?: string; trusted?: boolean }).sessionKey === route.sessionKey &&
(options as { trusted?: boolean }).trusted === false
);
});
}

View File

@@ -28,6 +28,16 @@ import {
createSlackTestAccount,
} from "./prepare.test-helpers.js";
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/system-event-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/system-event-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
describe("slack prepareSlackMessage inbound contract", () => {
const storeFixture = createSlackSessionStoreFixture("openclaw-slack-thread-");
@@ -39,6 +49,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
resetSlackThreadStarterCacheForTest();
clearSlackThreadParticipationCache();
clearSlackAllowFromCacheForTest();
enqueueSystemEventMock.mockClear();
});
afterAll(() => {
@@ -132,6 +143,20 @@ describe("slack prepareSlackMessage inbound contract", () => {
});
}
it("queues inbound message system events as untrusted", async () => {
const prepared = await prepareWithDefaultCtx(createSlackMessage({}));
expect(prepared).toBeTruthy();
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining("Slack DM from Alice: hi"),
expect.objectContaining({
sessionKey: expect.any(String),
contextKey: "slack:message:D123:1.000",
trusted: false,
}),
);
});
function createThreadSlackCtx(params: { cfg: OpenClawConfig; replies: unknown }) {
return createInboundSlackCtx({
cfg: params.cfg,

View File

@@ -764,6 +764,73 @@ describe("embedded attempt harness pinning", () => {
);
});
it("auto-forwards OpenAI Codex auth profiles to configured Codex harness runs", async () => {
const sessionEntry: SessionEntry = {
sessionId: "codex-auth-session",
updatedAt: Date.now(),
};
await fs.writeFile(
path.join(tmpDir, "auth-profiles.json"),
JSON.stringify({
version: 1,
profiles: {
"openai-codex:work": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
}),
);
runEmbeddedPiAgentMock.mockResolvedValueOnce({
meta: { durationMs: 1 },
} satisfies EmbeddedPiRunResult);
await runAgentAttempt({
providerOverride: "openai",
originalProvider: "openai",
modelOverride: "gpt-5.4",
cfg: {
agents: {
defaults: {
agentRuntime: { id: "codex", fallback: "none" },
},
},
} as OpenClawConfig,
sessionEntry,
sessionId: sessionEntry.sessionId,
sessionKey: "agent:main:main",
sessionAgentId: "main",
sessionFile: path.join(tmpDir, "session.jsonl"),
workspaceDir: tmpDir,
body: "continue",
isFallbackRetry: false,
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-codex-auto-auth-profile",
opts: { senderIsOwner: false } as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: undefined,
skillsSnapshot: undefined,
resolvedVerboseLevel: undefined,
agentDir: tmpDir,
onAgentEvent: vi.fn(),
authProfileProvider: "openai",
sessionHasHistory: true,
});
expect(runEmbeddedPiAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentHarnessId: "codex",
authProfileId: "openai-codex:work",
authProfileIdSource: "auto",
}),
);
});
it("pins a fresh unpinned session to the default PI harness", async () => {
const sessionEntry: SessionEntry = {
sessionId: "fresh-session",

View File

@@ -11,6 +11,8 @@ import { annotateInterSessionPromptText } from "../../sessions/input-provenance.
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { resolveMessageChannel } from "../../utils/message-channel.js";
import { resolveAuthProfileOrder } from "../auth-profiles/order.js";
import { ensureAuthProfileStore } from "../auth-profiles/store.js";
import { resolveBootstrapWarningSignaturesSeen } from "../bootstrap-budget.js";
import { runCliAgent } from "../cli-runner.js";
import { getCliSessionBinding, setCliSessionBinding } from "../cli-session.js";
@@ -85,6 +87,82 @@ type PersistTextTurnTranscriptParams = {
};
};
type HarnessAuthProfileSelection = {
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
authProfileProvider: string;
};
function resolveProfileProviderFromStore(params: {
agentDir: string;
profileId: string | undefined;
}): string | undefined {
const profileId = params.profileId?.trim();
if (!profileId) {
return undefined;
}
return ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
}).profiles[profileId]?.provider;
}
function resolveHarnessAuthProfileSelection(params: {
config: OpenClawConfig;
agentDir: string;
workspaceDir: string;
provider: string;
authProfileProvider: string;
sessionAuthProfileId?: string;
sessionAuthProfileSource?: "auto" | "user";
harnessId?: string;
harnessRuntime?: string;
allowHarnessAuthProfileForwarding: boolean;
}): HarnessAuthProfileSelection {
const sessionAuthProfileId = params.sessionAuthProfileId?.trim();
if (sessionAuthProfileId) {
return {
authProfileId: sessionAuthProfileId,
authProfileIdSource: params.sessionAuthProfileSource,
authProfileProvider:
resolveProfileProviderFromStore({
agentDir: params.agentDir,
profileId: sessionAuthProfileId,
}) ?? params.authProfileProvider,
};
}
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
provider: params.provider,
authProfileProvider: params.authProfileProvider,
config: params.config,
workspaceDir: params.workspaceDir,
harnessId: params.harnessId,
harnessRuntime: params.harnessRuntime,
allowHarnessAuthProfileForwarding: params.allowHarnessAuthProfileForwarding,
});
const harnessAuthProvider = runtimeAuthPlan.harnessAuthProvider;
if (!harnessAuthProvider) {
return { authProfileProvider: params.authProfileProvider };
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const authProfileId = resolveAuthProfileOrder({
cfg: params.config,
store,
provider: harnessAuthProvider,
})[0];
return authProfileId
? {
authProfileId,
authProfileIdSource: "auto",
authProfileProvider: harnessAuthProvider,
}
: { authProfileProvider: params.authProfileProvider };
}
function resolveTranscriptUsage(usage: PersistTextTurnTranscriptParams["assistant"]["usage"]) {
if (!usage) {
return ACP_TRANSCRIPT_USAGE;
@@ -320,10 +398,22 @@ export function runAgentAttempt(params: {
agentId: params.sessionAgentId,
sessionKey: params.sessionKey ?? params.sessionId,
});
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
const harnessAuthSelection = resolveHarnessAuthProfileSelection({
config: params.cfg,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
provider: params.providerOverride,
authProfileProvider: params.authProfileProvider,
sessionAuthProfileId: params.sessionEntry?.authProfileOverride,
sessionAuthProfileSource: params.sessionEntry?.authProfileOverrideSource,
harnessId: sessionPinnedAgentHarnessId,
harnessRuntime: agentHarnessPolicy.runtime,
allowHarnessAuthProfileForwarding: !isCliProvider(cliExecutionProvider, params.cfg),
});
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
provider: params.providerOverride,
authProfileProvider: harnessAuthSelection.authProfileProvider,
sessionAuthProfileId: harnessAuthSelection.authProfileId,
config: params.cfg,
workspaceDir: params.workspaceDir,
harnessId: sessionPinnedAgentHarnessId,
@@ -484,7 +574,7 @@ export function runAgentAttempt(params: {
provider: params.providerOverride,
model: params.modelOverride,
authProfileId,
authProfileIdSource: authProfileId ? params.sessionEntry?.authProfileOverrideSource : undefined,
authProfileIdSource: authProfileId ? harnessAuthSelection.authProfileIdSource : undefined,
thinkLevel: params.resolvedThinkLevel,
verboseLevel: params.resolvedVerboseLevel,
timeoutMs: params.timeoutMs,

View File

@@ -21,6 +21,7 @@ import {
mockedGlobalHookRunner,
mockedGetApiKeyForModel,
mockedPickFallbackThinkingLevel,
mockedResolveAuthProfileOrder,
mockedResolveContextWindowInfo,
mockedResolveFailoverStatus,
mockedRunContextEngineMaintenance,
@@ -376,6 +377,156 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(harnessParams?.runtimePlan).toBe(runtimePlan);
});
it("keeps auto-selected OpenAI Codex auth profiles for forced codex harness runs", async () => {
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () =>
makeAttemptResult({ assistantTexts: ["ok"] }),
);
const runtimePlan = makeForwardedRuntimePlan({
resolvedRef: {
provider: "openai",
modelId: "gpt-5.5",
harnessId: "codex",
},
auth: {
providerForAuth: "openai",
harnessAuthProvider: "openai-codex",
forwardedAuthProfileId: "openai-codex:default",
},
});
clearAgentHarnesses();
registerAgentHarness({
id: "codex",
label: "Codex",
supports: () => ({ supported: false }),
runAttempt: pluginRunAttempt,
});
mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan);
mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped"));
try {
await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
config: {
agents: {
defaults: {
agentRuntime: { id: "codex", fallback: "none" },
},
},
},
authProfileId: "openai-codex:default",
authProfileIdSource: "auto",
runId: "forced-codex-harness-keeps-auto-openai-codex-auth",
});
} finally {
clearAgentHarnesses();
}
expect(mockedGetApiKeyForModel).not.toHaveBeenCalled();
expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1);
expect(pluginRunAttempt).toHaveBeenCalledTimes(1);
expect(pluginRunAttempt).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
authProfileId: "openai-codex:default",
authProfileIdSource: "auto",
runtimePlan: expect.objectContaining({
resolvedRef: expect.objectContaining({
provider: "openai",
modelId: "gpt-5.5",
harnessId: "codex",
}),
auth: expect.objectContaining({
providerForAuth: "openai",
harnessAuthProvider: "openai-codex",
forwardedAuthProfileId: "openai-codex:default",
}),
}),
}),
);
const harnessParams = pluginRunAttempt.mock.calls[0]?.[0];
expect(harnessParams?.runtimePlan).toBe(runtimePlan);
});
it("auto-selects OpenAI Codex auth profiles for forced codex harness channel runs", async () => {
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () =>
makeAttemptResult({ assistantTexts: ["ok"] }),
);
const runtimePlan = makeForwardedRuntimePlan({
resolvedRef: {
provider: "openai",
modelId: "gpt-5.5",
harnessId: "codex",
},
auth: {
providerForAuth: "openai",
harnessAuthProvider: "openai-codex",
forwardedAuthProfileId: "openai-codex:default",
},
});
clearAgentHarnesses();
registerAgentHarness({
id: "codex",
label: "Codex",
supports: () => ({ supported: false }),
runAttempt: pluginRunAttempt,
});
mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan);
mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped"));
mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai-codex:default"]);
try {
await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
config: {
agents: {
defaults: {
agentRuntime: { id: "codex", fallback: "none" },
},
},
},
runId: "forced-codex-harness-auto-selects-openai-codex-auth",
});
} finally {
clearAgentHarnesses();
}
expect(mockedGetApiKeyForModel).not.toHaveBeenCalled();
expect(mockedResolveAuthProfileOrder).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai-codex",
}),
);
expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1);
expect(pluginRunAttempt).toHaveBeenCalledTimes(1);
expect(pluginRunAttempt).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
authProfileId: "openai-codex:default",
authProfileIdSource: "auto",
runtimePlan: expect.objectContaining({
resolvedRef: expect.objectContaining({
provider: "openai",
modelId: "gpt-5.5",
harnessId: "codex",
}),
auth: expect.objectContaining({
providerForAuth: "openai",
harnessAuthProvider: "openai-codex",
forwardedAuthProfileId: "openai-codex:default",
}),
}),
}),
);
const harnessParams = pluginRunAttempt.mock.calls[0]?.[0];
expect(harnessParams?.runtimePlan).toBe(runtimePlan);
});
it("blocks undersized models before dispatching a provider attempt", async () => {
mockedResolveContextWindowInfo.mockReturnValue({
tokens: 800,

View File

@@ -511,19 +511,60 @@ export async function runEmbeddedPiAgent(
: ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const preferredProfileId = params.authProfileId?.trim();
const requestedProfileId = params.authProfileId?.trim();
const resolvePluginHarnessPreferredProfileId = (): string | undefined => {
if (requestedProfileId) {
return requestedProfileId;
}
if (!pluginHarnessOwnsTransport) {
return undefined;
}
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
provider,
config: params.config,
workspaceDir: resolvedWorkspace,
harnessId: agentHarness.id,
harnessRuntime: agentHarness.id,
allowHarnessAuthProfileForwarding: true,
});
const harnessAuthProvider = runtimeAuthPlan.harnessAuthProvider;
if (!harnessAuthProvider) {
return undefined;
}
const harnessAuthStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
return resolveAuthProfileOrder({
cfg: params.config,
store: harnessAuthStore,
provider: harnessAuthProvider,
})[0]?.trim();
};
const preferredProfileId = pluginHarnessOwnsTransport
? resolvePluginHarnessPreferredProfileId()
: requestedProfileId;
let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined;
const canForwardPluginHarnessAuthProfile = (
profileId: string | undefined,
): profileId is string => {
if (!pluginHarnessOwnsTransport || !profileId) {
return false;
}
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
provider,
authProfileProvider: profileId.split(":", 1)[0],
sessionAuthProfileId: profileId,
config: params.config,
workspaceDir: resolvedWorkspace,
harnessId: agentHarness.id,
harnessRuntime: agentHarness.id,
allowHarnessAuthProfileForwarding: true,
});
return runtimeAuthPlan.forwardedAuthProfileId === profileId;
};
if (lockedProfileId) {
if (pluginHarnessOwnsTransport) {
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
provider,
authProfileProvider: lockedProfileId.split(":", 1)[0],
sessionAuthProfileId: lockedProfileId,
config: params.config,
workspaceDir: resolvedWorkspace,
harnessId: agentHarness.id,
});
if (!runtimeAuthPlan.forwardedAuthProfileId) {
if (!canForwardPluginHarnessAuthProfile(lockedProfileId)) {
lockedProfileId = undefined;
}
} else {
@@ -543,6 +584,12 @@ export async function runEmbeddedPiAgent(
}
}
}
const forwardedPluginHarnessProfileId =
pluginHarnessOwnsTransport &&
!lockedProfileId &&
canForwardPluginHarnessAuthProfile(preferredProfileId)
? preferredProfileId
: undefined;
if (lockedProfileId && !pluginHarnessOwnsTransport) {
const eligibility = resolveAuthProfileEligibility({
cfg: params.config,
@@ -662,6 +709,8 @@ export async function runEmbeddedPiAgent(
await initializeAuthProfile();
} else if (lockedProfileId) {
lastProfileId = lockedProfileId;
} else if (forwardedPluginHarnessProfileId) {
lastProfileId = forwardedPluginHarnessProfileId;
}
startupStages.mark("auth");
const { sessionAgentId } = resolveSessionAgentIds({

View File

@@ -10,12 +10,37 @@ import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
function collectMigrationSkill(value: string, previous: string[] | undefined): string[] {
return [...(previous ?? []), value];
}
function readMigrationSkills(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const skills = value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter((item) => item.length > 0);
return skills.length > 0 ? skills : undefined;
}
function addMigrationSkillOption(command: Command): Command {
return command.option(
"--skill <name>",
"Select one skill to migrate by name or item id; repeat for multiple skills",
collectMigrationSkill,
);
}
function addMigrationOptions(command: Command): Command {
return command
.option("--from <path>", "Source directory to migrate from")
.option("--include-secrets", "Import supported credentials and secrets", false)
.option("--overwrite", "Overwrite conflicting target files after item-level backups", false)
.option("--json", "Output JSON", false);
return addMigrationSkillOption(
command
.option("--from <path>", "Source directory to migrate from")
.option("--include-secrets", "Import supported credentials and secrets", false)
.option("--overwrite", "Overwrite conflicting target files after item-level backups", false)
.option("--json", "Output JSON", false),
);
}
export function registerMigrateCommand(program: Command) {
@@ -28,6 +53,11 @@ export function registerMigrateCommand(program: Command) {
.option("--overwrite", "Overwrite conflicting target files after item-level backups", false)
.option("--dry-run", "Preview only; do not apply changes", false)
.option("--yes", "Apply without prompting after preview", false)
.option(
"--skill <name>",
"Select one skill to migrate by name or item id; repeat for multiple skills",
collectMigrationSkill,
)
.option("--backup-output <path>", "Pre-migration backup archive path or directory")
.option("--no-backup", "Skip the pre-migration OpenClaw backup")
.option("--force", "Allow dangerous options such as --no-backup", false)
@@ -56,6 +86,7 @@ export function registerMigrateCommand(program: Command) {
source: opts.from as string | undefined,
includeSecrets: Boolean(opts.includeSecrets),
overwrite: Boolean(opts.overwrite),
skills: readMigrationSkills(opts.skill),
dryRun: Boolean(opts.dryRun),
yes: Boolean(opts.yes),
backupOutput: opts.backupOutput as string | undefined,
@@ -87,6 +118,7 @@ export function registerMigrateCommand(program: Command) {
source: opts.from as string | undefined,
includeSecrets: Boolean(opts.includeSecrets),
overwrite: Boolean(opts.overwrite),
skills: readMigrationSkills(opts.skill),
json: Boolean(opts.json),
});
});
@@ -106,6 +138,7 @@ export function registerMigrateCommand(program: Command) {
source: opts.from as string | undefined,
includeSecrets: Boolean(opts.includeSecrets),
overwrite: Boolean(opts.overwrite),
skills: readMigrationSkills(opts.skill),
yes: Boolean(opts.yes),
backupOutput: opts.backupOutput as string | undefined,
noBackup: opts.backup === false,

View File

@@ -0,0 +1,132 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { collectCodexNativeAssetWarnings, scanCodexNativeAssets } from "./codex-native-assets.js";
const tempRoots = new Set<string>();
async function makeTempRoot(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-codex-assets-"));
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 codexConfig(): OpenClawConfig {
return {
plugins: {
entries: {
codex: { enabled: true },
},
},
agents: {
defaults: {
agentRuntime: {
id: "codex",
},
},
},
} as OpenClawConfig;
}
afterEach(async () => {
for (const root of tempRoots) {
await fs.rm(root, { recursive: true, force: true });
}
tempRoots.clear();
});
describe("scanCodexNativeAssets", () => {
it("finds personal Codex CLI assets that isolated agents will not load implicitly", async () => {
const root = await makeTempRoot();
const codexHome = path.join(root, ".codex");
await writeFile(path.join(codexHome, "skills", "tweet-helper", "SKILL.md"));
await writeFile(path.join(root, ".agents", "skills", "agent-helper", "SKILL.md"));
await writeFile(path.join(codexHome, "skills", ".system", "system-skill", "SKILL.md"));
await writeFile(
path.join(
codexHome,
"plugins",
"cache",
"openai-primary-runtime",
"documents",
"1.0.0",
".codex-plugin",
"plugin.json",
),
"{}",
);
await writeFile(path.join(codexHome, "config.toml"));
await writeFile(path.join(codexHome, "hooks", "hooks.json"));
const hits = await scanCodexNativeAssets({
cfg: codexConfig(),
env: { CODEX_HOME: codexHome, HOME: root },
});
expect(hits).toEqual(
expect.arrayContaining([
{ kind: "skill", path: path.join(codexHome, "skills", "tweet-helper") },
{ kind: "skill", path: path.join(root, ".agents", "skills", "agent-helper") },
{
kind: "plugin",
path: path.join(
codexHome,
"plugins",
"cache",
"openai-primary-runtime",
"documents",
"1.0.0",
),
},
{ kind: "config", path: path.join(codexHome, "config.toml") },
{ kind: "hooks", path: path.join(codexHome, "hooks", "hooks.json") },
]),
);
expect(hits).not.toEqual(
expect.arrayContaining([
{ kind: "skill", path: path.join(codexHome, "skills", ".system", "system-skill") },
]),
);
});
it("does not scan when Codex is not configured", async () => {
const root = await makeTempRoot();
const codexHome = path.join(root, ".codex");
await writeFile(path.join(codexHome, "skills", "tweet-helper", "SKILL.md"));
await writeFile(path.join(root, ".agents", "skills", "agent-helper", "SKILL.md"));
await expect(
scanCodexNativeAssets({
cfg: {} as OpenClawConfig,
env: { CODEX_HOME: codexHome, HOME: root },
}),
).resolves.toEqual([]);
});
});
describe("collectCodexNativeAssetWarnings", () => {
it("points users at explicit Codex migration instead of auto-copying native assets", async () => {
const root = await makeTempRoot();
const codexHome = path.join(root, ".codex");
await writeFile(path.join(root, ".agents", "skills", "agent-helper", "SKILL.md"));
const warnings = await collectCodexNativeAssetWarnings({
cfg: codexConfig(),
env: { CODEX_HOME: codexHome, HOME: root },
});
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("isolated per-agent Codex homes");
expect(warnings[0]).toContain(codexHome);
expect(warnings[0]).toContain(path.join(root, ".agents", "skills"));
expect(warnings[0]).toContain("openclaw migrate codex --dry-run");
expect(warnings[0]).toContain("manual-review only");
});
});

View File

@@ -0,0 +1,215 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
export type CodexNativeAssetHit = {
kind: "skill" | "plugin" | "config" | "hooks";
path: string;
};
const MAX_SCAN_DEPTH = 6;
const MAX_DISCOVERED_DIRS = 2000;
function hasRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
}
function resolveUserHome(env: NodeJS.ProcessEnv): string {
return env.HOME?.trim() || os.homedir();
}
function resolveHomePath(value: string, env: NodeJS.ProcessEnv): string {
if (value === "~") {
return resolveUserHome(env);
}
if (value.startsWith("~/")) {
return path.join(resolveUserHome(env), value.slice(2));
}
return path.resolve(value);
}
function resolveCodexHome(env: NodeJS.ProcessEnv): string {
return resolveHomePath(env.CODEX_HOME?.trim() || "~/.codex", env);
}
function resolvePersonalAgentSkillsDir(env: NodeJS.ProcessEnv): string {
return path.join(resolveUserHome(env), ".agents", "skills");
}
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function isDirectory(filePath: string): Promise<boolean> {
try {
return (await fs.stat(filePath)).isDirectory();
} catch {
return false;
}
}
async function safeReadDir(dir: string): Promise<Dirent[]> {
return await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
}
async function discoverSkillHits(root: string): Promise<CodexNativeAssetHit[]> {
if (!(await isDirectory(root))) {
return [];
}
const hits: CodexNativeAssetHit[] = [];
async function visit(dir: string, depth: number): Promise<void> {
if (hits.length >= MAX_DISCOVERED_DIRS || depth > MAX_SCAN_DEPTH) {
return;
}
if (depth === 1 && path.basename(dir) === ".system") {
return;
}
if (await exists(path.join(dir, "SKILL.md"))) {
hits.push({ kind: "skill", path: dir });
return;
}
for (const entry of await safeReadDir(dir)) {
if (entry.isDirectory()) {
await visit(path.join(dir, entry.name), depth + 1);
}
}
}
await visit(root, 0);
return hits;
}
async function discoverPluginHits(root: string): Promise<CodexNativeAssetHit[]> {
if (!(await isDirectory(root))) {
return [];
}
const hits = new Map<string, CodexNativeAssetHit>();
async function visit(dir: string, depth: number): Promise<void> {
if (hits.size >= MAX_DISCOVERED_DIRS || depth > MAX_SCAN_DEPTH) {
return;
}
if (await exists(path.join(dir, ".codex-plugin", "plugin.json"))) {
hits.set(dir, { kind: "plugin", path: dir });
return;
}
for (const entry of await safeReadDir(dir)) {
if (entry.isDirectory()) {
await visit(path.join(dir, entry.name), depth + 1);
}
}
}
await visit(root, 0);
return [...hits.values()];
}
function isCodexRuntimeConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (normalizeString(env.OPENCLAW_AGENT_RUNTIME) === "codex") {
return true;
}
const defaults = cfg.agents?.defaults;
if (normalizeString(defaults?.agentRuntime?.id) === "codex") {
return true;
}
return (cfg.agents?.list ?? []).some(
(agent) => normalizeString(agent.agentRuntime?.id) === "codex",
);
}
function isCodexPluginConfigured(cfg: OpenClawConfig): boolean {
const plugins = cfg.plugins;
if (plugins?.enabled === false) {
return false;
}
const allow = plugins?.allow;
const allowList = Array.isArray(allow) ? allow.map((entry) => normalizeString(entry)) : undefined;
if (allowList && !allowList.includes("codex")) {
return false;
}
if (allowList?.includes("codex")) {
return true;
}
return hasRecord(plugins?.entries?.codex) && plugins.entries.codex.enabled !== false;
}
function shouldScanCodexNativeAssets(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
return isCodexRuntimeConfigured(cfg, env) || isCodexPluginConfigured(cfg);
}
export async function scanCodexNativeAssets(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<CodexNativeAssetHit[]> {
const env = params.env ?? process.env;
if (!shouldScanCodexNativeAssets(params.cfg, env)) {
return [];
}
const codexHome = resolveCodexHome(env);
const hits = new Map<string, CodexNativeAssetHit>();
function record(hit: CodexNativeAssetHit): void {
hits.set(`${hit.kind}:${hit.path}`, hit);
}
for (const hit of await discoverSkillHits(path.join(codexHome, "skills"))) {
record(hit);
}
for (const hit of await discoverSkillHits(resolvePersonalAgentSkillsDir(env))) {
record(hit);
}
for (const hit of await discoverPluginHits(path.join(codexHome, "plugins", "cache"))) {
record(hit);
}
const configPath = path.join(codexHome, "config.toml");
if (await exists(configPath)) {
record({ kind: "config", path: configPath });
}
const hooksPath = path.join(codexHome, "hooks", "hooks.json");
if (await exists(hooksPath)) {
record({ kind: "hooks", path: hooksPath });
}
return [...hits.values()].toSorted((a, b) => a.path.localeCompare(b.path));
}
function countKind(
hits: readonly CodexNativeAssetHit[],
kind: CodexNativeAssetHit["kind"],
): number {
return hits.filter((hit) => hit.kind === kind).length;
}
function plural(count: number, singular: string): string {
return `${count} ${singular}${count === 1 ? "" : "s"}`;
}
export async function collectCodexNativeAssetWarnings(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<string[]> {
const env = params.env ?? process.env;
const hits = await scanCodexNativeAssets({ cfg: params.cfg, env });
if (hits.length === 0) {
return [];
}
const counts = [
plural(countKind(hits, "skill"), "skill"),
plural(countKind(hits, "plugin"), "plugin"),
plural(countKind(hits, "config"), "config file"),
plural(countKind(hits, "hooks"), "hook file"),
];
return [
[
"- Personal Codex CLI assets were found, but native Codex-mode OpenClaw agents use isolated per-agent Codex homes.",
`- Sources: ${resolveCodexHome(env)} and ${resolvePersonalAgentSkillsDir(env)} (${counts.join(", ")}).`,
"- These assets will not be loaded by the Codex app-server child unless you intentionally promote them.",
"- Run `openclaw migrate codex --dry-run` to inventory them. Applying that migration copies skills into the current OpenClaw agent workspace; Codex plugins, hooks, and config stay manual-review only.",
].join("\n"),
];
}

View File

@@ -145,6 +145,8 @@ export async function collectDoctorPreviewWarnings(params: {
const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js");
warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env }));
}
const { collectCodexNativeAssetWarnings } = await import("./codex-native-assets.js");
warnings.push(...(await collectCodexNativeAssetWarnings({ cfg: params.cfg, env })));
if (hasPluginLoadPaths(params.cfg)) {
const { collectBundledPluginLoadPathWarnings, scanBundledPluginLoadPathMigrations } =

View File

@@ -5,6 +5,10 @@ import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
backupCreateCommand: vi.fn(),
cancelSymbol: Symbol("cancel"),
clackCancel: vi.fn(),
clackIsCancel: vi.fn(),
multiselect: vi.fn(),
promptYesNo: vi.fn(),
provider: {
id: "hermes",
@@ -27,6 +31,12 @@ vi.mock("../cli/prompt.js", () => ({
promptYesNo: mocks.promptYesNo,
}));
vi.mock("@clack/prompts", () => ({
cancel: mocks.clackCancel,
isCancel: mocks.clackIsCancel,
multiselect: mocks.multiselect,
}));
vi.mock("../plugins/migration-provider-runtime.js", () => ({
resolvePluginMigrationProvider: () => mocks.provider,
resolvePluginMigrationProviders: () => [mocks.provider],
@@ -56,6 +66,56 @@ function plan(overrides: Partial<MigrationPlan> = {}): MigrationPlan {
};
}
function codexSkillPlan(overrides: Partial<MigrationPlan> = {}): MigrationPlan {
const items: MigrationPlan["items"] = [
{
id: "skill:alpha",
kind: "skill",
action: "copy",
status: "planned",
source: "/tmp/codex/skills/alpha",
target: "/tmp/openclaw/workspace/skills/alpha",
details: {
skillName: "alpha",
sourceLabel: "Codex CLI skill",
},
},
{
id: "skill:beta",
kind: "skill",
action: "copy",
status: "planned",
source: "/tmp/codex/skills/beta",
target: "/tmp/openclaw/workspace/skills/beta",
details: {
skillName: "beta",
sourceLabel: "Personal AgentSkill",
},
},
{
id: "archive:config.toml",
kind: "archive",
action: "archive",
status: "planned",
},
];
return {
providerId: "codex",
source: "/tmp/codex",
summary: {
total: 3,
planned: 3,
migrated: 0,
skipped: 0,
conflicts: 0,
errors: 0,
sensitive: 0,
},
items,
...overrides,
};
}
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
@@ -75,6 +135,10 @@ describe("migrateApplyCommand", () => {
});
mocks.provider.plan.mockReset();
mocks.provider.apply.mockReset();
mocks.multiselect.mockReset();
mocks.clackCancel.mockReset();
mocks.clackIsCancel.mockReset();
mocks.clackIsCancel.mockImplementation((value) => value === mocks.cancelSymbol);
mocks.promptYesNo.mockReset();
mocks.backupCreateCommand.mockReset();
mocks.backupCreateCommand.mockResolvedValue({ archivePath: "/tmp/openclaw-backup.tgz" });
@@ -126,6 +190,52 @@ describe("migrateApplyCommand", () => {
expect(mocks.provider.apply).toHaveBeenCalledWith(expect.any(Object), planned);
});
it("prompts for Codex skills before interactive default apply", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
});
const planned = codexSkillPlan();
mocks.provider.plan.mockResolvedValue(planned);
mocks.multiselect.mockResolvedValue(["skill:alpha"]);
mocks.promptYesNo.mockResolvedValue(true);
mocks.provider.apply.mockImplementation(async (_ctx, selectedPlan: MigrationPlan) => ({
...selectedPlan,
summary: { ...selectedPlan.summary, planned: 0, migrated: 2 },
items: selectedPlan.items.map((item) =>
item.status === "planned" ? { ...item, status: "migrated" as const } : item,
),
}));
await migrateDefaultCommand(runtime, { provider: "codex" });
expect(mocks.multiselect).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Select Codex skills"),
initialValues: ["skill:alpha", "skill:beta"],
required: false,
options: expect.arrayContaining([
expect.objectContaining({ value: "skill:alpha", label: "alpha" }),
expect.objectContaining({ value: "skill:beta", label: "beta" }),
]),
}),
);
expect(mocks.promptYesNo).toHaveBeenCalledWith("Apply this migration now?", false);
const appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan;
expect(appliedPlan.summary).toMatchObject({ planned: 2, skipped: 1, conflicts: 0 });
expect(appliedPlan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "skill:alpha", status: "planned" }),
expect.objectContaining({
id: "skill:beta",
status: "skipped",
reason: "not selected for migration",
}),
expect.objectContaining({ id: "archive:config.toml", status: "planned" }),
]),
);
});
it("does not apply when interactive apply confirmation is declined", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
@@ -235,6 +345,67 @@ describe("migrateApplyCommand", () => {
expect(mocks.provider.apply).not.toHaveBeenCalled();
});
it("filters explicit Codex skills before apply conflict checks", async () => {
const planned = codexSkillPlan({
summary: {
total: 3,
planned: 2,
migrated: 0,
skipped: 0,
conflicts: 1,
errors: 0,
sensitive: 0,
},
items: [
{
id: "skill:alpha",
kind: "skill",
action: "copy",
status: "planned",
details: { skillName: "alpha" },
},
{
id: "skill:beta",
kind: "skill",
action: "copy",
status: "conflict",
reason: "target exists",
details: { skillName: "beta" },
},
{
id: "archive:config.toml",
kind: "archive",
action: "archive",
status: "planned",
},
],
});
mocks.provider.plan.mockResolvedValue(planned);
mocks.provider.apply.mockImplementation(async (_ctx, selectedPlan: MigrationPlan) => ({
...selectedPlan,
summary: { ...selectedPlan.summary, planned: 0, migrated: 2 },
items: selectedPlan.items.map((item) =>
item.status === "planned" ? { ...item, status: "migrated" as const } : item,
),
}));
await migrateApplyCommand(runtime, { provider: "codex", yes: true, skills: ["alpha"] });
const appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan;
expect(appliedPlan.summary).toMatchObject({ planned: 2, skipped: 1, conflicts: 0 });
expect(appliedPlan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "skill:alpha", status: "planned" }),
expect.objectContaining({
id: "skill:beta",
status: "skipped",
reason: "not selected for migration",
}),
]),
);
expect(mocks.backupCreateCommand).toHaveBeenCalled();
});
it("creates a verified backup before applying a conflict-free migration", async () => {
const planned = plan();
const applied: MigrationApplyResult = {

View File

@@ -1,3 +1,4 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import { promptYesNo } from "../cli/prompt.js";
import { getRuntimeConfig } from "../config/config.js";
import { redactMigrationPlan } from "../plugin-sdk/migration.js";
@@ -5,9 +6,18 @@ import { resolvePluginMigrationProviders } from "../plugins/migration-provider-r
import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { writeRuntimeJson } from "../runtime.js";
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
import { runMigrationApply } from "./migrate/apply.js";
import { formatMigrationPlan } from "./migrate/output.js";
import { createMigrationPlan, resolveMigrationProvider } from "./migrate/providers.js";
import {
applyMigrationSelectedSkillItemIds,
applyMigrationSkillSelection,
formatMigrationSkillSelectionHint,
formatMigrationSkillSelectionLabel,
getMigrationSkillSelectionValue,
getSelectableMigrationSkillItems,
} from "./migrate/selection.js";
import type {
MigrateApplyOptions,
MigrateCommonOptions,
@@ -16,6 +26,51 @@ import type {
export type { MigrateApplyOptions, MigrateCommonOptions, MigrateDefaultOptions };
function selectMigrationSkills(plan: MigrationPlan, opts: MigrateCommonOptions): MigrationPlan {
return applyMigrationSkillSelection(plan, opts.skills);
}
async function promptCodexMigrationSkillSelection(
runtime: RuntimeEnv,
plan: MigrationPlan,
opts: MigrateCommonOptions & { yes?: boolean },
): Promise<MigrationPlan | null> {
if (
plan.providerId !== "codex" ||
opts.yes ||
opts.json ||
opts.skills !== undefined ||
!process.stdin.isTTY
) {
return plan;
}
const skillItems = getSelectableMigrationSkillItems(plan);
if (skillItems.length === 0) {
return plan;
}
const selected = await multiselect<string>({
message: stylePromptMessage("Select Codex skills to migrate into this agent"),
options: skillItems.map((item) => {
const hint = formatMigrationSkillSelectionHint(item);
return {
value: getMigrationSkillSelectionValue(item),
label: formatMigrationSkillSelectionLabel(item),
hint: hint === undefined ? undefined : stylePromptHint(hint),
};
}),
initialValues: skillItems.map(getMigrationSkillSelectionValue),
required: false,
});
if (isCancel(selected)) {
cancel(stylePromptTitle("Migration cancelled.") ?? "Migration cancelled.");
runtime.log("Migration cancelled.");
return null;
}
const selectedPlan = applyMigrationSelectedSkillItemIds(plan, new Set(selected));
runtime.log(`Selected ${selected.length} of ${skillItems.length} Codex skills for migration.`);
return selectedPlan;
}
export async function migrateListCommand(runtime: RuntimeEnv, opts: { json?: boolean } = {}) {
const providers = resolvePluginMigrationProviders({ cfg: getRuntimeConfig() }).map(
(provider) => ({
@@ -51,7 +106,10 @@ export async function migratePlanCommand(
if (!providerId) {
throw new Error("Migration provider is required.");
}
const plan = await createMigrationPlan(runtime, { ...opts, provider: providerId });
const plan = selectMigrationSkills(
await createMigrationPlan(runtime, { ...opts, provider: providerId }),
opts,
);
if (opts.json) {
writeRuntimeJson(runtime, redactMigrationPlan(plan));
} else {
@@ -92,14 +150,18 @@ export async function migrateApplyCommand(
if (opts.json) {
return plan;
}
const selectedPlan = await promptCodexMigrationSkillSelection(runtime, plan, opts);
if (!selectedPlan) {
return plan;
}
const ok = await promptYesNo("Apply this migration now?", false);
if (!ok) {
runtime.log("Migration cancelled.");
return plan;
return selectedPlan;
}
return await runMigrationApply({
runtime,
opts: { ...opts, provider: providerId, yes: true, preflightPlan: plan },
opts: { ...opts, provider: providerId, yes: true, preflightPlan: selectedPlan },
providerId,
provider,
});
@@ -131,7 +193,10 @@ export async function migrateDefaultCommand(
}
const plan =
opts.json && opts.yes && !opts.dryRun
? await createMigrationPlan(runtime, { ...opts, provider: providerId })
? selectMigrationSkills(
await createMigrationPlan(runtime, { ...opts, provider: providerId }),
opts,
)
: await migratePlanCommand(runtime, {
...opts,
provider: providerId,
@@ -148,11 +213,22 @@ export async function migrateDefaultCommand(
runtime.log("Re-run with --yes to apply this migration non-interactively.");
return plan;
}
const selectedPlan = await promptCodexMigrationSkillSelection(runtime, plan, opts);
if (!selectedPlan) {
return plan;
}
const ok = await promptYesNo("Apply this migration now?", false);
if (!ok) {
runtime.log("Migration cancelled.");
return plan;
return selectedPlan;
}
return await migrateApplyCommand(runtime, {
...opts,
provider: providerId,
yes: true,
json: opts.json,
preflightPlan: selectedPlan,
});
}
return await migrateApplyCommand(runtime, {
...opts,

View File

@@ -5,6 +5,7 @@ import type { RuntimeEnv } from "../../runtime.js";
import { backupCreateCommand } from "../backup.js";
import { buildMigrationContext, buildMigrationReportDir } from "./context.js";
import { assertApplySucceeded, assertConflictFreePlan, writeApplyResult } from "./output.js";
import { applyMigrationSkillSelection } from "./selection.js";
import type { MigrateApplyOptions } from "./types.js";
function shouldTreatMissingBackupAsEmptyState(error: unknown): boolean {
@@ -58,7 +59,8 @@ export async function runMigrationApply(params: {
json: params.opts.json,
}),
));
assertConflictFreePlan(preflightPlan, params.providerId);
const selectedPlan = applyMigrationSkillSelection(preflightPlan, params.opts.skills);
assertConflictFreePlan(selectedPlan, params.providerId);
const stateDir = resolveStateDir();
const reportDir = buildMigrationReportDir(params.providerId, stateDir);
const backupPath = params.opts.noBackup
@@ -74,7 +76,7 @@ export async function runMigrationApply(params: {
reportDir,
json: params.opts.json,
});
const result = await params.provider.apply(ctx, preflightPlan);
const result = await params.provider.apply(ctx, selectedPlan);
const withBackup = {
...result,
backupPath: result.backupPath ?? backupPath,

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import type { MigrationItem, MigrationPlan } from "../../plugins/types.js";
import {
applyMigrationSelectedSkillItemIds,
applyMigrationSkillSelection,
MIGRATION_SKILL_NOT_SELECTED_REASON,
} from "./selection.js";
function skillItem(params: {
id: string;
name: string;
status?: MigrationItem["status"];
reason?: string;
}): MigrationItem {
return {
id: params.id,
kind: "skill",
action: "copy",
status: params.status ?? "planned",
source: `/tmp/codex/skills/${params.name}`,
target: `/tmp/openclaw/workspace/skills/${params.name}`,
reason: params.reason,
details: {
skillName: params.name,
sourceLabel: "Codex CLI skill",
},
};
}
function plan(items: MigrationItem[]): MigrationPlan {
return {
providerId: "codex",
source: "/tmp/codex",
summary: {
total: items.length,
planned: items.filter((item) => item.status === "planned").length,
migrated: 0,
skipped: items.filter((item) => item.status === "skipped").length,
conflicts: items.filter((item) => item.status === "conflict").length,
errors: 0,
sensitive: 0,
},
items,
};
}
describe("applyMigrationSkillSelection", () => {
it("keeps selected skills and skips unselected skill copy items", () => {
const selected = applyMigrationSkillSelection(
plan([
skillItem({ id: "skill:alpha", name: "alpha" }),
skillItem({ id: "skill:beta", name: "beta" }),
{
id: "archive:config.toml",
kind: "archive",
action: "archive",
status: "planned",
},
{
id: "plugin:docs:1",
kind: "manual",
action: "manual",
status: "skipped",
},
]),
["alpha"],
);
expect(selected.summary).toMatchObject({
total: 4,
planned: 2,
skipped: 2,
conflicts: 0,
});
expect(selected.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "skill:alpha", status: "planned" }),
expect.objectContaining({
id: "skill:beta",
status: "skipped",
reason: MIGRATION_SKILL_NOT_SELECTED_REASON,
}),
expect.objectContaining({ id: "archive:config.toml", status: "planned" }),
]),
);
});
it("accepts item ids as non-interactive skill selectors", () => {
const selected = applyMigrationSkillSelection(
plan([skillItem({ id: "skill:alpha", name: "alpha" })]),
["skill:alpha"],
);
expect(selected.items).toEqual([
expect.objectContaining({ id: "skill:alpha", status: "planned" }),
]);
});
it("can skip conflicting skills before apply conflict checks run", () => {
const selected = applyMigrationSkillSelection(
plan([
skillItem({ id: "skill:alpha", name: "alpha" }),
skillItem({
id: "skill:beta",
name: "beta",
status: "conflict",
reason: "target exists",
}),
]),
["alpha"],
);
expect(selected.summary.conflicts).toBe(0);
expect(selected.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "skill:alpha", status: "planned" }),
expect.objectContaining({
id: "skill:beta",
status: "skipped",
reason: MIGRATION_SKILL_NOT_SELECTED_REASON,
}),
]),
);
});
it("allows interactive selection to choose no skills", () => {
const selected = applyMigrationSelectedSkillItemIds(
plan([
skillItem({ id: "skill:alpha", name: "alpha" }),
skillItem({ id: "skill:beta", name: "beta" }),
]),
new Set(),
);
expect(selected.summary).toMatchObject({ planned: 0, skipped: 2 });
expect(selected.items.every((item) => item.status === "skipped")).toBe(true);
});
it("rejects unknown explicit skill selectors with available choices", () => {
expect(() =>
applyMigrationSkillSelection(
plan([
skillItem({ id: "skill:alpha", name: "alpha" }),
skillItem({ id: "skill:beta", name: "beta" }),
]),
["gamma"],
),
).toThrow('No migratable skill matched "gamma". Available skills: alpha, beta.');
});
});

View File

@@ -0,0 +1,159 @@
import path from "node:path";
import { markMigrationItemSkipped, summarizeMigrationItems } from "../../plugin-sdk/migration.js";
import type { MigrationItem, MigrationPlan } from "../../plugins/types.js";
export const MIGRATION_SKILL_NOT_SELECTED_REASON = "not selected for migration";
function normalizeSelectionRef(value: string): string {
return value.trim().toLowerCase();
}
function readMigrationSkillName(item: MigrationItem): string | undefined {
const value = item.details?.skillName;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function readMigrationSkillSourceLabel(item: MigrationItem): string | undefined {
const value = item.details?.sourceLabel;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function migrationSkillRefs(item: MigrationItem): string[] {
const skillName = readMigrationSkillName(item);
const idSuffix = item.id.startsWith("skill:") ? item.id.slice("skill:".length) : undefined;
const sourceBase = item.source ? path.basename(item.source) : undefined;
const targetBase = item.target ? path.basename(item.target) : undefined;
return [item.id, idSuffix, skillName, sourceBase, targetBase].filter(
(value): value is string => typeof value === "string" && value.trim().length > 0,
);
}
function formatSelectionRefList(values: readonly string[]): string {
if (values.length === 0) {
return "none";
}
return values.map((value) => `"${value}"`).join(", ");
}
function buildSkillSelectionIndex(
items: readonly MigrationItem[],
): Map<string, ReadonlySet<string>> {
const index = new Map<string, Set<string>>();
for (const item of items) {
for (const ref of migrationSkillRefs(item)) {
const normalized = normalizeSelectionRef(ref);
if (!normalized) {
continue;
}
const existing = index.get(normalized) ?? new Set<string>();
existing.add(item.id);
index.set(normalized, existing);
}
}
return index;
}
function resolveSelectedSkillItemIds(
items: readonly MigrationItem[],
selectedRefs: readonly string[],
): Set<string> {
const index = buildSkillSelectionIndex(items);
const selectedIds = new Set<string>();
const unknownRefs: string[] = [];
const ambiguousRefs: string[] = [];
for (const ref of selectedRefs) {
const normalized = normalizeSelectionRef(ref);
if (!normalized) {
continue;
}
const matches = index.get(normalized);
if (!matches) {
unknownRefs.push(ref);
continue;
}
if (matches.size > 1) {
ambiguousRefs.push(ref);
continue;
}
const [id] = matches;
if (id) {
selectedIds.add(id);
}
}
if (unknownRefs.length > 0 || ambiguousRefs.length > 0) {
const available = items
.map(formatMigrationSkillSelectionLabel)
.toSorted((a, b) => a.localeCompare(b));
const parts: string[] = [];
if (unknownRefs.length > 0) {
parts.push(`No migratable skill matched ${formatSelectionRefList(unknownRefs)}.`);
}
if (ambiguousRefs.length > 0) {
parts.push(`Skill selection ${formatSelectionRefList(ambiguousRefs)} was ambiguous.`);
}
parts.push(`Available skills: ${available.length > 0 ? available.join(", ") : "none"}.`);
throw new Error(parts.join(" "));
}
return selectedIds;
}
export function getSelectableMigrationSkillItems(plan: MigrationPlan): MigrationItem[] {
return plan.items.filter(
(item) =>
item.kind === "skill" &&
item.action === "copy" &&
(item.status === "planned" || item.status === "conflict"),
);
}
export function getMigrationSkillSelectionValue(item: MigrationItem): string {
return item.id;
}
export function formatMigrationSkillSelectionLabel(item: MigrationItem): string {
return readMigrationSkillName(item) ?? item.id.replace(/^skill:/u, "");
}
export function formatMigrationSkillSelectionHint(item: MigrationItem): string | undefined {
const parts = [readMigrationSkillSourceLabel(item)];
if (item.status === "conflict") {
parts.push(item.reason ? `conflict: ${item.reason}` : "conflict");
}
return (
parts
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join("; ") || undefined
);
}
export function applyMigrationSelectedSkillItemIds(
plan: MigrationPlan,
selectedItemIds: ReadonlySet<string>,
): MigrationPlan {
const selectableIds = new Set(getSelectableMigrationSkillItems(plan).map((item) => item.id));
const items = plan.items.map((item) => {
if (!selectableIds.has(item.id) || selectedItemIds.has(item.id)) {
return item;
}
return markMigrationItemSkipped(item, MIGRATION_SKILL_NOT_SELECTED_REASON);
});
return {
...plan,
items,
summary: summarizeMigrationItems(items),
};
}
export function applyMigrationSkillSelection(
plan: MigrationPlan,
selectedSkillRefs: readonly string[] | undefined,
): MigrationPlan {
if (selectedSkillRefs === undefined) {
return plan;
}
const selectable = getSelectableMigrationSkillItems(plan);
const selectedIds = resolveSelectedSkillItemIds(selectable, selectedSkillRefs);
return applyMigrationSelectedSkillItemIds(plan, selectedIds);
}

View File

@@ -5,6 +5,7 @@ export type MigrateCommonOptions = {
source?: string;
includeSecrets?: boolean;
overwrite?: boolean;
skills?: string[];
json?: boolean;
};