From befcf12158ccf9bfd7c5407ff74edff5c6ad5bf3 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 7 Jul 2026 15:22:25 -0700 Subject: [PATCH] app-server: add app/installed runtime snapshot API --- .../schema/json/ClientRequest.json | 44 ++ .../codex_app_server_protocol.schemas.json | 85 +++ .../codex_app_server_protocol.v2.schemas.json | 85 +++ .../schema/json/v2/AppsInstalledParams.json | 22 + .../schema/json/v2/AppsInstalledResponse.json | 41 + .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/AppsInstalledParams.ts | 17 + .../typescript/v2/AppsInstalledResponse.ts | 9 + .../schema/typescript/v2/InstalledApp.ts | 18 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 67 ++ .../src/protocol/v2/apps.rs | 35 + codex-rs/app-server/README.md | 29 + codex-rs/app-server/src/message_processor.rs | 5 + codex-rs/app-server/src/request_processors.rs | 4 + .../src/request_processors/apps_processor.rs | 235 ++++++ .../request_processors/config_processor.rs | 1 + .../tests/common/test_app_server.rs | 10 + .../tests/suite/v2/app_installed.rs | 717 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/chatgpt/src/workspace_settings.rs | 63 +- codex-rs/codex-mcp/src/connector_runtime.rs | 17 + .../codex-mcp/src/connector_runtime_tests.rs | 25 + codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/tools.rs | 5 + codex-rs/core/src/connectors.rs | 137 +++- codex-rs/core/src/connectors_tests.rs | 80 ++ 27 files changed, 1745 insertions(+), 14 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/AppsInstalledParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/AppsInstalledResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppsInstalledParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppsInstalledResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/InstalledApp.ts create mode 100644 codex-rs/app-server/tests/suite/v2/app_installed.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 2f39f821b6a8..4d2e79756c09 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -87,6 +87,26 @@ ], "type": "string" }, + "AppsInstalledParams": { + "description": "Read the committed installed connector runtime snapshot.", + "properties": { + "reload": { + "description": "When true, refresh workspace policy and, when Apps are permitted, atomically refresh and publish the hosted connector runtime snapshot first.", + "type": "boolean" + }, + "threadId": { + "description": "Optional loaded thread id used to evaluate effective app configuration.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "reload" + ], + "type": "object" + }, "AppsListParams": { "description": "EXPERIMENTAL - list available apps/connectors.", "properties": { @@ -5704,6 +5724,30 @@ "title": "App/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "app/installed" + ], + "title": "App/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppsInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/installedRequest", + "type": "object" + }, { "properties": { "id": { 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 53bd7cef4ec6..8125c442c515 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 @@ -1093,6 +1093,30 @@ "title": "App/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "app/installed" + ], + "title": "App/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/AppsInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -6819,6 +6843,45 @@ }, "type": "object" }, + "AppsInstalledParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Read the committed installed connector runtime snapshot.", + "properties": { + "reload": { + "description": "When true, refresh workspace policy and, when Apps are permitted, atomically refresh and publish the hosted connector runtime snapshot first.", + "type": "boolean" + }, + "threadId": { + "description": "Optional loaded thread id used to evaluate effective app configuration.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "reload" + ], + "title": "AppsInstalledParams", + "type": "object" + }, + "AppsInstalledResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "The installed connectors in one committed runtime snapshot.", + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/v2/InstalledApp" + }, + "type": "array" + } + }, + "required": [ + "apps" + ], + "title": "AppsInstalledResponse", + "type": "object" + }, "AppsListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "EXPERIMENTAL - list available apps/connectors.", @@ -11185,6 +11248,28 @@ } ] }, + "InstalledApp": { + "description": "Installed connector runtime state.", + "properties": { + "callable": { + "description": "Whether the connector is enabled and has a non-synthetic, model-visible tool allowed by effective MCP and app/tool policy in the committed runtime snapshot.", + "type": "boolean" + }, + "enabled": { + "description": "Effective enabled state after applying global, workspace, local, and managed configuration at read time.", + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "callable", + "enabled", + "id" + ], + "type": "object" + }, "InternalChatMessageMetadataPassthrough": { "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", "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 11540d40c76d..d10562da74a7 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 @@ -945,6 +945,45 @@ }, "type": "object" }, + "AppsInstalledParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Read the committed installed connector runtime snapshot.", + "properties": { + "reload": { + "description": "When true, refresh workspace policy and, when Apps are permitted, atomically refresh and publish the hosted connector runtime snapshot first.", + "type": "boolean" + }, + "threadId": { + "description": "Optional loaded thread id used to evaluate effective app configuration.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "reload" + ], + "title": "AppsInstalledParams", + "type": "object" + }, + "AppsInstalledResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "The installed connectors in one committed runtime snapshot.", + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/InstalledApp" + }, + "type": "array" + } + }, + "required": [ + "apps" + ], + "title": "AppsInstalledResponse", + "type": "object" + }, "AppsListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "EXPERIMENTAL - list available apps/connectors.", @@ -2124,6 +2163,30 @@ "title": "App/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "app/installed" + ], + "title": "App/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppsInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -7589,6 +7652,28 @@ } ] }, + "InstalledApp": { + "description": "Installed connector runtime state.", + "properties": { + "callable": { + "description": "Whether the connector is enabled and has a non-synthetic, model-visible tool allowed by effective MCP and app/tool policy in the committed runtime snapshot.", + "type": "boolean" + }, + "enabled": { + "description": "Effective enabled state after applying global, workspace, local, and managed configuration at read time.", + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "callable", + "enabled", + "id" + ], + "type": "object" + }, "InternalChatMessageMetadataPassthrough": { "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsInstalledParams.json b/codex-rs/app-server-protocol/schema/json/v2/AppsInstalledParams.json new file mode 100644 index 000000000000..bd1b2e086212 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsInstalledParams.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Read the committed installed connector runtime snapshot.", + "properties": { + "reload": { + "description": "When true, refresh workspace policy and, when Apps are permitted, atomically refresh and publish the hosted connector runtime snapshot first.", + "type": "boolean" + }, + "threadId": { + "description": "Optional loaded thread id used to evaluate effective app configuration.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "reload" + ], + "title": "AppsInstalledParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsInstalledResponse.json new file mode 100644 index 000000000000..d80cf7fc572c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsInstalledResponse.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "InstalledApp": { + "description": "Installed connector runtime state.", + "properties": { + "callable": { + "description": "Whether the connector is enabled and has a non-synthetic, model-visible tool allowed by effective MCP and app/tool policy in the committed runtime snapshot.", + "type": "boolean" + }, + "enabled": { + "description": "Effective enabled state after applying global, workspace, local, and managed configuration at read time.", + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "callable", + "enabled", + "id" + ], + "type": "object" + } + }, + "description": "The installed connectors in one committed runtime snapshot.", + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/InstalledApp" + }, + "type": "array" + } + }, + "required": [ + "apps" + ], + "title": "AppsInstalledResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 6af04ef40119..0d393ff1c15d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -7,6 +7,7 @@ import type { GetConversationSummaryParams } from "./GetConversationSummaryParam import type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; import type { InitializeParams } from "./InitializeParams"; import type { RequestId } from "./RequestId"; +import type { AppsInstalledParams } from "./v2/AppsInstalledParams"; import type { AppsListParams } from "./v2/AppsListParams"; import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams"; import type { CommandExecParams } from "./v2/CommandExecParams"; @@ -88,4 +89,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsInstalledParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsInstalledParams.ts new file mode 100644 index 000000000000..0899e804ffaa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsInstalledParams.ts @@ -0,0 +1,17 @@ +// 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. + +/** + * Read the committed installed connector runtime snapshot. + */ +export type AppsInstalledParams = { +/** + * Optional loaded thread id used to evaluate effective app configuration. + */ +threadId?: string | null, +/** + * When true, refresh workspace policy and, when Apps are permitted, atomically refresh and + * publish the hosted connector runtime snapshot first. + */ +reload: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsInstalledResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsInstalledResponse.ts new file mode 100644 index 000000000000..4978452a6c82 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsInstalledResponse.ts @@ -0,0 +1,9 @@ +// 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 { InstalledApp } from "./InstalledApp"; + +/** + * The installed connectors in one committed runtime snapshot. + */ +export type AppsInstalledResponse = { apps: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/InstalledApp.ts b/codex-rs/app-server-protocol/schema/typescript/v2/InstalledApp.ts new file mode 100644 index 000000000000..ce8e9de7710e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/InstalledApp.ts @@ -0,0 +1,18 @@ +// 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. + +/** + * Installed connector runtime state. + */ +export type InstalledApp = { id: string, +/** + * Effective enabled state after applying global, workspace, local, and managed configuration + * at read time. + */ +enabled: boolean, +/** + * Whether the connector is enabled and has a non-synthetic, model-visible tool allowed by + * effective MCP and app/tool policy in the committed runtime snapshot. + */ +callable: boolean, }; 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 00d39b31e35c..225d4ee3421a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -30,6 +30,8 @@ export type { AppToolsConfig } from "./AppToolsConfig"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; export type { AppsConfig } from "./AppsConfig"; export type { AppsDefaultConfig } from "./AppsDefaultConfig"; +export type { AppsInstalledParams } from "./AppsInstalledParams"; +export type { AppsInstalledResponse } from "./AppsInstalledResponse"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; @@ -186,6 +188,7 @@ export type { HookTrustStatus } from "./HookTrustStatus"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; +export type { InstalledApp } from "./InstalledApp"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c4b94d8b3fb3..f41814a412f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -734,6 +734,11 @@ client_request_definitions! { serialization: None, response: v2::AppsListResponse, }, + AppsInstalled => "app/installed" { + params: v2::AppsInstalledParams, + serialization: None, + response: v2::AppsInstalledResponse, + }, // File system requests are intentionally concurrent. Desktop already treats local // file system operations as concurrent, and app-server remote fs mirrors that model. FsReadFile => "fs/readFile" { @@ -3005,6 +3010,68 @@ mod tests { Ok(()) } + #[test] + fn serialize_installed_apps() -> Result<()> { + let request = ClientRequest::AppsInstalled { + request_id: RequestId::Integer(9), + params: v2::AppsInstalledParams::default(), + }; + assert_eq!( + json!({ + "method": "app/installed", + "id": 9, + "params": { + "threadId": null, + "reload": false + } + }), + serde_json::to_value(&request)?, + ); + + let reload_request = ClientRequest::AppsInstalled { + request_id: RequestId::Integer(10), + params: v2::AppsInstalledParams { + thread_id: Some("thread-1".to_string()), + reload: true, + }, + }; + assert_eq!( + json!({ + "method": "app/installed", + "id": 10, + "params": { + "threadId": "thread-1", + "reload": true + } + }), + serde_json::to_value(&reload_request)?, + ); + Ok(()) + } + + #[test] + fn serialize_installed_apps_response() -> Result<()> { + let response = v2::AppsInstalledResponse { + apps: vec![v2::InstalledApp { + id: "demo-app".to_string(), + enabled: false, + callable: false, + }], + }; + + assert_eq!( + json!({ + "apps": [{ + "id": "demo-app", + "enabled": false, + "callable": false + }] + }), + serde_json::to_value(response)?, + ); + Ok(()) + } + #[test] fn serialize_environment_add() -> Result<()> { let request = ClientRequest::EnvironmentAdd { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/apps.rs b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs index f14c11b0d826..b5c199dd8ff0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/apps.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs @@ -24,6 +24,41 @@ pub struct AppsListParams { pub force_refetch: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Read the committed installed connector runtime snapshot. +pub struct AppsInstalledParams { + /// Optional loaded thread id used to evaluate effective app configuration. + #[ts(optional = nullable)] + pub thread_id: Option, + /// When true, refresh workspace policy and, when Apps are permitted, atomically refresh and + /// publish the hosted connector runtime snapshot first. + pub reload: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Installed connector runtime state. +pub struct InstalledApp { + pub id: String, + /// Effective enabled state after applying global, workspace, local, and managed configuration + /// at read time. + pub enabled: bool, + /// Whether the connector is enabled and has a non-synthetic, model-visible tool allowed by + /// effective MCP and app/tool policy in the committed runtime snapshot. + pub callable: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// The installed connectors in one committed runtime snapshot. +pub struct AppsInstalledResponse { + pub apps: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d52f1bd16851..e47a43bba23e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -218,6 +218,7 @@ Example with notification opt-out: - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. +- `app/installed` — read installed connector runtime state from the last committed snapshot, optionally refreshing it first. - `app/list` — list available apps. - `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. By default, any missing enrollment is completed before the response and the preference is persisted for the current app-server client scope. Pass `ephemeral: true` to enable remote control only for the current process without changing the persisted preference. - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. By default, the disabled preference is persisted for the current app-server client scope. Pass `ephemeral: true` to disable only for the current process without changing the persisted preference. This does not revoke already enrolled controller devices. @@ -1788,6 +1789,34 @@ To disable a non-managed hook, upsert a state entry at `hooks.state` with `confi To re-enable it, upsert the same hook key with `"enabled": true`. ## Apps +Use `app/installed` to read installed connector runtime state without loading connector-directory metadata. The method is available when the under-development `apps_runtime_state_refactor` feature is enabled. When that feature is disabled, clients can continue using the legacy `app/list` compatibility path. + +```json +{ "method": "app/installed", "id": 49, "params": { + "threadId": "thr_123", + "reload": false +} } +{ "id": 49, "result": { + "apps": [ + { + "id": "demo-app", + "enabled": true, + "callable": true + } + ] +} } +``` + +Each returned row is a unique, non-empty connector ID observed on at least one tool in the committed runtime snapshot. IDs are retained before read-time configuration and policy projection, so a connector whose returned tools are locally disabled or policy-blocked remains present with `callable: false`. A connector with no tools in `/ps/mcp tools/list` is absent; this is a deliberate narrowing from the RFC draft because the runtime no longer consumes `_meta.installedApps`. + +`enabled` is computed from effective global, workspace, local, and managed configuration for `threadId`, or from the current global configuration when `threadId` is omitted. `callable` is true only when the connector is enabled and the committed snapshot contains at least one non-synthetic, model-visible tool allowed by effective MCP and app/tool policy. + +Full connector metadata is intentionally outside this response. Once the M3 dependency lands, canonical names, descriptions, branding, labels, and install URLs remain the responsibility of `app/read` and `ConnectorMetadataStore`; tool-level connector name and description fields are compact runtime/search metadata, not the canonical metadata authority. + +The response deliberately omits connector MCP tool definitions. Those remain internal to runtime publication through the host-owned `codex_apps` MCP path. The endpoint also deliberately returns the full lightweight `apps` array without cursor pagination: it is an atomic read of one committed snapshot rather than a directory-listing API, and returning it as one value avoids mixing snapshot generations across pages. + +`reload` is required. With `reload: false`, the server reads the last committed snapshot and cached workspace policy without a network refresh; unknown workspace policy fails closed for `enabled` and `callable` while retaining tool-derived identities. With `reload: true`, it refreshes workspace policy and, when global and workspace policy permit Apps, performs one uncached hosted-connector refresh under the shared refresh lock, publishes the resulting snapshot atomically, and returns that exact committed state. Global or workspace policy blocks skip the network tools refresh, retain any previously committed identities, and report them as disabled and non-callable. + Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, legacy logo URLs, structured light and dark icon assets, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. ```json diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 29ace7d99fcc..146c5416c981 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1290,6 +1290,11 @@ impl MessageProcessor { ClientRequest::AppsList { params, .. } => { self.apps_processor.apps_list(&request_id, params).await } + ClientRequest::AppsInstalled { params, .. } => self + .apps_processor + .apps_installed(params) + .await + .map(|response| Some(response.into())), ClientRequest::SkillsConfigWrite { params, .. } => { self.catalog_processor.skills_config_write(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f03351468faa..641fe072f13a 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -33,6 +33,8 @@ use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::AppTemplateSummary; use codex_app_server_protocol::AppTemplateUnavailableReason; +use codex_app_server_protocol::AppsInstalledParams; +use codex_app_server_protocol::AppsInstalledResponse; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; @@ -88,6 +90,7 @@ use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InitializeResponse; +use codex_app_server_protocol::InstalledApp; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; @@ -542,6 +545,7 @@ pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor; use crate::error_code::internal_error; use crate::error_code::invalid_request; +use crate::error_code::method_not_found; use crate::filters::compute_source_filters; use crate::filters::source_kind_matches; use crate::thread_state::ConnectionCapabilities; diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 5ff7ec40db5a..3ef8c57e5eca 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -1,5 +1,6 @@ use super::*; use crate::app_info::app_info_to_api; +use codex_core::connectors as core_connectors; pub(crate) struct AppsRequestProcessor { auth_manager: Arc, @@ -32,6 +33,154 @@ impl AppsRequestProcessor { } } + pub(crate) async fn apps_installed( + &self, + params: AppsInstalledParams, + ) -> Result { + let started_at = Instant::now(); + let reload = params.reload; + let mut retained_previous_snapshot = false; + let mut refresh_disposition = if reload { + "not_started" + } else { + "not_requested" + }; + let mut snapshot_age = None; + let mut snapshot_tool_count = 0; + let result = async { + let config = self + .load_apps_installed_config(params.thread_id.as_deref()) + .await?; + if !config.features.enabled(Feature::AppsRuntimeStateRefactor) { + return Err(method_not_found( + "app/installed is not enabled for this app-server", + )); + } + + let auth = self.auth_manager.auth().await; + let apps_enabled = config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)); + + // A cached read must not turn into a workspace-settings network request. Unknown + // workspace policy fails closed while retaining installed identities; an explicit + // reload bypasses the policy cache. + let workspace_policy = if !apps_enabled { + Ok(false) + } else if reload { + workspace_settings::refresh_codex_plugins_enabled_for_workspace( + &config, + auth.as_ref(), + Some(&self.workspace_settings_cache), + ) + .await + } else { + Ok(workspace_settings::cached_codex_plugins_enabled_for_workspace( + &config, + auth.as_ref(), + &self.workspace_settings_cache, + ) + .unwrap_or(false)) + }; + if let Err(err) = &workspace_policy { + warn!( + "failed to refresh workspace Codex plugins setting; disabling Codex plugins for this read: {err:#}" + ); + } + let workspace_enabled = workspace_policy.as_ref().copied().unwrap_or(false); + let runtime_enabled = apps_enabled && workspace_enabled; + + let mcp_manager = self.thread_manager.mcp_manager(); + let previous_snapshot = core_connectors::connector_runtime_snapshot( + &config, + auth.as_ref(), + mcp_manager.as_ref(), + ); + let snapshot = if reload && runtime_enabled { + match core_connectors::refresh_connector_runtime_snapshot( + &config, + auth.as_ref(), + self.thread_manager.environment_manager(), + mcp_manager.as_ref(), + ) + .await + { + Ok(snapshot) => { + refresh_disposition = "success"; + Some(snapshot) + } + Err(err) => { + refresh_disposition = "error"; + retained_previous_snapshot = + core_connectors::peek_connector_runtime_snapshot( + &config, + auth.as_ref(), + mcp_manager.as_ref(), + ) + .is_some(); + return Err(internal_error(format!( + "failed to refresh installed connector runtime state: {err:#}" + ))); + } + } + } else { + if reload { + refresh_disposition = if !apps_enabled { + "skipped_apps_disabled" + } else if workspace_policy.is_err() { + "skipped_workspace_policy_error" + } else { + "skipped_workspace_disabled" + }; + retained_previous_snapshot = previous_snapshot.is_some(); + } + previous_snapshot + }; + let Some(snapshot) = snapshot else { + return Ok(AppsInstalledResponse { apps: Vec::new() }); + }; + + snapshot_age = Some(snapshot.age()); + snapshot_tool_count = snapshot.tools().len(); + let connector_ids = core_connectors::installed_app_ids(snapshot.as_ref()); + let callable_connector_ids = if runtime_enabled { + core_connectors::callable_app_ids( + &config, + auth.as_ref(), + mcp_manager.as_ref(), + snapshot.as_ref(), + ) + .await + } else { + Default::default() + }; + let apps = connector_ids + .into_iter() + .map(|id| { + let enabled = runtime_enabled && core_connectors::app_is_enabled(&config, &id); + InstalledApp { + id: id.clone(), + enabled, + callable: runtime_enabled && callable_connector_ids.contains(&id), + } + }) + .collect::>(); + Ok(AppsInstalledResponse { apps }) + } + .await; + + record_apps_installed_metrics( + started_at, + reload, + retained_previous_snapshot, + refresh_disposition, + snapshot_age, + snapshot_tool_count, + result.as_ref().ok(), + ); + result + } + pub(crate) async fn apps_list( &self, request_id: &ConnectionRequestId, @@ -342,6 +491,21 @@ impl AppsRequestProcessor { Ok((thread_id, thread)) } + async fn load_apps_installed_config( + &self, + thread_id: Option<&str>, + ) -> Result { + let Some(thread_id) = thread_id else { + return self.load_latest_config(/*fallback_cwd*/ None).await; + }; + let (_, thread) = self.load_thread(thread_id).await?; + let thread_config = thread.config().await; + self.config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + async fn load_latest_config( &self, fallback_cwd: Option, @@ -379,6 +543,77 @@ const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); // `app/list` is the legacy request-path baseline for the future `app/installed` endpoint; // `path=legacy` keeps it separate from the new snapshot-backed implementation in dashboards. const APPS_INSTALLED_DURATION_METRIC: &str = "codex.apps.installed.duration_ms"; +const APPS_INSTALLED_RESPONSE_BYTES_METRIC: &str = "codex.apps.installed.response_bytes"; +const APPS_INSTALLED_CONNECTOR_COUNT_METRIC: &str = "codex.apps.installed.connector_count"; +const APPS_INSTALLED_TOOL_COUNT_METRIC: &str = "codex.apps.installed.tool_count"; +const APPS_SNAPSHOT_AGE_METRIC: &str = "codex.apps.snapshot.age_ms"; + +fn record_apps_installed_metrics( + started_at: Instant, + reload: bool, + retained_previous_snapshot: bool, + refresh_disposition: &'static str, + snapshot_age: Option, + snapshot_tool_count: usize, + response: Option<&AppsInstalledResponse>, +) { + let Some(metrics) = codex_otel::global() else { + return; + }; + let reload = if reload { "true" } else { "false" }; + let outcome = if response.is_some() { + "success" + } else { + "error" + }; + let retained_previous_snapshot = if retained_previous_snapshot { + "true" + } else { + "false" + }; + let _ = metrics.record_duration( + APPS_INSTALLED_DURATION_METRIC, + started_at.elapsed(), + &[ + ("path", "new"), + ("reload", reload), + ("refresh", refresh_disposition), + ("outcome", outcome), + ("retained_previous_snapshot", retained_previous_snapshot), + ], + ); + let Some(response) = response else { + return; + }; + if let Ok(bytes) = serde_json::to_vec(response) { + let _ = metrics.histogram( + APPS_INSTALLED_RESPONSE_BYTES_METRIC, + usize_to_i64(bytes.len()), + &[("path", "new")], + ); + } + let _ = metrics.histogram( + APPS_INSTALLED_CONNECTOR_COUNT_METRIC, + usize_to_i64(response.apps.len()), + &[("path", "new")], + ); + let _ = metrics.histogram( + APPS_INSTALLED_TOOL_COUNT_METRIC, + usize_to_i64(snapshot_tool_count), + &[("path", "new")], + ); + if let Some(snapshot_age) = snapshot_age { + let _ = metrics.record_duration( + APPS_SNAPSHOT_AGE_METRIC, + snapshot_age, + &[("path", "new"), ("observation", "installed")], + ); + } +} + +fn usize_to_i64(value: usize) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} fn record_legacy_apps_installed_duration(started_at: Instant, reload: bool) { let reload = if reload { "true" } else { "false" }; diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b6dbb30b0834..58f4f5bb5030 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -48,6 +48,7 @@ use serde_json::json; use std::path::PathBuf; const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ + "apps_runtime_state_refactor", "auth_elicitation", "memories", "mentions_v2", diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 0fc0594baa95..9e00a1aa9154 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -13,6 +13,7 @@ use tokio::process::ChildStdout; use anyhow::Context; use anyhow::ensure; +use codex_app_server_protocol::AppsInstalledParams; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientInfo; @@ -880,6 +881,15 @@ impl TestAppServer { self.send_request("app/list", params).await } + /// Send an `app/installed` JSON-RPC request. + pub async fn send_apps_installed_request( + &mut self, + params: AppsInstalledParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("app/installed", params).await + } + /// Send an `mcpServer/resource/read` JSON-RPC request. pub async fn send_mcp_resource_read_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/app_installed.rs b/codex-rs/app-server/tests/suite/v2/app_installed.rs new file mode 100644 index 000000000000..543b2169b581 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/app_installed.rs @@ -0,0 +1,717 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Json; +use axum::Router; +use axum::routing::get; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppsInstalledParams; +use codex_app_server_protocol::AppsInstalledResponse; +use codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::AppsListResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::ListToolsResult; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +use super::app_list::connector_tool; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio::test] +async fn installed_apps_is_method_not_found_when_feature_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_apps_installed_request(AppsInstalledParams::default()) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32601); + assert_eq!( + error.error.message, + "app/installed is not enabled for this app-server" + ); + Ok(()) +} + +#[tokio::test] +async fn installed_apps_reload_commits_tool_derived_ids_and_cached_read_does_not_fetch() +-> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + let codex_home = configured_codex_home(fixture.base_url())?; + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let initially_empty = send_installed_request(&mut app_server, /*reload*/ false).await?; + assert_eq!(initially_empty, AppsInstalledResponse { apps: Vec::new() }); + assert_eq!(fixture.list_tools_calls(), 0); + assert_eq!(fixture.workspace_settings_calls(), 0); + + let refreshed = send_installed_request(&mut app_server, /*reload*/ true).await?; + assert_eq!( + refreshed.apps, + vec![ + codex_app_server_protocol::InstalledApp { + id: "alpha".to_string(), + enabled: true, + callable: true, + }, + codex_app_server_protocol::InstalledApp { + id: "blocked".to_string(), + enabled: true, + callable: false, + }, + codex_app_server_protocol::InstalledApp { + id: "disabled".to_string(), + enabled: false, + callable: false, + }, + ] + ); + assert_eq!(fixture.list_tools_calls(), 1); + assert_eq!(fixture.workspace_settings_calls(), 1); + + let cached = send_installed_request(&mut app_server, /*reload*/ false).await?; + assert_eq!(cached, refreshed); + assert_eq!(fixture.list_tools_calls(), 1); + assert_eq!(fixture.workspace_settings_calls(), 1); + fixture.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn installed_apps_reload_commits_empty_tools_result_and_cached_read_does_not_fetch() +-> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + fixture.set_tools(Vec::new()); + let codex_home = configured_codex_home(fixture.base_url())?; + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let refreshed = send_installed_request(&mut app_server, /*reload*/ true).await?; + assert_eq!(refreshed, AppsInstalledResponse { apps: Vec::new() }); + assert_eq!(fixture.list_tools_calls(), 1); + + let cached = send_installed_request(&mut app_server, /*reload*/ false).await?; + assert_eq!(cached, refreshed); + assert_eq!(fixture.list_tools_calls(), 1); + fixture.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn installed_apps_does_not_treat_synthetic_link_as_installed_identity() -> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + let mut synthetic_link = connector_tool("link-only", "Link Only")?; + synthetic_link + .meta + .as_mut() + .expect("connector tool should have metadata") + .0 + .insert("_codex_apps".to_string(), json!({ "synthetic_link": true })); + fixture.set_tools(vec![ + synthetic_link, + connector_tool("alpha", "Alpha Tool Name")?, + ]); + let codex_home = configured_codex_home(fixture.base_url())?; + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let response = send_installed_request(&mut app_server, /*reload*/ true).await?; + assert_eq!( + response.apps, + vec![codex_app_server_protocol::InstalledApp { + id: "alpha".to_string(), + enabled: true, + callable: true, + }] + ); + + fixture.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn legacy_app_list_force_refetch_still_works_with_runtime_refactor_enabled() -> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + fixture.set_tools(vec![connector_tool("alpha", "Alpha Tool Name")?]); + let directory_app = AppInfo { + id: "alpha".to_string(), + name: "Alpha Directory".to_string(), + description: Some("Directory metadata v1".to_string()), + logo_url: None, + logo_url_dark: None, + icon_assets: None, + icon_dark_assets: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }; + fixture.set_directory_apps(vec![directory_app.clone()]); + let codex_home = configured_codex_home(fixture.base_url())?; + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let installed = send_installed_request(&mut app_server, /*reload*/ true).await?; + assert_eq!( + installed.apps, + vec![codex_app_server_protocol::InstalledApp { + id: "alpha".to_string(), + enabled: true, + callable: true, + }] + ); + assert_eq!(fixture.directory_calls(), 0); + + let initial_request_id = app_server + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + let initial_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(initial_request_id)), + ) + .await??; + let AppsListResponse { + data: initial_data, + next_cursor: initial_next_cursor, + } = to_response(initial_response)?; + assert_eq!(initial_data.len(), 1); + assert_eq!(initial_data[0].id, "alpha"); + assert_eq!( + initial_data[0].description, + Some("Directory metadata v1".to_string()) + ); + assert!(initial_data[0].is_accessible); + assert!(initial_next_cursor.is_none()); + let initial_directory_calls = fixture.directory_calls(); + assert!(initial_directory_calls > 0); + + let mut updated_directory_app = directory_app; + updated_directory_app.description = Some("Directory metadata v2".to_string()); + fixture.set_directory_apps(vec![updated_directory_app]); + let refresh_request_id = app_server + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: true, + }) + .await?; + let refresh_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(refresh_request_id)), + ) + .await??; + let wire_response = serde_json::to_value(&refresh_response)?; + assert!(wire_response["result"]["data"].is_array()); + assert!(wire_response["result"].get("nextCursor").is_some()); + assert!(wire_response["result"].get("apps").is_none()); + let AppsListResponse { + data: refreshed_data, + next_cursor: refreshed_next_cursor, + } = to_response(refresh_response)?; + assert_eq!(refreshed_data.len(), 1); + assert_eq!(refreshed_data[0].id, "alpha"); + assert_eq!( + refreshed_data[0].description, + Some("Directory metadata v2".to_string()) + ); + assert!(refreshed_data[0].is_accessible); + assert!(refreshed_next_cursor.is_none()); + assert!(fixture.directory_calls() > initial_directory_calls); + + fixture.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn installed_apps_workspace_policy_retains_identities_as_disabled() -> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + let codex_home = configured_codex_home(fixture.base_url())?; + { + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + let committed = send_installed_request(&mut app_server, /*reload*/ true).await?; + assert_eq!(committed.apps.len(), 3); + } + + fixture.set_workspace_plugins_enabled(false); + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + let cold_cached = send_installed_request(&mut app_server, /*reload*/ false).await?; + assert_eq!(cold_cached.apps.len(), 3); + assert!( + cold_cached + .apps + .iter() + .all(|app| !app.enabled && !app.callable) + ); + assert_eq!(fixture.workspace_settings_calls(), 1); + + let blocked = send_installed_request(&mut app_server, /*reload*/ true).await?; + assert_eq!(blocked.apps.len(), 3); + assert!(blocked.apps.iter().all(|app| !app.enabled && !app.callable)); + assert_eq!(fixture.list_tools_calls(), 1); + assert_eq!(fixture.workspace_settings_calls(), 2); + fixture.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn installed_apps_global_disable_retains_tool_derived_identities() -> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + let codex_home = configured_codex_home(fixture.base_url())?; + { + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + let committed = send_installed_request(&mut app_server, /*reload*/ true).await?; + assert_eq!(committed.apps.len(), 3); + } + + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write(&config_path, config.replace("apps = true", "apps = false"))?; + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let cached = send_installed_request(&mut app_server, /*reload*/ false).await?; + assert_eq!(cached.apps.len(), 3); + assert!(cached.apps.iter().all(|app| !app.enabled && !app.callable)); + let reload = send_installed_request(&mut app_server, /*reload*/ true).await?; + assert_eq!(reload, cached); + assert_eq!(fixture.list_tools_calls(), 1); + assert_eq!(fixture.workspace_settings_calls(), 1); + + fixture.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn installed_apps_thread_id_uses_effective_thread_config() -> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + let codex_home = configured_codex_home(fixture.base_url())?; + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + send_installed_request(&mut app_server, /*reload*/ true).await?; + + let request_id = app_server + .send_thread_start_request(ThreadStartParams { + config: Some(HashMap::from([( + "apps.alpha.enabled".to_string(), + json!(false), + )])), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(response)?; + + let request_id = app_server + .send_apps_installed_request(AppsInstalledParams { + thread_id: Some(thread.id), + reload: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: AppsInstalledResponse = to_response(response)?; + assert_eq!( + response.apps, + vec![ + codex_app_server_protocol::InstalledApp { + id: "alpha".to_string(), + enabled: false, + callable: false, + }, + codex_app_server_protocol::InstalledApp { + id: "blocked".to_string(), + enabled: true, + callable: false, + }, + codex_app_server_protocol::InstalledApp { + id: "disabled".to_string(), + enabled: false, + callable: false, + }, + ] + ); + + fixture.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn installed_apps_failed_reload_retains_previous_snapshot() -> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + let codex_home = configured_codex_home(fixture.base_url())?; + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let committed = send_installed_request(&mut app_server, /*reload*/ true).await?; + fixture.fail_next_list_tools(); + let request_id = app_server + .send_apps_installed_request(AppsInstalledParams { + thread_id: None, + reload: true, + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.code, -32603); + + let retained = send_installed_request(&mut app_server, /*reload*/ false).await?; + assert_eq!(retained, committed); + assert_eq!(fixture.list_tools_calls(), 2); + fixture.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn installed_apps_concurrent_reloads_are_serialized() -> Result<()> { + let fixture = InstalledAppsFixture::start().await?; + let codex_home = configured_codex_home(fixture.base_url())?; + let mut app_server = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let first_id = app_server + .send_apps_installed_request(AppsInstalledParams { + thread_id: None, + reload: true, + }) + .await?; + let second_id = app_server + .send_apps_installed_request(AppsInstalledParams { + thread_id: None, + reload: true, + }) + .await?; + for request_id in [first_id, second_id] { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: AppsInstalledResponse = to_response(response)?; + assert_eq!(response.apps.len(), 3); + } + + assert_eq!(fixture.list_tools_calls(), 2); + assert_eq!(fixture.max_in_flight(), 1); + fixture.shutdown(); + Ok(()) +} + +async fn send_installed_request( + app_server: &mut TestAppServer, + reload: bool, +) -> Result { + let request_id = app_server + .send_apps_installed_request(AppsInstalledParams { + thread_id: None, + reload, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +fn configured_codex_home(base_url: &str) -> Result { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" +mcp_oauth_credentials_store = "file" + +[features] +apps = true +apps_runtime_state_refactor = true + +[apps.blocked] +default_tools_enabled = false + +[apps.disabled] +enabled = false +"#, + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + Ok(codex_home) +} + +#[derive(Clone)] +struct InstalledAppsMcpServer { + state: Arc, +} + +impl ServerHandler for InstalledAppsMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + let state = Arc::clone(&self.state); + async move { + state.list_tools_calls.fetch_add(1, Ordering::SeqCst); + let in_flight = state.in_flight.fetch_add(1, Ordering::SeqCst) + 1; + state.max_in_flight.fetch_max(in_flight, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(100)).await; + let should_fail = state.fail_next.swap(false, Ordering::SeqCst); + state.in_flight.fetch_sub(1, Ordering::SeqCst); + if should_fail { + return Err(rmcp::ErrorData::internal_error( + "injected tools/list failure", + None, + )); + } + + Ok(ListToolsResult { + meta: None, + next_cursor: None, + tools: state + .tools + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + }) + } + } +} + +struct InstalledAppsServerState { + tools: Mutex>, + directory_apps: Mutex>, + list_tools_calls: AtomicUsize, + directory_calls: AtomicUsize, + workspace_settings_calls: AtomicUsize, + workspace_plugins_enabled: AtomicBool, + in_flight: AtomicUsize, + max_in_flight: AtomicUsize, + fail_next: AtomicBool, +} + +struct InstalledAppsFixture { + base_url: String, + state: Arc, + handle: JoinHandle<()>, +} + +impl InstalledAppsFixture { + async fn start() -> Result { + let state = Arc::new(InstalledAppsServerState { + tools: Mutex::new(vec![ + connector_tool("alpha", "Alpha Tool Name")?, + connector_tool("blocked", "Policy Blocked Tool Name")?, + connector_tool("disabled", "Locally Disabled Tool Name")?, + connector_tool("alpha", "Duplicate Alpha Tool Name")?, + connector_tool("", "Empty Connector ID")?, + Tool::new( + "missing_connector_id", + "Missing connector id", + Arc::new(Default::default()), + ), + ]), + directory_apps: Mutex::new(Vec::new()), + list_tools_calls: AtomicUsize::new(0), + directory_calls: AtomicUsize::new(0), + workspace_settings_calls: AtomicUsize::new(0), + workspace_plugins_enabled: AtomicBool::new(true), + in_flight: AtomicUsize::new(0), + max_in_flight: AtomicUsize::new(0), + fail_next: AtomicBool::new(false), + }); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let mcp_service = StreamableHttpService::new( + { + let state = Arc::clone(&state); + move || { + Ok(InstalledAppsMcpServer { + state: Arc::clone(&state), + }) + } + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let workspace_state = Arc::clone(&state); + let directory_state = Arc::clone(&state); + let workspace_directory_state = Arc::clone(&state); + let router = Router::new() + .route( + "/connectors/directory/list", + get(move || { + let state = Arc::clone(&directory_state); + async move { + state.directory_calls.fetch_add(1, Ordering::SeqCst); + let apps = state + .directory_apps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Json(json!({ "apps": apps, "next_token": null })) + } + }), + ) + .route( + "/connectors/directory/list_workspace", + get(move || { + let state = Arc::clone(&workspace_directory_state); + async move { + state.directory_calls.fetch_add(1, Ordering::SeqCst); + let apps = state + .directory_apps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Json(json!({ "apps": apps, "next_token": null })) + } + }), + ) + .route( + "/accounts/account-123/settings", + get(move || { + let state = Arc::clone(&workspace_state); + async move { + state + .workspace_settings_calls + .fetch_add(1, Ordering::SeqCst); + let enabled = state.workspace_plugins_enabled.load(Ordering::SeqCst); + Json(json!({ + "beta_settings": { "enable_plugins": enabled } + })) + } + }), + ) + .nest_service("/api/codex/ps/mcp", mcp_service); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + Ok(Self { + base_url: format!("http://{address}"), + state, + handle, + }) + } + + fn base_url(&self) -> &str { + &self.base_url + } + + fn list_tools_calls(&self) -> usize { + self.state.list_tools_calls.load(Ordering::SeqCst) + } + + fn set_tools(&self, tools: Vec) { + *self + .state + .tools + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = tools; + } + + fn set_directory_apps(&self, apps: Vec) { + *self + .state + .directory_apps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = apps; + } + + fn directory_calls(&self) -> usize { + self.state.directory_calls.load(Ordering::SeqCst) + } + + fn max_in_flight(&self) -> usize { + self.state.max_in_flight.load(Ordering::SeqCst) + } + + fn workspace_settings_calls(&self) -> usize { + self.state.workspace_settings_calls.load(Ordering::SeqCst) + } + + fn set_workspace_plugins_enabled(&self, enabled: bool) { + self.state + .workspace_plugins_enabled + .store(enabled, Ordering::SeqCst); + } + + fn fail_next_list_tools(&self) { + self.state.fail_next.store(true, Ordering::SeqCst); + } + + fn shutdown(self) { + self.handle.abort(); + } +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 3d55079dc6f9..38873de829d2 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,5 +1,6 @@ mod account; mod analytics; +mod app_installed; mod app_list; mod attestation; mod auto_env; diff --git a/codex-rs/chatgpt/src/workspace_settings.rs b/codex-rs/chatgpt/src/workspace_settings.rs index d5875adcf827..3fbd3c77bfef 100644 --- a/codex-rs/chatgpt/src/workspace_settings.rs +++ b/codex-rs/chatgpt/src/workspace_settings.rs @@ -80,10 +80,70 @@ impl WorkspaceSettingsCache { } } +/// Returns the cached workspace Apps policy without performing network I/O. +/// +/// Accounts that do not require workspace policy lookup are always enabled. A workspace cache +/// miss returns `None`, allowing callers to choose an explicit fail-open or fail-closed policy. +pub fn cached_codex_plugins_enabled_for_workspace( + config: &Config, + auth: Option<&CodexAuth>, + cache: &WorkspaceSettingsCache, +) -> Option { + let Some(auth) = auth else { + return Some(true); + }; + if !auth.is_chatgpt_auth() || !auth.is_workspace_account() { + return Some(true); + } + let Some(account_id) = auth.get_account_id().filter(|id| !id.is_empty()) else { + return Some(true); + }; + cache.get_codex_plugins_enabled(&WorkspaceSettingsCacheKey { + chatgpt_base_url: config.chatgpt_base_url.clone(), + account_id, + }) +} + pub async fn codex_plugins_enabled_for_workspace( config: &Config, auth: Option<&CodexAuth>, cache: Option<&WorkspaceSettingsCache>, +) -> anyhow::Result { + codex_plugins_enabled_for_workspace_inner( + config, + auth, + cache, + WorkspaceSettingsReadMode::UseCache, + ) + .await +} + +/// Refreshes workspace Apps policy without accepting an existing cached value. +pub async fn refresh_codex_plugins_enabled_for_workspace( + config: &Config, + auth: Option<&CodexAuth>, + cache: Option<&WorkspaceSettingsCache>, +) -> anyhow::Result { + codex_plugins_enabled_for_workspace_inner( + config, + auth, + cache, + WorkspaceSettingsReadMode::Refresh, + ) + .await +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum WorkspaceSettingsReadMode { + UseCache, + Refresh, +} + +async fn codex_plugins_enabled_for_workspace_inner( + config: &Config, + auth: Option<&CodexAuth>, + cache: Option<&WorkspaceSettingsCache>, + read_mode: WorkspaceSettingsReadMode, ) -> anyhow::Result { let Some(auth) = auth else { return Ok(true); @@ -104,7 +164,8 @@ pub async fn codex_plugins_enabled_for_workspace( chatgpt_base_url: config.chatgpt_base_url.clone(), account_id: account_id.clone(), }; - if let Some(cache) = cache + if read_mode == WorkspaceSettingsReadMode::UseCache + && let Some(cache) = cache && let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key) { return Ok(enabled); diff --git a/codex-rs/codex-mcp/src/connector_runtime.rs b/codex-rs/codex-mcp/src/connector_runtime.rs index 8e1fc39d830c..22505707ff24 100644 --- a/codex-rs/codex-mcp/src/connector_runtime.rs +++ b/codex-rs/codex-mcp/src/connector_runtime.rs @@ -160,6 +160,23 @@ impl ConnectorRuntimeManager { ) -> Option> { self.context(codex_home, key).current_snapshot() } + + /// Returns the snapshot only when the requested context is already active. + /// + /// Unlike [`Self::current_snapshot`], this never activates a context or reads persistence, so + /// observations such as failure telemetry cannot displace a newer account/workspace context. + pub fn peek_current_snapshot( + &self, + codex_home: PathBuf, + key: ConnectorRuntimeContextKey, + ) -> Option> { + let identity = ConnectorRuntimeIdentity { codex_home, key }; + let active = lock_unpoisoned(&self.inner.active); + active + .as_ref() + .filter(|active| active.identity == identity) + .and_then(|active| active.entry.current_snapshot.load_full()) + } } impl ConnectorRuntimeContext { diff --git a/codex-rs/codex-mcp/src/connector_runtime_tests.rs b/codex-rs/codex-mcp/src/connector_runtime_tests.rs index a033d773a3fa..1723640ea017 100644 --- a/codex-rs/codex-mcp/src/connector_runtime_tests.rs +++ b/codex-rs/codex-mcp/src/connector_runtime_tests.rs @@ -536,6 +536,31 @@ fn activating_new_context_discards_old_context_without_state_bleed() { &snapshot_b, &context_b.current_snapshot().expect("context B snapshot") )); + assert!( + manager + .peek_current_snapshot( + codex_home.path().to_path_buf(), + ConnectorRuntimeContextKey { + account_id: Some("account-a".to_string()), + chatgpt_user_id: Some("user-a".to_string()), + is_workspace_account: false, + }, + ) + .is_none() + ); + assert!(Arc::ptr_eq( + &snapshot_b, + &manager + .peek_current_snapshot( + codex_home.path().to_path_buf(), + ConnectorRuntimeContextKey { + account_id: Some("account-b".to_string()), + chatgpt_user_id: Some("user-b".to_string()), + is_workspace_account: false, + }, + ) + .expect("peek should preserve active context B") + )); } #[test] diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 09ba68bf5fa3..5c06d4c469e2 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -79,6 +79,7 @@ pub use mcp::McpPermissionPromptAutoApproveContext; pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::qualified_mcp_tool_name_prefix; pub use tools::declared_openai_file_input_param_names; +pub use tools::mcp_server_config_allows_tool; pub(crate) mod auth_elicitation; mod catalog; diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index f63ab002df41..3510ff91fdd8 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -113,6 +113,11 @@ impl ToolFilter { } } +/// Returns whether one raw MCP tool name is allowed by a server's effective tool filter. +pub fn mcp_server_config_allows_tool(config: &McpServerConfig, tool_name: &str) -> bool { + ToolFilter::from_config(config).allows(tool_name) +} + /// Returns the model-visible view of a tool while preserving the raw metadata used by execution. /// Declared file parameters are presented as local file paths; execution later uploads those files /// and replaces the paths with the uploaded-file objects expected by the app. diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index bfdb40462280..c01a26829ab0 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::collections::HashSet; use std::sync::Arc; use std::sync::LazyLock; @@ -9,9 +10,11 @@ use async_channel::unbounded; pub use codex_connectors::AppBranding; pub use codex_connectors::AppInfo; pub use codex_connectors::AppMetadata; +use codex_connectors::AppToolPolicyEvaluator; +use codex_connectors::AppToolPolicyInput; use codex_connectors::ConnectorDirectoryCacheContext; use codex_connectors::ConnectorDirectoryCacheKey; -use codex_connectors::app_is_enabled; +use codex_connectors::app_is_enabled as app_is_enabled_from_config; use codex_connectors::apps_config_from_layer_stack; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; @@ -25,6 +28,7 @@ use crate::config::Config; use crate::mcp::McpManager; use crate::plugins::list_tool_suggest_discoverable_plugins; use crate::session::INITIAL_SUBMIT_ID; +use codex_config::McpServerConfig; use codex_config::types::ApprovalsReviewer; use codex_config::types::ToolSuggestDiscoverableType; use codex_core_plugins::PluginsManager; @@ -43,6 +47,8 @@ use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; use codex_mcp::connector_runtime_context_key; use codex_mcp::effective_mcp_servers; +use codex_mcp::mcp_server_config_allows_tool; +use codex_mcp::tool_is_model_visible; use codex_mcp::tool_plugin_provenance; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -359,6 +365,20 @@ pub fn connector_runtime_snapshot( ) } +/// Observes the requested connector runtime only if it remains the active context. +pub fn peek_connector_runtime_snapshot( + config: &Config, + auth: Option<&CodexAuth>, + mcp_manager: &McpManager, +) -> Option> { + mcp_manager + .connector_runtime_manager() + .peek_current_snapshot( + config.codex_home.to_path_buf(), + connector_runtime_context_key(auth), + ) +} + /// Performs one strict, uncached hosted-connector refresh and returns the exact committed snapshot. /// /// The temporary connection manager contains only the host-owned Codex Apps server and skips its @@ -563,19 +583,23 @@ fn collect_accessible_connectors_from_mcp_tools<'a>( } fn accessible_connectors_for_app_list_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { - let non_synthetic_tools = mcp_tools.iter().filter(|tool| { - tool.tool - .meta - .as_deref() - .and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY)) - .and_then(serde_json::Value::as_object) - .and_then(|meta| meta.get("synthetic_link")) - .and_then(serde_json::Value::as_bool) - != Some(true) - }); + let non_synthetic_tools = mcp_tools + .iter() + .filter(|tool| !tool_is_synthetic_link(tool)); collect_accessible_connectors_from_mcp_tools(non_synthetic_tools) } +fn tool_is_synthetic_link(tool: &ToolInfo) -> bool { + tool.tool + .meta + .as_deref() + .and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY)) + .and_then(serde_json::Value::as_object) + .and_then(|meta| meta.get("synthetic_link")) + .and_then(serde_json::Value::as_bool) + == Some(true) +} + pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { let user_apps_config = apps_config_from_layer_stack(&config.config_layer_stack); let requirements_apps_config = config.config_layer_stack.requirements_toml().apps.as_ref(); @@ -588,7 +612,8 @@ pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> && (apps_config.default.is_some() || apps_config.apps.contains_key(connector.id.as_str())) { - connector.is_enabled = app_is_enabled(apps_config, Some(connector.id.as_str())); + connector.is_enabled = + app_is_enabled_from_config(apps_config, Some(connector.id.as_str())); } if requirements_apps_config @@ -602,6 +627,94 @@ pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> connectors } +/// Returns the effective local and managed enablement for one connector. +/// +/// Runtime snapshots intentionally do not persist configuration-derived state. Callers should +/// evaluate this helper against the config associated with each read instead. +pub fn app_is_enabled(config: &Config, connector_id: &str) -> bool { + let user_enabled = apps_config_from_layer_stack(&config.config_layer_stack) + .as_ref() + .map(|apps_config| app_is_enabled_from_config(apps_config, Some(connector_id))) + .unwrap_or(true); + let disabled_by_requirements = config + .config_layer_stack + .requirements_toml() + .apps + .as_ref() + .and_then(|apps| apps.apps.get(connector_id)) + .is_some_and(|app| app.enabled == Some(false)); + + user_enabled && !disabled_by_requirements +} + +/// Returns the installed connector IDs represented by non-synthetic runtime tools. +pub fn installed_app_ids(snapshot: &ConnectorRuntimeSnapshot) -> BTreeSet { + snapshot + .tools() + .iter() + .filter(|tool| !tool_is_synthetic_link(tool)) + .filter_map(|tool| tool.connector_id.as_deref()) + .map(str::trim) + .filter(|connector_id| !connector_id.is_empty()) + .map(str::to_string) + .collect() +} + +/// Returns installed connector IDs that currently have a model-callable runtime tool. +/// +/// The persisted snapshot stays configuration-neutral. This read-time projection resolves the +/// effective host MCP server and applies its tool filter together with app/tool policy and model +/// visibility. +pub async fn callable_app_ids( + config: &Config, + auth: Option<&CodexAuth>, + mcp_manager: &McpManager, + snapshot: &ConnectorRuntimeSnapshot, +) -> BTreeSet { + let mcp_config = mcp_manager.runtime_config(config).await; + let effective_servers = effective_mcp_servers(&mcp_config, auth); + let Some(server_config) = effective_servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .and_then(|server| server.configured_config()) + else { + return BTreeSet::new(); + }; + callable_app_ids_for_tools(config, server_config, snapshot.tools()) +} + +fn callable_app_ids_for_tools( + config: &Config, + server_config: &McpServerConfig, + tools: &[ToolInfo], +) -> BTreeSet { + let app_tool_policy = AppToolPolicyEvaluator::new(&config.config_layer_stack); + tools + .iter() + .filter(|tool| !tool_is_synthetic_link(tool)) + .filter(|tool| tool_is_model_visible(tool)) + .filter(|tool| mcp_server_config_allows_tool(server_config, &tool.tool.name)) + .filter_map(|tool| { + let connector_id = tool.connector_id.as_deref()?.trim(); + if connector_id.is_empty() || !app_is_enabled(config, connector_id) { + return None; + } + let annotations = tool.tool.annotations.as_ref(); + app_tool_policy + .policy(AppToolPolicyInput { + connector_id: Some(connector_id), + tool_name: &tool.tool.name, + tool_title: tool.tool.title.as_deref(), + destructive_hint: annotations + .and_then(|annotations| annotations.destructive_hint), + open_world_hint: annotations + .and_then(|annotations| annotations.open_world_hint), + }) + .enabled + .then(|| connector_id.to_string()) + }) + .collect() +} + pub fn with_app_plugin_sources( mut connectors: Vec, tool_plugin_provenance: &ToolPluginProvenance, diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index fb285390484d..c98253fa8c28 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -20,6 +20,7 @@ use rmcp::model::JsonObject; use rmcp::model::Meta; use rmcp::model::Tool; use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::collections::HashSet; use std::sync::Arc; use tempfile::tempdir; @@ -487,6 +488,85 @@ async fn with_app_enabled_state_preserves_unrelated_disabled_connector() { ); } +#[tokio::test] +async fn app_is_enabled_uses_local_default_and_connector_override() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[apps._default] +enabled = false + +[apps.connector_drive] +enabled = true +"#, + ) + .expect("write config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should build"); + + assert!(!app_is_enabled(&config, "connector_slack")); + assert!(app_is_enabled(&config, "connector_drive")); +} + +#[tokio::test] +async fn app_is_enabled_applies_managed_disable_at_read_time() { + let codex_home = tempdir().expect("tempdir should succeed"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect("config should build"); + let requirements = ConfigRequirementsToml { + apps: Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_drive".to_string(), + AppRequirementToml { + enabled: Some(false), + tools: None, + }, + )]), + }), + ..Default::default() + }; + config.config_layer_stack = + ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) + .expect("requirements stack"); + + assert!(app_is_enabled(&config, "connector_slack")); + assert!(!app_is_enabled(&config, "connector_drive")); +} + +#[tokio::test] +async fn callable_app_ids_apply_effective_mcp_tool_filter() { + let codex_home = tempdir().expect("tempdir should succeed"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should build"); + let mut server_config = codex_mcp::codex_apps_mcp_server_config( + "https://example.invalid", + /*apps_mcp_product_sku*/ None, + ); + server_config.enabled_tools = Some(vec!["alpha_tool".to_string(), "beta_tool".to_string()]); + server_config.disabled_tools = Some(vec!["alpha_tool".to_string()]); + let tools = vec![ + codex_app_tool("alpha_tool", "alpha", Some("Alpha"), &[]), + codex_app_tool("beta_tool", "beta", Some("Beta"), &[]), + codex_app_tool("not_allowlisted", "hidden", Some("Hidden"), &[]), + ]; + + assert_eq!( + callable_app_ids_for_tools(&config, &server_config, &tools), + BTreeSet::from(["beta".to_string()]) + ); +} + #[tokio::test] async fn tool_suggest_connector_ids_include_configured_tool_suggest_discoverables() { let codex_home = tempdir().expect("tempdir should succeed");