Support detect and import MCP, Subagents, hooks, commands from external (#19949)

## Why
This PR expands the migration path so Codex can detect and import MCP
server config, hooks, commands, and subagents configs in a Codex-native
shape.

## What changed

- Added a `codex-external-agent-migration` crate that owns conversion
logic for external-agent MCP servers, hooks, commands, and subagents.
- Extended the app-server external-agent config detection/import API
with migration item types for MCP server config, hooks, commands, and
subagents.

## Migration strategy

The migration is intentionally conservative: Codex only imports
external-agent config that can be represented safely in Codex today.
Unsupported or ambiguous config is skipped instead of being partially
translated into behavior that may not match the source system.

- **MCP servers**: import supported stdio and HTTP MCP server
definitions into `mcp_servers`. Disabled servers and servers filtered
out by source `enabledMcpjsonServers` / `disabledMcpjsonServers` are
skipped. Project-scoped MCP entries from `.claude.json` are included
when they match the repo path.
- **Hooks**: import only supported command hooks into
`.codex/hooks.json`. Unsupported hook features such as conditional
groups, async handlers, prompt/http hooks, or unknown fields are
skipped. Referenced hook scripts are copied into `.codex/hooks/`,
preserving any existing target scripts.
- **Commands**: import supported external commands as Codex skills under
`.agents/skills/source-command-*`. Commands that rely on source runtime
expansion such as `$ARGUMENTS`, `$1`, `@file` references, shell
interpolation, or colliding generated names are skipped.
- **Subagents**: import valid subagent Markdown files into
`.codex/agents/*.toml` when they have the minimum Codex agent fields.
Source model names are not migrated, so imported agents keep the user’s
Codex default model; compatible reasoning effort and sandbox mode are
migrated when present.
- **Skills and project guidance**: copy missing skill directories into
`.agents/skills` and migrate `CLAUDE.md` guidance into `AGENTS.md`,
rewriting source-agent terminology to Codex terminology where
appropriate.
- **Detection details**: detected migration items include lightweight
details for UI preview, such as MCP server names, hook event names,
generated command skill names, and subagent names. Import still
recomputes from disk instead of trusting details as the source of truth.

- Adds focused coverage for the new migration behavior and app-server
import flow.

## Verification

- `cargo test -p codex-external-agent-migration`
- `cargo test -p codex-hooks`
- `cargo test -p codex-app-server external_agent_config`
- `just bazel-lock-check`
This commit is contained in:
alexsong-oai
2026-04-28 17:45:24 -07:00
committed by GitHub
Unverified
parent ebdf3a878c
commit cb8b1bbcd6
25 changed files with 3662 additions and 171 deletions
+13
View File
@@ -1853,6 +1853,7 @@ dependencies = [
"codex-core-plugins",
"codex-device-key",
"codex-exec-server",
"codex-external-agent-migration",
"codex-external-agent-sessions",
"codex-features",
"codex-feedback",
@@ -2683,6 +2684,18 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "codex-external-agent-migration"
version = "0.0.0"
dependencies = [
"codex-hooks",
"pretty_assertions",
"serde_json",
"serde_yaml",
"tempfile",
"toml 0.9.11+spec-1.1.0",
]
[[package]]
name = "codex-external-agent-sessions"
version = "0.0.0"
+2
View File
@@ -40,6 +40,7 @@ members = [
"exec-server",
"execpolicy",
"execpolicy-legacy",
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
"file-search",
@@ -147,6 +148,7 @@ codex-exec = { path = "exec" }
codex-file-system = { path = "file-system" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-external-agent-sessions = { path = "external-agent-sessions" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-features = { path = "features" }
@@ -354,6 +354,17 @@
],
"type": "object"
},
"CommandMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"ConfigBatchWriteParams": {
"properties": {
"edits": {
@@ -850,6 +861,9 @@
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG",
"SUBAGENTS",
"HOOKS",
"COMMANDS",
"SESSIONS"
],
"type": "string"
@@ -1390,6 +1404,17 @@
},
"type": "object"
},
"HookMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"ImageDetail": {
"enum": [
"auto",
@@ -1696,6 +1721,17 @@
],
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"McpServerOauthLoginParams": {
"properties": {
"name": {
@@ -1779,6 +1815,27 @@
},
"MigrationDetails": {
"properties": {
"commands": {
"default": [],
"items": {
"$ref": "#/definitions/CommandMigration"
},
"type": "array"
},
"hooks": {
"default": [],
"items": {
"$ref": "#/definitions/HookMigration"
},
"type": "array"
},
"mcpServers": {
"default": [],
"items": {
"$ref": "#/definitions/McpServerMigration"
},
"type": "array"
},
"plugins": {
"default": [],
"items": {
@@ -1792,6 +1849,13 @@
"$ref": "#/definitions/SessionMigration"
},
"type": "array"
},
"subagents": {
"default": [],
"items": {
"$ref": "#/definitions/SubagentMigration"
},
"type": "array"
}
},
"type": "object"
@@ -3158,6 +3222,17 @@
],
"type": "string"
},
"SubagentMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -6786,6 +6786,17 @@
],
"type": "string"
},
"CommandMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"Config": {
"additionalProperties": true,
"properties": {
@@ -8347,6 +8358,9 @@
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG",
"SUBAGENTS",
"HOOKS",
"COMMANDS",
"SESSIONS"
],
"type": "string"
@@ -9556,6 +9570,17 @@
],
"type": "string"
},
"HookMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"HookOutputEntry": {
"properties": {
"kind": {
@@ -10469,6 +10494,17 @@
"title": "McpResourceReadResponse",
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"McpServerOauthLoginCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -10793,6 +10829,27 @@
},
"MigrationDetails": {
"properties": {
"commands": {
"default": [],
"items": {
"$ref": "#/definitions/v2/CommandMigration"
},
"type": "array"
},
"hooks": {
"default": [],
"items": {
"$ref": "#/definitions/v2/HookMigration"
},
"type": "array"
},
"mcpServers": {
"default": [],
"items": {
"$ref": "#/definitions/v2/McpServerMigration"
},
"type": "array"
},
"plugins": {
"default": [],
"items": {
@@ -10806,6 +10863,13 @@
"$ref": "#/definitions/v2/SessionMigration"
},
"type": "array"
},
"subagents": {
"default": [],
"items": {
"$ref": "#/definitions/v2/SubagentMigration"
},
"type": "array"
}
},
"type": "object"
@@ -14136,6 +14200,17 @@
}
]
},
"SubagentMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"TerminalInteractionNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -3285,6 +3285,17 @@
],
"type": "string"
},
"CommandMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"Config": {
"additionalProperties": true,
"properties": {
@@ -4846,6 +4857,9 @@
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG",
"SUBAGENTS",
"HOOKS",
"COMMANDS",
"SESSIONS"
],
"type": "string"
@@ -6166,6 +6180,17 @@
],
"type": "string"
},
"HookMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"HookOutputEntry": {
"properties": {
"kind": {
@@ -7123,6 +7148,17 @@
"title": "McpResourceReadResponse",
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"McpServerOauthLoginCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -7447,6 +7483,27 @@
},
"MigrationDetails": {
"properties": {
"commands": {
"default": [],
"items": {
"$ref": "#/definitions/CommandMigration"
},
"type": "array"
},
"hooks": {
"default": [],
"items": {
"$ref": "#/definitions/HookMigration"
},
"type": "array"
},
"mcpServers": {
"default": [],
"items": {
"$ref": "#/definitions/McpServerMigration"
},
"type": "array"
},
"plugins": {
"default": [],
"items": {
@@ -7460,6 +7517,13 @@
"$ref": "#/definitions/SessionMigration"
},
"type": "array"
},
"subagents": {
"default": [],
"items": {
"$ref": "#/definitions/SubagentMigration"
},
"type": "array"
}
},
"type": "object"
@@ -12022,6 +12086,17 @@
}
]
},
"SubagentMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"TerminalInteractionNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -1,6 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CommandMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
@@ -40,12 +51,58 @@
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG",
"SUBAGENTS",
"HOOKS",
"COMMANDS",
"SESSIONS"
],
"type": "string"
},
"HookMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"MigrationDetails": {
"properties": {
"commands": {
"default": [],
"items": {
"$ref": "#/definitions/CommandMigration"
},
"type": "array"
},
"hooks": {
"default": [],
"items": {
"$ref": "#/definitions/HookMigration"
},
"type": "array"
},
"mcpServers": {
"default": [],
"items": {
"$ref": "#/definitions/McpServerMigration"
},
"type": "array"
},
"plugins": {
"default": [],
"items": {
@@ -59,6 +116,13 @@
"$ref": "#/definitions/SessionMigration"
},
"type": "array"
},
"subagents": {
"default": [],
"items": {
"$ref": "#/definitions/SubagentMigration"
},
"type": "array"
}
},
"type": "object"
@@ -101,6 +165,17 @@
"path"
],
"type": "object"
},
"SubagentMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
}
},
"properties": {
@@ -1,6 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CommandMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
@@ -40,12 +51,58 @@
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG",
"SUBAGENTS",
"HOOKS",
"COMMANDS",
"SESSIONS"
],
"type": "string"
},
"HookMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"MigrationDetails": {
"properties": {
"commands": {
"default": [],
"items": {
"$ref": "#/definitions/CommandMigration"
},
"type": "array"
},
"hooks": {
"default": [],
"items": {
"$ref": "#/definitions/HookMigration"
},
"type": "array"
},
"mcpServers": {
"default": [],
"items": {
"$ref": "#/definitions/McpServerMigration"
},
"type": "array"
},
"plugins": {
"default": [],
"items": {
@@ -59,6 +116,13 @@
"$ref": "#/definitions/SessionMigration"
},
"type": "array"
},
"subagents": {
"default": [],
"items": {
"$ref": "#/definitions/SubagentMigration"
},
"type": "array"
}
},
"type": "object"
@@ -101,6 +165,17 @@
"path"
],
"type": "object"
},
"SubagentMigration": {
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
}
},
"properties": {
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CommandMigration = { name: string, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG" | "SESSIONS";
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG" | "SUBAGENTS" | "HOOKS" | "COMMANDS" | "SESSIONS";
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookMigration = { name: string, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type McpServerMigration = { name: string, };
@@ -1,7 +1,11 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CommandMigration } from "./CommandMigration";
import type { HookMigration } from "./HookMigration";
import type { McpServerMigration } from "./McpServerMigration";
import type { PluginsMigration } from "./PluginsMigration";
import type { SessionMigration } from "./SessionMigration";
import type { SubagentMigration } from "./SubagentMigration";
export type MigrationDetails = { plugins: Array<PluginsMigration>, sessions: Array<SessionMigration>, };
export type MigrationDetails = { plugins: Array<PluginsMigration>, sessions: Array<SessionMigration>, mcpServers: Array<McpServerMigration>, hooks: Array<HookMigration>, subagents: Array<SubagentMigration>, commands: Array<CommandMigration>, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SubagentMigration = { name: string, };
@@ -58,6 +58,7 @@ export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRe
export type { CommandExecutionRequestApprovalResponse } from "./CommandExecutionRequestApprovalResponse";
export type { CommandExecutionSource } from "./CommandExecutionSource";
export type { CommandExecutionStatus } from "./CommandExecutionStatus";
export type { CommandMigration } from "./CommandMigration";
export type { Config } from "./Config";
export type { ConfigBatchWriteParams } from "./ConfigBatchWriteParams";
export type { ConfigEdit } from "./ConfigEdit";
@@ -154,6 +155,7 @@ export type { HookCompletedNotification } from "./HookCompletedNotification";
export type { HookEventName } from "./HookEventName";
export type { HookExecutionMode } from "./HookExecutionMode";
export type { HookHandlerType } from "./HookHandlerType";
export type { HookMigration } from "./HookMigration";
export type { HookOutputEntry } from "./HookOutputEntry";
export type { HookOutputEntryKind } from "./HookOutputEntryKind";
export type { HookPromptFragment } from "./HookPromptFragment";
@@ -209,6 +211,7 @@ export type { McpResourceReadResponse } from "./McpResourceReadResponse";
export type { McpServerElicitationAction } from "./McpServerElicitationAction";
export type { McpServerElicitationRequestParams } from "./McpServerElicitationRequestParams";
export type { McpServerElicitationRequestResponse } from "./McpServerElicitationRequestResponse";
export type { McpServerMigration } from "./McpServerMigration";
export type { McpServerOauthLoginCompletedNotification } from "./McpServerOauthLoginCompletedNotification";
export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams";
export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse";
@@ -313,6 +316,7 @@ export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd";
export type { SkillsListParams } from "./SkillsListParams";
export type { SkillsListResponse } from "./SkillsListResponse";
export type { SortDirection } from "./SortDirection";
export type { SubagentMigration } from "./SubagentMigration";
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
export type { TextElement } from "./TextElement";
export type { TextPosition } from "./TextPosition";
@@ -1092,6 +1092,15 @@ pub enum ExternalAgentConfigMigrationItemType {
#[serde(rename = "MCP_SERVER_CONFIG")]
#[ts(rename = "MCP_SERVER_CONFIG")]
McpServerConfig,
#[serde(rename = "SUBAGENTS")]
#[ts(rename = "SUBAGENTS")]
Subagents,
#[serde(rename = "HOOKS")]
#[ts(rename = "HOOKS")]
Hooks,
#[serde(rename = "COMMANDS")]
#[ts(rename = "COMMANDS")]
Commands,
#[serde(rename = "SESSIONS")]
#[ts(rename = "SESSIONS")]
Sessions,
@@ -1121,11 +1130,47 @@ pub struct SessionMigration {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpServerMigration {
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HookMigration {
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SubagentMigration {
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandMigration {
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MigrationDetails {
#[serde(default)]
pub plugins: Vec<PluginsMigration>,
#[serde(default)]
pub sessions: Vec<SessionMigration>,
#[serde(default)]
pub mcp_servers: Vec<McpServerMigration>,
#[serde(default)]
pub hooks: Vec<HookMigration>,
#[serde(default)]
pub subagents: Vec<SubagentMigration>,
#[serde(default)]
pub commands: Vec<CommandMigration>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -7876,7 +7921,7 @@ mod tests {
marketplace_name: "team-marketplace".to_string(),
plugin_names: vec!["asana".to_string()],
}],
sessions: Vec::new(),
..Default::default()
}),
}
);
@@ -7913,7 +7958,7 @@ mod tests {
marketplace_name: "team-marketplace".to_string(),
plugin_names: vec!["asana".to_string()],
}],
sessions: Vec::new(),
..Default::default()
}),
}],
}
+1
View File
@@ -38,6 +38,7 @@ codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
codex-device-key = { workspace = true }
codex-exec-server = { workspace = true }
codex-external-agent-migration = { workspace = true }
codex-external-agent-sessions = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
@@ -9,6 +9,15 @@ use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_add::is_local_marketplace_source;
use codex_external_agent_migration::build_mcp_config_from_external;
use codex_external_agent_migration::count_missing_commands;
use codex_external_agent_migration::count_missing_subagents;
use codex_external_agent_migration::hook_migration_event_names;
use codex_external_agent_migration::import_commands;
use codex_external_agent_migration::import_hooks;
use codex_external_agent_migration::import_subagents;
use codex_external_agent_migration::missing_command_names;
use codex_external_agent_migration::missing_subagent_names;
use codex_external_agent_sessions::ExternalAgentSessionMigration;
use codex_external_agent_sessions::detect_recent_sessions;
use codex_protocol::protocol::Product;
@@ -43,6 +52,9 @@ pub(crate) enum ExternalAgentConfigMigrationItemType {
AgentsMd,
Plugins,
McpServerConfig,
Subagents,
Hooks,
Commands,
Sessions,
}
@@ -53,9 +65,18 @@ pub(crate) struct PluginsMigration {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NamedMigration {
pub name: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct MigrationDetails {
pub plugins: Vec<PluginsMigration>,
pub sessions: Vec<ExternalAgentSessionMigration>,
pub mcp_servers: Vec<NamedMigration>,
pub hooks: Vec<NamedMigration>,
pub subagents: Vec<NamedMigration>,
pub commands: Vec<NamedMigration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -182,7 +203,38 @@ impl ExternalAgentConfigService {
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {}
ExternalAgentConfigMigrationItemType::McpServerConfig => {
self.import_mcp_server_config(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::McpServerConfig,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Subagents => {
let subagents_count = self.import_subagents(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Subagents,
Some(subagents_count),
);
}
ExternalAgentConfigMigrationItemType::Hooks => {
self.import_hooks(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Hooks,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Commands => {
let commands_count = self.import_commands(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Commands,
Some(commands_count),
);
}
ExternalAgentConfigMigrationItemType::Sessions => {}
}
}
@@ -241,6 +293,81 @@ impl ExternalAgentConfigService {
}
}
let source_root = self.source_root(repo_root);
let mcp_settings = self.mcp_settings(repo_root, settings.clone())?;
let migrated_mcp = build_mcp_config_from_external(
source_root.as_path(),
Some(self.external_agent_home.as_path()),
mcp_settings.as_ref(),
)?;
let mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
if !is_empty_toml_table(&migrated_mcp) {
let mut should_include = true;
if target_config.exists() {
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str::<TomlValue>(&existing_raw).map_err(|err| {
invalid_data_error(format!("invalid existing config.toml: {err}"))
})?
};
should_include = merge_missing_toml_values(&mut existing, &migrated_mcp)?;
}
if should_include {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: format!(
"Migrate MCP servers from {} into {}",
source_root.display(),
target_config.display()
),
cwd: cwd.clone(),
details: Some(MigrationDetails {
mcp_servers: named_migrations(mcp_server_names.clone()),
..Default::default()
}),
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::McpServerConfig,
/*skills_count*/ None,
);
}
}
let source_external_agent_dir = repo_root.map_or_else(
|| self.external_agent_home.clone(),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR),
);
let target_hooks = repo_root.map_or_else(
|| self.codex_home.join("hooks.json"),
|repo_root| repo_root.join(".codex").join("hooks.json"),
);
let hook_event_names =
hook_migration_event_names(source_external_agent_dir.as_path(), &target_hooks)?;
if !hook_event_names.is_empty() && is_missing_or_empty_text_file(&target_hooks)? {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Hooks,
description: format!(
"Migrate hooks from {} to {}",
source_external_agent_dir.display(),
target_hooks.display()
),
cwd: cwd.clone(),
details: Some(MigrationDetails {
hooks: named_migrations(hook_event_names),
..Default::default()
}),
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Hooks,
/*skills_count*/ None,
);
}
let source_skills = repo_root.map_or_else(
|| self.external_agent_home.join("skills"),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
@@ -268,6 +395,62 @@ impl ExternalAgentConfigService {
);
}
let source_commands = source_external_agent_dir.join("commands");
let target_command_skills = repo_root.map_or_else(
|| self.home_target_skills_dir(),
|repo_root| repo_root.join(".agents").join("skills"),
);
let commands_count = count_missing_commands(&source_commands, &target_command_skills)?;
if commands_count > 0 {
let command_names = missing_command_names(&source_commands, &target_command_skills)?;
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Commands,
description: format!(
"Migrate commands from {} to {}",
source_commands.display(),
target_command_skills.display()
),
cwd: cwd.clone(),
details: Some(MigrationDetails {
commands: named_migrations(command_names),
..Default::default()
}),
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Commands,
Some(commands_count),
);
}
let source_subagents = source_external_agent_dir.join("agents");
let target_subagents = repo_root.map_or_else(
|| self.codex_home.join("agents"),
|repo_root| repo_root.join(".codex").join("agents"),
);
let subagents_count = count_missing_subagents(&source_subagents, &target_subagents)?;
if subagents_count > 0 {
let subagent_names = missing_subagent_names(&source_subagents, &target_subagents)?;
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Subagents,
description: format!(
"Migrate subagents from {} to {}",
source_subagents.display(),
target_subagents.display()
),
cwd: cwd.clone(),
details: Some(MigrationDetails {
subagents: named_migrations(subagent_names),
..Default::default()
}),
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Subagents,
Some(subagents_count),
);
}
let source_agents_md = if let Some(repo_root) = repo_root {
find_repo_agents_md_source(repo_root)?
} else {
@@ -357,8 +540,8 @@ impl ExternalAgentConfigService {
),
cwd: None,
details: Some(MigrationDetails {
plugins: Vec::new(),
sessions,
..Default::default()
}),
});
emit_migration_metric(
@@ -379,6 +562,41 @@ impl ExternalAgentConfigService {
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"))
}
fn mcp_settings(
&self,
repo_root: Option<&Path>,
source_settings: Option<JsonValue>,
) -> io::Result<Option<JsonValue>> {
if repo_root.is_some() && source_settings.is_none() {
let home_settings = self.external_agent_home.join("settings.json");
match read_external_settings(&home_settings) {
Ok(settings) => Ok(settings),
Err(err) => {
tracing::warn!(
path = %home_settings.display(),
error = %err,
"ignoring invalid external agent home settings during repo MCP migration"
);
Ok(None)
}
}
} else {
Ok(source_settings)
}
}
fn source_root(&self, repo_root: Option<&Path>) -> PathBuf {
repo_root.map_or_else(
|| {
self.external_agent_home
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
},
Path::to_path_buf,
)
}
fn detect_plugin_migration(
&self,
source_settings: &Path,
@@ -445,11 +663,11 @@ impl ExternalAgentConfigService {
let local_details = (!local_plugins.is_empty()).then_some(MigrationDetails {
plugins: local_plugins,
sessions: Vec::new(),
..Default::default()
});
let remote_details = (!remote_plugins.is_empty()).then_some(MigrationDetails {
plugins: remote_plugins,
sessions: Vec::new(),
..Default::default()
});
Ok((local_details, remote_details))
@@ -535,7 +753,8 @@ impl ExternalAgentConfigService {
}
fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? {
let repo_root = find_repo_root(cwd)?;
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
repo_root.join(".codex").join("config.toml"),
@@ -586,6 +805,112 @@ impl ExternalAgentConfigService {
Ok(())
}
fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result<()> {
let repo_root = find_repo_root(cwd)?;
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
repo_root.join(".codex").join("config.toml"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.external_agent_home.join("settings.json"),
self.codex_home.join("config.toml"),
)
};
let settings = self.mcp_settings(
repo_root.as_deref(),
read_external_settings(&source_settings)?,
)?;
let migrated = build_mcp_config_from_external(
self.source_root(repo_root.as_deref()).as_path(),
Some(self.external_agent_home.as_path()),
settings.as_ref(),
)?;
if is_empty_toml_table(&migrated) {
return Ok(());
}
let Some(target_parent) = target_config.parent() else {
return Err(invalid_data_error("config target path has no parent"));
};
fs::create_dir_all(target_parent)?;
if !target_config.exists() {
write_toml_file(&target_config, &migrated)?;
return Ok(());
}
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str::<TomlValue>(&existing_raw)
.map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
};
if merge_missing_toml_values(&mut existing, &migrated)? {
write_toml_file(&target_config, &existing)?;
}
Ok(())
}
fn import_subagents(&self, cwd: Option<&Path>) -> io::Result<usize> {
let (source_agents, target_agents) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("agents"),
repo_root.join(".codex").join("agents"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
} else {
(
self.external_agent_home.join("agents"),
self.codex_home.join("agents"),
)
};
import_subagents(&source_agents, &target_agents)
}
fn import_hooks(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_external_agent_dir, target_hooks) =
if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR),
repo_root.join(".codex").join("hooks.json"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.external_agent_home.clone(),
self.codex_home.join("hooks.json"),
)
};
import_hooks(&source_external_agent_dir, &target_hooks)?;
Ok(())
}
fn import_commands(&self, cwd: Option<&Path>) -> io::Result<usize> {
let (source_commands, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("commands"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
} else {
(
self.external_agent_home.join("commands"),
self.home_target_skills_dir(),
)
};
import_commands(&source_commands, &target_skills)
}
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<usize> {
let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
@@ -730,7 +1055,7 @@ fn extract_plugin_migration_details(
Some(MigrationDetails {
plugins,
sessions: Vec::new(),
..Default::default()
})
}
@@ -1167,6 +1492,21 @@ fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> {
fs::write(path, format!("{}\n", serialized.trim_end()))
}
fn migrated_mcp_server_names(value: &TomlValue) -> Vec<String> {
value
.get("mcp_servers")
.and_then(TomlValue::as_table)
.map(|servers| servers.keys().cloned().collect())
.unwrap_or_default()
}
fn named_migrations(names: Vec<String>) -> Vec<NamedMigration> {
names
.into_iter()
.map(|name| NamedMigration { name })
.collect()
}
fn is_empty_toml_table(value: &TomlValue) -> bool {
match value {
TomlValue::Table(table) => table.is_empty(),
@@ -1193,10 +1533,18 @@ fn migration_metric_tags(
ExternalAgentConfigMigrationItemType::AgentsMd => "agents_md",
ExternalAgentConfigMigrationItemType::Plugins => "plugins",
ExternalAgentConfigMigrationItemType::McpServerConfig => "mcp_server_config",
ExternalAgentConfigMigrationItemType::Subagents => "subagents",
ExternalAgentConfigMigrationItemType::Hooks => "hooks",
ExternalAgentConfigMigrationItemType::Commands => "commands",
ExternalAgentConfigMigrationItemType::Sessions => "sessions",
};
let mut tags = vec![("migration_type", migration_type.to_string())];
if item_type == ExternalAgentConfigMigrationItemType::Skills {
if matches!(
item_type,
ExternalAgentConfigMigrationItemType::Skills
| ExternalAgentConfigMigrationItemType::Subagents
| ExternalAgentConfigMigrationItemType::Commands
) {
tags.push(("skills_count", skills_count.unwrap_or(0).to_string()));
}
tags
File diff suppressed because it is too large Load Diff
@@ -2,17 +2,22 @@ use crate::config::external_agent_config::ExternalAgentConfigDetectOptions;
use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use crate::config::external_agent_config::ExternalAgentConfigService;
use crate::config::external_agent_config::NamedMigration as CoreNamedMigration;
use crate::config::external_agent_config::PendingPluginImport;
use crate::error_code::internal_error;
use crate::error_code::invalid_params;
use codex_app_server_protocol::CommandMigration;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::HookMigration;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerMigration;
use codex_app_server_protocol::MigrationDetails;
use codex_app_server_protocol::PluginsMigration;
use codex_app_server_protocol::SubagentMigration;
use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration;
use codex_external_agent_sessions::PendingSessionImport;
use codex_external_agent_sessions::PrepareSessionImportsError;
@@ -68,6 +73,13 @@ impl ExternalAgentConfigApi {
CoreMigrationItemType::McpServerConfig => {
ExternalAgentConfigMigrationItemType::McpServerConfig
}
CoreMigrationItemType::Subagents => {
ExternalAgentConfigMigrationItemType::Subagents
}
CoreMigrationItemType::Hooks => ExternalAgentConfigMigrationItemType::Hooks,
CoreMigrationItemType::Commands => {
ExternalAgentConfigMigrationItemType::Commands
}
CoreMigrationItemType::Sessions => {
ExternalAgentConfigMigrationItemType::Sessions
}
@@ -92,6 +104,30 @@ impl ExternalAgentConfigApi {
title: session.title,
})
.collect(),
mcp_servers: details
.mcp_servers
.into_iter()
.map(|mcp_server| McpServerMigration {
name: mcp_server.name,
})
.collect(),
hooks: details
.hooks
.into_iter()
.map(|hook| HookMigration { name: hook.name })
.collect(),
subagents: details
.subagents
.into_iter()
.map(|subagent| SubagentMigration {
name: subagent.name,
})
.collect(),
commands: details
.commands
.into_iter()
.map(|command| CommandMigration { name: command.name })
.collect(),
}),
})
.collect(),
@@ -182,6 +218,15 @@ impl ExternalAgentConfigApi {
ExternalAgentConfigMigrationItemType::McpServerConfig => {
CoreMigrationItemType::McpServerConfig
}
ExternalAgentConfigMigrationItemType::Subagents => {
CoreMigrationItemType::Subagents
}
ExternalAgentConfigMigrationItemType::Hooks => {
CoreMigrationItemType::Hooks
}
ExternalAgentConfigMigrationItemType::Commands => {
CoreMigrationItemType::Commands
}
ExternalAgentConfigMigrationItemType::Sessions => {
CoreMigrationItemType::Sessions
}
@@ -209,6 +254,30 @@ impl ExternalAgentConfigApi {
title: session.title,
})
.collect(),
mcp_servers: details
.mcp_servers
.into_iter()
.map(|mcp_server| CoreNamedMigration {
name: mcp_server.name,
})
.collect(),
hooks: details
.hooks
.into_iter()
.map(|hook| CoreNamedMigration { name: hook.name })
.collect(),
subagents: details
.subagents
.into_iter()
.map(|subagent| CoreNamedMigration {
name: subagent.name,
})
.collect(),
commands: details
.commands
.into_iter()
.map(|command| CoreNamedMigration { name: command.name })
.collect(),
}
}),
})
+58 -1
View File
@@ -48,6 +48,7 @@ use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
@@ -1172,6 +1173,7 @@ impl MessageProcessor {
request_id: ConnectionRequestId,
params: ExternalAgentConfigImportParams,
) -> Result<(), JSONRPCErrorError> {
let needs_runtime_refresh = migration_items_need_runtime_refresh(&params.migration_items);
let has_plugin_imports = params.migration_items.iter().any(|item| {
matches!(
item.item_type,
@@ -1182,7 +1184,7 @@ impl MessageProcessor {
.external_agent_config_api
.prepare_pending_session_imports(&params)?;
let pending_plugin_imports = self.external_agent_config_api.import(params).await?;
if has_plugin_imports {
if needs_runtime_refresh {
self.handle_config_mutation().await;
}
for pending_session_import in pending_session_imports {
@@ -1241,5 +1243,60 @@ impl MessageProcessor {
}
}
fn migration_items_need_runtime_refresh(items: &[ExternalAgentConfigMigrationItem]) -> bool {
items.iter().any(|item| {
matches!(
item.item_type,
ExternalAgentConfigMigrationItemType::Config
| ExternalAgentConfigMigrationItemType::Skills
| ExternalAgentConfigMigrationItemType::McpServerConfig
| ExternalAgentConfigMigrationItemType::Hooks
| ExternalAgentConfigMigrationItemType::Commands
| ExternalAgentConfigMigrationItemType::Plugins
)
})
}
#[cfg(test)]
mod tracing_tests;
#[cfg(test)]
mod tests {
use super::*;
fn migration_item(
item_type: ExternalAgentConfigMigrationItemType,
) -> ExternalAgentConfigMigrationItem {
ExternalAgentConfigMigrationItem {
item_type,
description: String::new(),
cwd: None,
details: None,
}
}
#[test]
fn migration_items_that_update_runtime_sources_trigger_refresh() {
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Config,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Skills,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::McpServerConfig,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Hooks,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Commands,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Plugins,
)]));
assert!(!migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Sessions,
)]));
}
}
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "external-agent-migration",
crate_name = "codex_external_agent_migration",
)
@@ -0,0 +1,23 @@
[package]
name = "codex-external-agent-migration"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
doctest = false
name = "codex_external_agent_migration"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-hooks = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
toml = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
File diff suppressed because it is too large Load Diff
+22
View File
@@ -5,6 +5,28 @@ mod registry;
mod schema;
mod types;
/// Hook event names as they appear in hooks JSON and config files.
pub const HOOK_EVENT_NAMES: [&str; 6] = [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",
"Stop",
];
/// Hook event names whose matcher fields are meaningful during dispatch.
///
/// Other events can appear in hooks JSON, but Codex ignores their matcher
/// fields because those events do not dispatch against a tool or session-start
/// source.
pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 4] = [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
];
pub use events::permission_request::PermissionRequestDecision;
pub use events::permission_request::PermissionRequestOutcome;
pub use events::permission_request::PermissionRequestRequest;
@@ -799,7 +799,7 @@ mod tests {
plugin_names: vec!["warehouse".to_string()],
},
],
sessions: Vec::new(),
..Default::default()
}
}