From 58e4ed17a0f8779b547a5894d44378b8bed287da Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 15 Jul 2026 20:01:36 +0000 Subject: [PATCH] Expose connector candidates from imported sessions (#33414) ## What changed - Correlate MCP server attribution in imported session records with connector metadata, and persist normalized connector names in the session import ledger. - Extend `externalAgentConfig/import/readHistories` with connector candidates, including each connector's display name, imported-session count, and detection source. ## Testing - Cover connector detection through the session import and history-read flow. - Verify candidate aggregation uses the latest import for each source session. GitOrigin-RevId: 7f1f7f596925b2e6efcd17afd4490148e1ac9ff1 --- .../codex_app_server_protocol.schemas.json | 34 ++++++ .../codex_app_server_protocol.v2.schemas.json | 34 ++++++ ...gentConfigImportHistoriesReadResponse.json | 34 ++++++ ...lAgentConfigImportHistoriesReadResponse.ts | 3 +- ...ExternalAgentImportedConnectorCandidate.ts | 6 + .../ExternalAgentImportedConnectorSource.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/config.rs | 17 +++ codex-rs/app-server/README.md | 1 + .../src/external_agent_migration/processor.rs | 25 +++- .../src/external_agent_migration/service.rs | 10 +- .../service/source.rs | 6 + .../service/source_cla.rs | 42 +++++++ .../session_importer.rs | 74 +++++++++-- .../tests/suite/v2/external_agent_config.rs | 54 ++++++++ .../src/sessions/connectors_cla.rs | 115 ++++++++++++++++++ .../src/sessions/export.rs | 11 +- .../src/sessions/ledger.rs | 43 +++++++ .../src/sessions/ledger_tests.rs | 53 ++++++++ .../src/sessions/mod.rs | 44 ++++--- .../src/sessions/records.rs | 12 ++ 21 files changed, 587 insertions(+), 38 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentImportedConnectorCandidate.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentImportedConnectorSource.ts create mode 100644 codex-rs/external-agent-migration/src/sessions/connectors_cla.rs diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 7ea4ea8965e1..83b49189da5b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9352,6 +9352,12 @@ "ExternalAgentConfigImportHistoriesReadResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "connectors": { + "items": { + "$ref": "#/definitions/v2/ExternalAgentImportedConnectorCandidate" + }, + "type": "array" + }, "data": { "items": { "$ref": "#/definitions/v2/ExternalAgentConfigImportHistory" @@ -9360,6 +9366,7 @@ } }, "required": [ + "connectors", "data" ], "title": "ExternalAgentConfigImportHistoriesReadResponse", @@ -9594,6 +9601,33 @@ ], "type": "string" }, + "ExternalAgentImportedConnectorCandidate": { + "properties": { + "name": { + "type": "string" + }, + "sessionCount": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "source": { + "$ref": "#/definitions/v2/ExternalAgentImportedConnectorSource" + } + }, + "required": [ + "name", + "sessionCount", + "source" + ], + "type": "object" + }, + "ExternalAgentImportedConnectorSource": { + "enum": [ + "remoteMcpServersConfig" + ], + "type": "string" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ea42a95ee042..7310fc388e62 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5545,6 +5545,12 @@ "ExternalAgentConfigImportHistoriesReadResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "connectors": { + "items": { + "$ref": "#/definitions/ExternalAgentImportedConnectorCandidate" + }, + "type": "array" + }, "data": { "items": { "$ref": "#/definitions/ExternalAgentConfigImportHistory" @@ -5553,6 +5559,7 @@ } }, "required": [ + "connectors", "data" ], "title": "ExternalAgentConfigImportHistoriesReadResponse", @@ -5787,6 +5794,33 @@ ], "type": "string" }, + "ExternalAgentImportedConnectorCandidate": { + "properties": { + "name": { + "type": "string" + }, + "sessionCount": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "source": { + "$ref": "#/definitions/ExternalAgentImportedConnectorSource" + } + }, + "required": [ + "name", + "sessionCount", + "source" + ], + "type": "object" + }, + "ExternalAgentImportedConnectorSource": { + "enum": [ + "remoteMcpServersConfig" + ], + "type": "string" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportHistoriesReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportHistoriesReadResponse.json index 3e39fa49ae5f..6799de86836e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportHistoriesReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportHistoriesReadResponse.json @@ -116,9 +116,42 @@ "SESSIONS" ], "type": "string" + }, + "ExternalAgentImportedConnectorCandidate": { + "properties": { + "name": { + "type": "string" + }, + "sessionCount": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "source": { + "$ref": "#/definitions/ExternalAgentImportedConnectorSource" + } + }, + "required": [ + "name", + "sessionCount", + "source" + ], + "type": "object" + }, + "ExternalAgentImportedConnectorSource": { + "enum": [ + "remoteMcpServersConfig" + ], + "type": "string" } }, "properties": { + "connectors": { + "items": { + "$ref": "#/definitions/ExternalAgentImportedConnectorCandidate" + }, + "type": "array" + }, "data": { "items": { "$ref": "#/definitions/ExternalAgentConfigImportHistory" @@ -127,6 +160,7 @@ } }, "required": [ + "connectors", "data" ], "title": "ExternalAgentConfigImportHistoriesReadResponse", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistoriesReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistoriesReadResponse.ts index 9df61f556d0e..b48aa224bfad 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistoriesReadResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportHistoriesReadResponse.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigImportHistory } from "./ExternalAgentConfigImportHistory"; +import type { ExternalAgentImportedConnectorCandidate } from "./ExternalAgentImportedConnectorCandidate"; -export type ExternalAgentConfigImportHistoriesReadResponse = { data: Array, }; +export type ExternalAgentConfigImportHistoriesReadResponse = { data: Array, connectors: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentImportedConnectorCandidate.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentImportedConnectorCandidate.ts new file mode 100644 index 000000000000..9aad5f5a9b1f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentImportedConnectorCandidate.ts @@ -0,0 +1,6 @@ +// 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 { ExternalAgentImportedConnectorSource } from "./ExternalAgentImportedConnectorSource"; + +export type ExternalAgentImportedConnectorCandidate = { name: string, sessionCount: number, source: ExternalAgentImportedConnectorSource, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentImportedConnectorSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentImportedConnectorSource.ts new file mode 100644 index 000000000000..5398eb44c458 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentImportedConnectorSource.ts @@ -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 ExternalAgentImportedConnectorSource = "remoteMcpServersConfig"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 3b12b25396ec..e5dd528f3875 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -121,6 +121,8 @@ export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImp export type { ExternalAgentConfigImportTypeResult } from "./ExternalAgentConfigImportTypeResult"; export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; export type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; +export type { ExternalAgentImportedConnectorCandidate } from "./ExternalAgentImportedConnectorCandidate"; +export type { ExternalAgentImportedConnectorSource } from "./ExternalAgentImportedConnectorSource"; export type { FeedbackUploadParams } from "./FeedbackUploadParams"; export type { FeedbackUploadResponse } from "./FeedbackUploadResponse"; export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 4770ea8539a7..72a4300ba479 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -756,6 +756,23 @@ pub struct ExternalAgentConfigImportHistory { #[ts(export_to = "v2/")] pub struct ExternalAgentConfigImportHistoriesReadResponse { pub data: Vec, + pub connectors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ExternalAgentImportedConnectorSource { + RemoteMcpServersConfig, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentImportedConnectorCandidate { + pub name: String, + pub session_count: u32, + pub source: ExternalAgentImportedConnectorSource, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 869e8e0efe28..07e6b0ff05f7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -243,6 +243,7 @@ Example with notification opt-out: - `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and an optional `source` identifier reserved for adapter selection; omitted, `null`, or unrecognized values retain the default behavior. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names. - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. +- `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. diff --git a/codex-rs/app-server/src/external_agent_migration/processor.rs b/codex-rs/app-server/src/external_agent_migration/processor.rs index 8eee5585cbef..5520b31f957c 100644 --- a/codex-rs/app-server/src/external_agent_migration/processor.rs +++ b/codex-rs/app-server/src/external_agent_migration/processor.rs @@ -22,6 +22,8 @@ use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigImportTypeResult as ProtocolImportTypeResult; use codex_app_server_protocol::ExternalAgentConfigMigrationItem; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; +use codex_app_server_protocol::ExternalAgentImportedConnectorCandidate; +use codex_app_server_protocol::ExternalAgentImportedConnectorSource; use codex_app_server_protocol::HookMigration; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerMigration; @@ -32,6 +34,7 @@ use codex_app_server_protocol::SkillMigration; use codex_arg0::Arg0DispatchPaths; use codex_core::ThreadManager; use codex_external_agent_migration::sessions::ExternalAgentSessionMigration as CoreSessionMigration; +use codex_external_agent_migration::sessions::read_imported_connector_candidates; use codex_rollout::StateDbHandle; use codex_state::ExternalAgentConfigImportFailureRecord; use codex_state::ExternalAgentConfigImportSuccessRecord; @@ -89,15 +92,16 @@ impl ExternalAgentConfigRequestProcessor { arg0_paths, codex_home, } = args; + let migration_service = + ExternalAgentConfigService::new(codex_home.clone(), analytics_events_client.clone()); let session_importer = ExternalAgentSessionImporter::new( - codex_home.clone(), + codex_home, + migration_service.connector_metadata_roots.clone(), Arc::clone(&thread_manager), thread_store, config_manager, arg0_paths, ); - let migration_service = - ExternalAgentConfigService::new(codex_home, analytics_events_client.clone()); Self { outgoing, migration_service, @@ -369,8 +373,21 @@ impl ExternalAgentConfigRequestProcessor { .into_iter() .map(protocol_import_history) .collect::, _>>()?; + let connectors = read_imported_connector_candidates(&self.migration_service.codex_home) + .map_err(|err| { + internal_error(format!( + "failed to read imported connector candidates: {err}" + )) + })? + .into_iter() + .map(|candidate| ExternalAgentImportedConnectorCandidate { + name: candidate.name, + session_count: candidate.session_count, + source: ExternalAgentImportedConnectorSource::RemoteMcpServersConfig, + }) + .collect(); - Ok(ExternalAgentConfigImportHistoriesReadResponse { data }) + Ok(ExternalAgentConfigImportHistoriesReadResponse { data, connectors }) } fn validate_pending_session_imports( diff --git a/codex-rs/app-server/src/external_agent_migration/service.rs b/codex-rs/app-server/src/external_agent_migration/service.rs index bc6cc81967ed..59872163816c 100644 --- a/codex-rs/app-server/src/external_agent_migration/service.rs +++ b/codex-rs/app-server/src/external_agent_migration/service.rs @@ -187,7 +187,8 @@ pub(crate) struct ExternalAgentConfigMigrationItem { #[derive(Clone)] pub(crate) struct ExternalAgentConfigService { - codex_home: PathBuf, + pub(super) codex_home: PathBuf, + pub(super) connector_metadata_roots: Vec, external_agent_home: PathBuf, analytics_events_client: Option, source: ExternalAgentSource, @@ -197,8 +198,10 @@ impl ExternalAgentConfigService { pub(crate) fn new(codex_home: PathBuf, analytics_events_client: AnalyticsEventsClient) -> Self { let source = ExternalAgentSource::default(); let external_agent_home = default_external_agent_home(source); + let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home); Self { codex_home, + connector_metadata_roots, external_agent_home, analytics_events_client: Some(analytics_events_client), source, @@ -207,11 +210,14 @@ impl ExternalAgentConfigService { #[cfg(test)] fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self { + let source = ExternalAgentSource::default(); + let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home); Self { codex_home, + connector_metadata_roots, external_agent_home, analytics_events_client: None, - source: ExternalAgentSource::default(), + source, } } diff --git a/codex-rs/app-server/src/external_agent_migration/service/source.rs b/codex-rs/app-server/src/external_agent_migration/service/source.rs index e1cd813ac11d..a0041a20ffca 100644 --- a/codex-rs/app-server/src/external_agent_migration/service/source.rs +++ b/codex-rs/app-server/src/external_agent_migration/service/source.rs @@ -107,6 +107,12 @@ impl ExternalAgentSource { } } + pub(super) fn connector_metadata_roots(self, external_agent_home: &Path) -> Vec { + match self { + Self::Cla => source_cla::connector_metadata_roots(external_agent_home), + } + } + pub(super) fn marketplace_import_sources( self, external_agent_home: &Path, diff --git a/codex-rs/app-server/src/external_agent_migration/service/source_cla.rs b/codex-rs/app-server/src/external_agent_migration/service/source_cla.rs index c38b39fb254a..82f82d0de8e7 100644 --- a/codex-rs/app-server/src/external_agent_migration/service/source_cla.rs +++ b/codex-rs/app-server/src/external_agent_migration/service/source_cla.rs @@ -52,6 +52,48 @@ const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile = CommandMigrationProfi CommandDescriptionMode::RequireFrontmatter, ); +pub(super) fn connector_metadata_roots(external_agent_home: &Path) -> Vec { + let Some(home) = external_agent_home + .parent() + .filter(|path| !path.as_os_str().is_empty()) + else { + return Vec::new(); + }; + + #[cfg(target_os = "macos")] + { + vec![home.join("Library/Application Support/Claude")] + } + + #[cfg(target_os = "windows")] + { + let default_roaming = home.join("AppData/Roaming"); + let default_local = home.join("AppData/Local"); + let roaming = std::env::var_os("APPDATA") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| default_roaming.clone()); + let local = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| default_local.clone()); + let mut roots = vec![ + local.join("Packages/Claude_pzs8sxrjxfjjc/LocalCache/Roaming/Claude"), + roaming.join("Claude"), + default_local.join("Packages/Claude_pzs8sxrjxfjjc/LocalCache/Roaming/Claude"), + default_roaming.join("Claude"), + ]; + roots.sort(); + roots.dedup(); + roots + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + vec![home.join(".config/Claude")] + } +} + pub(super) fn effective_settings(project_settings: &Path) -> io::Result> { let mut effective = super::read_external_settings(project_settings)?; let Some(settings_dir) = project_settings.parent() else { diff --git a/codex-rs/app-server/src/external_agent_migration/session_importer.rs b/codex-rs/app-server/src/external_agent_migration/session_importer.rs index f48ce6d96966..2d520ad005f3 100644 --- a/codex-rs/app-server/src/external_agent_migration/session_importer.rs +++ b/codex-rs/app-server/src/external_agent_migration/session_importer.rs @@ -8,7 +8,9 @@ use codex_core::config::ConfigOverrides; use codex_external_agent_migration::sessions::CompletedExternalAgentSessionImport; use codex_external_agent_migration::sessions::ExternalAgentSessionMigration; use codex_external_agent_migration::sessions::ImportedExternalAgentSession; +use codex_external_agent_migration::sessions::ImportedSessionConnectorAttribution; use codex_external_agent_migration::sessions::PendingSessionImport; +use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors; use codex_external_agent_migration::sessions::prepare_validated_session_import; use codex_external_agent_migration::sessions::record_completed_session_imports; use codex_models_manager::manager::RefreshStrategy; @@ -33,9 +35,15 @@ use crate::config_manager::ConfigManager; const SESSION_IMPORT_CONCURRENCY: usize = 5; +struct CompletedSessionImport { + import: CompletedExternalAgentSessionImport, + connector_attribution: Option, +} + #[derive(Clone)] pub(super) struct ExternalAgentSessionImporter { codex_home: PathBuf, + connector_metadata_roots: Vec, permits: Arc, thread_manager: Arc, thread_store: Arc, @@ -46,6 +54,7 @@ pub(super) struct ExternalAgentSessionImporter { impl ExternalAgentSessionImporter { pub(super) fn new( codex_home: PathBuf, + connector_metadata_roots: Vec, thread_manager: Arc, thread_store: Arc, config_manager: ConfigManager, @@ -53,6 +62,7 @@ impl ExternalAgentSessionImporter { ) -> Self { Self { codex_home, + connector_metadata_roots, permits: Arc::new(Semaphore::new(1)), thread_manager, thread_store, @@ -91,8 +101,8 @@ impl ExternalAgentSessionImporter { match result { Ok(Some(completed_import)) => { item_result.record_success( - Some(completed_import.source_path.display().to_string()), - Some(completed_import.imported_thread_id.to_string()), + Some(completed_import.import.source_path.display().to_string()), + Some(completed_import.import.imported_thread_id.to_string()), ); completed_imports.push(completed_import); } @@ -107,6 +117,42 @@ impl ExternalAgentSessionImporter { } } } + let connector_attributions = completed_imports + .iter() + .filter_map(|completed_import| completed_import.connector_attribution.clone()) + .collect::>(); + let connector_metadata_roots = self.connector_metadata_roots.clone(); + let mut connector_names_by_session = match tokio::task::spawn_blocking(move || { + detect_imported_cla_session_connectors( + &connector_attributions, + &connector_metadata_roots, + ) + }) + .await + { + Ok(connector_names_by_session) => connector_names_by_session, + Err(err) => { + record_import_error( + &mut item_result, + "session_connector_detection_task", + err.to_string(), + /*source*/ None, + ); + Default::default() + } + }; + for completed_import in &mut completed_imports { + let Some(attribution) = &completed_import.connector_attribution else { + continue; + }; + completed_import.import.connector_names = connector_names_by_session + .remove(&attribution.session_id) + .unwrap_or_default(); + } + let completed_imports = completed_imports + .into_iter() + .map(|completed_import| completed_import.import) + .collect(); if let Err(err) = record_completed_session_imports(&self.codex_home, completed_imports) { record_import_error( &mut item_result, @@ -121,7 +167,7 @@ impl ExternalAgentSessionImporter { async fn import_requested_session( &self, session: ExternalAgentSessionMigration, - ) -> Result, SessionImportFailure> { + ) -> Result, SessionImportFailure> { let source_path = session.path.clone(); let Some(pending_import) = self.prepare_session_import(session) @@ -134,6 +180,16 @@ impl ExternalAgentSessionImporter { else { return Ok(None); }; + let connector_attribution = pending_import + .source_path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + .map(|session_id| ImportedSessionConnectorAttribution { + session_id: session_id.to_string(), + server_ids: pending_import.attributed_mcp_server_ids, + }); let imported_thread_id = self.persist_session(pending_import.session) .await @@ -142,10 +198,14 @@ impl ExternalAgentSessionImporter { message, stage: "session_persist", })?; - Ok(Some(CompletedExternalAgentSessionImport { - source_path: pending_import.source_path, - source_content_sha256: pending_import.source_content_sha256, - imported_thread_id, + Ok(Some(CompletedSessionImport { + import: CompletedExternalAgentSessionImport { + source_path: pending_import.source_path, + source_content_sha256: pending_import.source_content_sha256, + imported_thread_id, + connector_names: Vec::new(), + }, + connector_attribution, })) } diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index a4efc1d899d5..1a63adc09c74 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -14,6 +14,8 @@ use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse; use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; +use codex_app_server_protocol::ExternalAgentImportedConnectorCandidate; +use codex_app_server_protocol::ExternalAgentImportedConnectorSource; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; @@ -46,6 +48,21 @@ fn external_agent_home(codex_home: &Path) -> PathBuf { codex_home.join(concat!(".", "cla", "ude")) } +fn connector_metadata_root(home: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + { + home.join("Library/Application Support/Claude") + } + #[cfg(target_os = "windows")] + { + home.join("AppData/Roaming/Claude") + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + home.join(".config/Claude") + } +} + fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String { assert!(!response.import_id.is_empty()); response.import_id @@ -196,6 +213,7 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl ) .await??; let response: ExternalAgentConfigImportHistoriesReadResponse = to_response(response)?; + assert_eq!(response.connectors, Vec::new()); let entry = response .data .iter() @@ -749,10 +767,24 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); + let manifest_dir = connector_metadata_root(codex_home.path()) + .join("claude-code-sessions/account/organization"); let control_request = "src/auth.rs:1-5"; let first_request = "Fix auth flow"; std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; + std::fs::create_dir_all(&manifest_dir)?; + std::fs::write( + manifest_dir.join("session.json"), + serde_json::json!({ + "cliSessionId": "session", + "remoteMcpServersConfig": [ + { "name": "Gmail", "uuid": "gmail-server" }, + { "name": "Slack", "uuid": "slack-server" }, + ], + }) + .to_string(), + )?; std::fs::write( &session_path, [ @@ -774,6 +806,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { "type": "assistant", "cwd": &project_root, "timestamp": &recent_timestamp, + "attributionMcpServer": "gmail-server", "message": { "content": "first answer" }, }) .to_string(), @@ -861,6 +894,27 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .expect("session success should include imported thread id") .to_string(); + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import/readHistories", + /*params*/ None, + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportHistoriesReadResponse = to_response(response)?; + assert_eq!( + response.connectors, + vec![ExternalAgentImportedConnectorCandidate { + name: "Gmail".to_string(), + session_count: 1, + source: ExternalAgentImportedConnectorSource::RemoteMcpServersConfig, + }] + ); + let request_id = mcp .send_thread_list_request(ThreadListParams { cursor: None, diff --git a/codex-rs/external-agent-migration/src/sessions/connectors_cla.rs b/codex-rs/external-agent-migration/src/sessions/connectors_cla.rs new file mode 100644 index 000000000000..a164995661cf --- /dev/null +++ b/codex-rs/external-agent-migration/src/sessions/connectors_cla.rs @@ -0,0 +1,115 @@ +use serde::Deserialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +const SESSION_MANIFESTS_DIR: &str = "claude-code-sessions"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedSessionConnectorAttribution { + pub session_id: String, + pub server_ids: BTreeSet, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionManifest { + cli_session_id: Option, + #[serde(default)] + remote_mcp_servers_config: Vec, +} + +#[derive(Deserialize)] +struct RemoteMcpServerConfig { + name: Option, + uuid: Option, +} + +pub fn detect_imported_cla_session_connectors( + session_attributions: &[ImportedSessionConnectorAttribution], + connector_metadata_roots: &[PathBuf], +) -> BTreeMap> { + if session_attributions.is_empty() { + return BTreeMap::new(); + } + + let attributed_server_ids_by_session = session_attributions + .iter() + .map(|attribution| { + ( + attribution.session_id.clone(), + attribution.server_ids.clone(), + ) + }) + .collect::>(); + let mut connector_names_by_session = BTreeMap::>::new(); + + for metadata_root in connector_metadata_roots { + let manifests_root = metadata_root.join(SESSION_MANIFESTS_DIR); + for manifest_path in json_files_recursively(&manifests_root) { + let Some(manifest) = read_session_manifest(&manifest_path) else { + continue; + }; + let Some(session_id) = manifest.cli_session_id else { + continue; + }; + let Some(attributed_server_ids) = attributed_server_ids_by_session.get(&session_id) + else { + continue; + }; + if attributed_server_ids.is_empty() { + continue; + } + + let connector_names = connector_names_by_session.entry(session_id).or_default(); + for server in manifest.remote_mcp_servers_config { + let Some(uuid) = server.uuid else { + continue; + }; + if !attributed_server_ids.contains(&uuid) { + continue; + } + let Some(name) = super::normalized_connector_display_name(server.name.as_deref()) + else { + continue; + }; + connector_names.entry(name.to_lowercase()).or_insert(name); + } + } + } + + connector_names_by_session + .into_iter() + .map(|(session_id, names)| (session_id, names.into_values().collect())) + .collect() +} + +fn read_session_manifest(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + serde_json::from_str(&contents).ok() +} + +fn json_files_recursively(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + pending.push(entry.path()); + } else if file_type.is_file() + && entry.path().extension().and_then(|value| value.to_str()) == Some("json") + { + files.push(entry.path()); + } + } + } + files +} diff --git a/codex-rs/external-agent-migration/src/sessions/export.rs b/codex-rs/external-agent-migration/src/sessions/export.rs index ec34f9211f06..fc3335c781ea 100644 --- a/codex-rs/external-agent-migration/src/sessions/export.rs +++ b/codex-rs/external-agent-migration/src/sessions/export.rs @@ -18,6 +18,7 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_utils_output_truncation::approx_tokens_from_byte_count_i64; +use std::collections::BTreeSet; use std::io; use std::path::Path; @@ -25,19 +26,18 @@ const EXTERNAL_SESSION_IMPORTED_MARKER: &str = ""; #[cfg(test)] fn load_session_for_import(path: &Path) -> io::Result> { - Ok( - load_session_for_import_with_content_sha256(path)? - .map(|(session, _content_sha256)| session), - ) + Ok(load_session_for_import_with_content_sha256(path)? + .map(|(session, _content_sha256, _attributed_mcp_server_ids)| session)) } pub(crate) fn load_session_for_import_with_content_sha256( path: &Path, -) -> io::Result> { +) -> io::Result)>> { let parsed = read_session_import(path)?; let Some(cwd) = parsed.cwd else { return Ok(None); }; + let attributed_mcp_server_ids = parsed.attributed_mcp_server_ids; let messages = parsed.messages; let first_user_message_text = messages .iter() @@ -67,6 +67,7 @@ pub(crate) fn load_session_for_import_with_content_sha256( rollout_items, }, parsed.content_sha256, + attributed_mcp_server_ids, ))) } diff --git a/codex-rs/external-agent-migration/src/sessions/ledger.rs b/codex-rs/external-agent-migration/src/sessions/ledger.rs index 6fbd0ce2dc57..fa4c276e6cac 100644 --- a/codex-rs/external-agent-migration/src/sessions/ledger.rs +++ b/codex-rs/external-agent-migration/src/sessions/ledger.rs @@ -4,6 +4,7 @@ use serde::Deserialize; use serde::Serialize; use sha2::Digest; use sha2::Sha256; +use std::collections::BTreeMap; use std::collections::HashMap; use std::fs; use std::fs::File; @@ -28,6 +29,8 @@ struct ImportedExternalAgentSessionRecord { imported_at: i64, #[serde(default)] source_modified_at: Option, + #[serde(default)] + connector_names: Vec, } #[derive(Debug, PartialEq, Eq)] @@ -35,6 +38,13 @@ pub struct CompletedExternalAgentSessionImport { pub source_path: PathBuf, pub source_content_sha256: String, pub imported_thread_id: ThreadId, + pub connector_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedConnectorCandidate { + pub name: String, + pub session_count: u32, } #[derive(Debug, Clone, Copy)] @@ -63,6 +73,7 @@ pub(crate) fn record_imported_session( source_content_sha256: session_content_sha256(&source_path)?, source_path, imported_thread_id, + connector_names: Vec::new(), }], ) } @@ -86,6 +97,7 @@ pub fn record_completed_session_imports( record.imported_thread_id = import.imported_thread_id; record.imported_at = imported_at; record.source_modified_at = source_modified_at.or(record.source_modified_at); + record.connector_names = import.connector_names; ledger.records.push(record); continue; } @@ -95,11 +107,42 @@ pub fn record_completed_session_imports( imported_thread_id: import.imported_thread_id, imported_at, source_modified_at, + connector_names: import.connector_names, }); } save_import_ledger(codex_home, &ledger) } +pub fn read_imported_connector_candidates( + codex_home: &Path, +) -> io::Result> { + let ledger = load_import_ledger(codex_home)?; + let mut connector_names_by_source = BTreeMap::new(); + for record in ledger.records { + connector_names_by_source.insert(record.source_path, record.connector_names); + } + let mut candidates_by_name = BTreeMap::::new(); + for connector_names in connector_names_by_source.into_values() { + let connector_names = connector_names + .into_iter() + .filter_map(|name| super::normalized_connector_display_name(Some(&name))) + .map(|name| (name.to_lowercase(), name)) + .collect::>(); + for (key, name) in connector_names { + let candidate = candidates_by_name + .entry(key) + .or_insert(ImportedConnectorCandidate { + name, + session_count: 0, + }); + candidate.session_count = candidate.session_count.saturating_add(1); + } + } + let mut candidates = candidates_by_name.into_values().collect::>(); + candidates.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(candidates) +} + impl ImportedExternalAgentSessionLedger { pub(super) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> { let mut states = HashMap::new(); diff --git a/codex-rs/external-agent-migration/src/sessions/ledger_tests.rs b/codex-rs/external-agent-migration/src/sessions/ledger_tests.rs index f4b9dd6f7c2a..fd8843d442a2 100644 --- a/codex-rs/external-agent-migration/src/sessions/ledger_tests.rs +++ b/codex-rs/external-agent-migration/src/sessions/ledger_tests.rs @@ -1,5 +1,7 @@ use super::CompletedExternalAgentSessionImport; +use super::ImportedConnectorCandidate; use super::ImportedExternalAgentSessionLedger; +use super::read_imported_connector_candidates; use super::record_completed_session_imports; use codex_protocol::ThreadId; use sha2::Digest; @@ -35,6 +37,7 @@ fn completed_imports_do_not_read_source_files() { source_path: source_path.clone(), source_content_sha256: format!("{:x}", Sha256::digest(contents)), imported_thread_id, + connector_names: Vec::new(), }], ) .expect("record completed imports"); @@ -64,6 +67,7 @@ fn completed_import_refreshes_existing_record_metadata() { source_path: source_path.clone(), source_content_sha256: content_sha256.clone(), imported_thread_id: first_thread_id, + connector_names: vec!["Gmail".to_string()], }], ) .expect("record first import"); @@ -73,6 +77,7 @@ fn completed_import_refreshes_existing_record_metadata() { source_path: source_path.clone(), source_content_sha256: content_sha256, imported_thread_id: second_thread_id, + connector_names: vec!["Slack".to_string()], }], ) .expect("record replacement import"); @@ -82,4 +87,52 @@ fn completed_import_refreshes_existing_record_metadata() { assert_eq!(ledger.records[0].source_path, source_path); assert_eq!(ledger.records[0].imported_thread_id, second_thread_id); assert!(ledger.records[0].source_modified_at.is_some()); + assert_eq!(ledger.records[0].connector_names, vec!["Slack"]); +} + +#[test] +fn connector_candidates_use_latest_import_for_each_source() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let first_source = root.path().join("first.jsonl"); + let second_source = root.path().join("second.jsonl"); + + record_completed_session_imports( + &codex_home, + vec![ + CompletedExternalAgentSessionImport { + source_path: first_source.clone(), + source_content_sha256: "first-version".to_string(), + imported_thread_id: ThreadId::new(), + connector_names: vec!["Gmail".to_string()], + }, + CompletedExternalAgentSessionImport { + source_path: first_source, + source_content_sha256: "second-version".to_string(), + imported_thread_id: ThreadId::new(), + connector_names: vec!["Slack".to_string()], + }, + CompletedExternalAgentSessionImport { + source_path: second_source, + source_content_sha256: "only-version".to_string(), + imported_thread_id: ThreadId::new(), + connector_names: vec!["Gmail".to_string(), "Slack".to_string()], + }, + ], + ) + .expect("record imports"); + + assert_eq!( + read_imported_connector_candidates(&codex_home).expect("read connector candidates"), + vec![ + ImportedConnectorCandidate { + name: "Gmail".to_string(), + session_count: 1, + }, + ImportedConnectorCandidate { + name: "Slack".to_string(), + session_count: 2, + }, + ] + ); } diff --git a/codex-rs/external-agent-migration/src/sessions/mod.rs b/codex-rs/external-agent-migration/src/sessions/mod.rs index cfaeef5b57a2..0369f4961657 100644 --- a/codex-rs/external-agent-migration/src/sessions/mod.rs +++ b/codex-rs/external-agent-migration/src/sessions/mod.rs @@ -1,5 +1,6 @@ //! Parsing and export helpers for external-agent session histories. +mod connectors_cla; mod detect_cla; mod export; mod ledger; @@ -7,20 +8,31 @@ mod records; mod title; use codex_protocol::protocol::RolloutItem; +use std::collections::BTreeSet; use std::io; use std::path::Path; use std::path::PathBuf; +pub use connectors_cla::ImportedSessionConnectorAttribution; +pub use connectors_cla::detect_imported_cla_session_connectors; pub use detect_cla::detect_recent_cla_sessions; use export::load_session_for_import_with_content_sha256; pub use ledger::CompletedExternalAgentSessionImport; +pub use ledger::ImportedConnectorCandidate; pub use ledger::has_current_session_been_imported; +pub use ledger::read_imported_connector_candidates; pub use ledger::record_completed_session_imports; pub use records::SessionSummary; pub use records::summarize_session; const SESSION_TITLE_MAX_LEN: usize = 120; +fn normalized_connector_display_name(name: Option<&str>) -> Option { + name.map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExternalAgentSessionMigration { pub path: PathBuf, @@ -40,6 +52,7 @@ pub struct ImportedExternalAgentSession { pub struct PendingSessionImport { pub source_path: PathBuf, pub source_content_sha256: String, + pub attributed_mcp_server_ids: BTreeSet, pub session: ImportedExternalAgentSession, } @@ -51,32 +64,25 @@ pub fn prepare_validated_session_import( if has_been_imported { return Ok(None); } - let Some((source_path, imported_session, source_content_sha256)) = - load_importable_session(&session.path)? - else { - return Ok(None); - }; - Ok(Some(PendingSessionImport { - source_path, - source_content_sha256, - session: imported_session, - })) + load_importable_session(&session.path) } -fn load_importable_session( - path: &Path, -) -> io::Result> { +fn load_importable_session(path: &Path) -> io::Result> { let source_path = std::fs::canonicalize(path)?; - let Some((imported_session, source_content_sha256)) = + let Some((imported_session, source_content_sha256, attributed_mcp_server_ids)) = load_session_for_import_with_content_sha256(&source_path)? else { return Ok(None); }; - Ok(imported_session.cwd.is_dir().then_some(( - source_path, - imported_session, - source_content_sha256, - ))) + Ok(imported_session + .cwd + .is_dir() + .then_some(PendingSessionImport { + source_path, + source_content_sha256, + attributed_mcp_server_ids, + session: imported_session, + })) } #[derive(Debug, Clone)] diff --git a/codex-rs/external-agent-migration/src/sessions/records.rs b/codex-rs/external-agent-migration/src/sessions/records.rs index 3ab968c43782..f19bfd11ab7e 100644 --- a/codex-rs/external-agent-migration/src/sessions/records.rs +++ b/codex-rs/external-agent-migration/src/sessions/records.rs @@ -8,6 +8,7 @@ use super::truncate; use serde_json::Value as JsonValue; use sha2::Digest; use sha2::Sha256; +use std::collections::BTreeSet; use std::fs::File; use std::io; use std::io::BufRead; @@ -31,6 +32,7 @@ pub(super) struct ParsedSessionImport { pub ai_title: Option, pub messages: Vec, pub content_sha256: String, + pub attributed_mcp_server_ids: BTreeSet, } pub fn summarize_session(path: &Path) -> io::Result> { @@ -114,6 +116,7 @@ pub(super) fn read_session_import(path: &Path) -> io::Result io::Result(trimmed) else { continue; }; + if let Some(server_id) = record + .get("attributionMcpServer") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|server_id| !server_id.is_empty()) + { + attributed_mcp_server_ids.insert(server_id.to_string()); + } if cwd.is_none() { cwd = record .get("cwd") @@ -151,6 +162,7 @@ pub(super) fn read_session_import(path: &Path) -> io::Result