From 03433b693e9f59359ac4cff25ca17c6db15af8b6 Mon Sep 17 00:00:00 2001 From: Steven Lee Date: Tue, 7 Jul 2026 00:44:50 +0000 Subject: [PATCH 1/2] feat: add connector metadata app read --- .../schema/json/ClientRequest.json | 40 ++ .../codex_app_server_protocol.schemas.json | 129 ++++++ .../codex_app_server_protocol.v2.schemas.json | 129 ++++++ .../schema/json/v2/AppsReadParams.json | 18 + .../schema/json/v2/AppsReadResponse.json | 255 ++++++++++++ .../schema/typescript/ClientRequest.ts | 3 +- .../schema/typescript/v2/AppsReadParams.ts | 13 + .../schema/typescript/v2/AppsReadResponse.ts | 9 + .../schema/typescript/v2/ConnectorMetadata.ts | 10 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 24 ++ .../src/protocol/v2/apps.rs | 34 ++ codex-rs/app-server/README.md | 33 ++ codex-rs/app-server/src/app_info.rs | 29 ++ codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/app-server/src/request_processors.rs | 2 + .../src/request_processors/apps_processor.rs | 51 +++ .../tests/common/test_app_server.rs | 7 + .../app-server/tests/suite/v2/app_read.rs | 378 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/chatgpt/src/chatgpt_client.rs | 56 +++ codex-rs/chatgpt/src/connectors.rs | 230 +++++++++++ codex-rs/connectors/src/lib.rs | 3 + codex-rs/connectors/src/metadata_store.rs | 117 ++++++ .../connectors/src/metadata_store_tests.rs | 63 +++ 25 files changed, 1637 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/AppsReadParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppsReadParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppsReadResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.ts create mode 100644 codex-rs/app-server/tests/suite/v2/app_read.rs create mode 100644 codex-rs/connectors/src/metadata_store.rs create mode 100644 codex-rs/connectors/src/metadata_store_tests.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..fdd9fe6a3ebe 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -120,6 +120,22 @@ }, "type": "object" }, + "AppsReadParams": { + "description": "EXPERIMENTAL - read metadata for specific apps/connectors.", + "properties": { + "appIds": { + "description": "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "appIds" + ], + "type": "object" + }, "AskForApproval": { "oneOf": [ { @@ -5680,6 +5696,30 @@ "title": "Plugin/share/deleteRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "app/read" + ], + "title": "App/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppsReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/readRequest", + "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 2ffff2f96f2b..a4dfefd293a9 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 @@ -1069,6 +1069,30 @@ "title": "Plugin/share/deleteRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "app/read" + ], + "title": "App/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/AppsReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -6877,6 +6901,48 @@ "title": "AppsListResponse", "type": "object" }, + "AppsReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - read metadata for specific apps/connectors.", + "properties": { + "appIds": { + "description": "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "appIds" + ], + "title": "AppsReadParams", + "type": "object" + }, + "AppsReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - app/read response.", + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/v2/ConnectorMetadata" + }, + "type": "array" + }, + "missingAppIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "apps", + "missingAppIds" + ], + "title": "AppsReadResponse", + "type": "object" + }, "AskForApproval": { "oneOf": [ { @@ -8653,6 +8719,69 @@ ], "type": "object" }, + "ConnectorMetadata": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppBranding" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ConsumeAccountRateLimitResetCreditOutcome": { "oneOf": [ { 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 dc6e2c969317..ee72dcac8e04 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 @@ -1003,6 +1003,48 @@ "title": "AppsListResponse", "type": "object" }, + "AppsReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - read metadata for specific apps/connectors.", + "properties": { + "appIds": { + "description": "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "appIds" + ], + "title": "AppsReadParams", + "type": "object" + }, + "AppsReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - app/read response.", + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/ConnectorMetadata" + }, + "type": "array" + }, + "missingAppIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "apps", + "missingAppIds" + ], + "title": "AppsReadResponse", + "type": "object" + }, "AskForApproval": { "oneOf": [ { @@ -2099,6 +2141,30 @@ "title": "Plugin/share/deleteRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "app/read" + ], + "title": "App/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppsReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -4893,6 +4959,69 @@ ], "type": "object" }, + "ConnectorMetadata": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "ConsumeAccountRateLimitResetCreditOutcome": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/AppsReadParams.json new file mode 100644 index 000000000000..4e9e8cc7e61e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsReadParams.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - read metadata for specific apps/connectors.", + "properties": { + "appIds": { + "description": "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "appIds" + ], + "title": "AppsReadParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json new file mode 100644 index 000000000000..d162f1b216cb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json @@ -0,0 +1,255 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" + }, + "ConnectorMetadata": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + } + }, + "description": "EXPERIMENTAL - app/read response.", + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/ConnectorMetadata" + }, + "type": "array" + }, + "missingAppIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "apps", + "missingAppIds" + ], + "title": "AppsReadResponse", + "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..e6a2f05f3505 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -8,6 +8,7 @@ import type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; import type { InitializeParams } from "./InitializeParams"; import type { RequestId } from "./RequestId"; import type { AppsListParams } from "./v2/AppsListParams"; +import type { AppsReadParams } from "./v2/AppsReadParams"; import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams"; import type { CommandExecParams } from "./v2/CommandExecParams"; import type { CommandExecResizeParams } from "./v2/CommandExecResizeParams"; @@ -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/read", id: RequestId, params: AppsReadParams, } | { "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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsReadParams.ts new file mode 100644 index 000000000000..63b29f1e367c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsReadParams.ts @@ -0,0 +1,13 @@ +// 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. + +/** + * EXPERIMENTAL - read metadata for specific apps/connectors. + */ +export type AppsReadParams = { +/** + * App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while + * preserving their first-request order. + */ +appIds: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsReadResponse.ts new file mode 100644 index 000000000000..308d7ddebea7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsReadResponse.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 { ConnectorMetadata } from "./ConnectorMetadata"; + +/** + * EXPERIMENTAL - app/read response. + */ +export type AppsReadResponse = { apps: Array, missingAppIds: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.ts new file mode 100644 index 000000000000..9ffe19ca2493 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.ts @@ -0,0 +1,10 @@ +// 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 { AppBranding } from "./AppBranding"; +import type { AppMetadata } from "./AppMetadata"; + +/** + * EXPERIMENTAL - metadata returned by app/read. + */ +export type ConnectorMetadata = { id: string, name: string, description: string | null, distributionChannel: string | null, branding: AppBranding | null, appMetadata: AppMetadata | null, labels: { [key in string]?: string } | null, installUrl: string | null, }; 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..27e60e682577 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -32,6 +32,8 @@ export type { AppsConfig } from "./AppsConfig"; export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; +export type { AppsReadParams } from "./AppsReadParams"; +export type { AppsReadResponse } from "./AppsReadResponse"; export type { AskForApproval } from "./AskForApproval"; export type { AttestationGenerateParams } from "./AttestationGenerateParams"; export type { AttestationGenerateResponse } from "./AttestationGenerateResponse"; @@ -85,6 +87,7 @@ export type { ConfigWarningNotification } from "./ConfigWarningNotification"; export type { ConfigWriteResponse } from "./ConfigWriteResponse"; export type { ConfiguredHookHandler } from "./ConfiguredHookHandler"; export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; +export type { ConnectorMetadata } from "./ConnectorMetadata"; export type { ConsumeAccountRateLimitResetCreditOutcome } from "./ConsumeAccountRateLimitResetCreditOutcome"; export type { ConsumeAccountRateLimitResetCreditParams } from "./ConsumeAccountRateLimitResetCreditParams"; export type { ConsumeAccountRateLimitResetCreditResponse } from "./ConsumeAccountRateLimitResetCreditResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c4b94d8b3fb3..9dadcc690824 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -729,6 +729,11 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginShareDeleteResponse, }, + AppsRead => "app/read" { + params: v2::AppsReadParams, + serialization: None, + response: v2::AppsReadResponse, + }, AppsList => "app/list" { params: v2::AppsListParams, serialization: None, @@ -3005,6 +3010,25 @@ mod tests { Ok(()) } + #[test] + fn serialize_read_apps() -> Result<()> { + let request = ClientRequest::AppsRead { + request_id: RequestId::Integer(9), + params: v2::AppsReadParams { + app_ids: vec!["app-a".to_string(), "app-b".to_string()], + }, + }; + assert_eq!( + json!({ + "method": "app/read", + "id": 9, + "params": { "appIds": ["app-a", "app-b"] } + }), + serde_json::to_value(&request)?, + ); + 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..ad53384451d7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/apps.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs @@ -131,6 +131,40 @@ fn non_empty_category(category: Option<&str>) -> Option { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - read metadata for specific apps/connectors. +pub struct AppsReadParams { + /// App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while + /// preserving their first-request order. + pub app_ids: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - metadata returned by app/read. +pub struct ConnectorMetadata { + pub id: String, + pub name: String, + pub description: Option, + pub distribution_channel: Option, + pub branding: Option, + pub app_metadata: Option, + pub labels: Option>, + pub install_url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app/read response. +pub struct AppsReadResponse { + pub apps: Vec, + pub missing_app_ids: 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 8300c70d94ca..d7a658b2f477 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1856,6 +1856,39 @@ The server also emits `app/list/updated` notifications whenever either source (a } ``` +Use `app/read` when a client already has app ids and only needs metadata. The request accepts at +most 100 `appIds`; repeated ids are deduplicated while preserving first-request order. Both `apps` +and `missingAppIds` follow that order. Unknown or unauthorized ids are returned as partial misses +instead of failing the whole request. + +```json +{ "method": "app/read", "id": 51, "params": { + "appIds": ["demo-app", "missing-app"] +} } +{ "id": 51, "result": { + "apps": [ + { + "id": "demo-app", + "name": "Demo App", + "description": "Example connector for documentation.", + "distributionChannel": null, + "branding": null, + "appMetadata": null, + "labels": null, + "installUrl": "https://chatgpt.com/apps/demo-app/demo-app" + } + ], + "missingAppIds": ["missing-app"] +} } +``` + +`app/read` reads fresh metadata records from a cache partitioned by backend URL and ChatGPT +account/workspace identity, then makes at most one `POST /ps/connectors/batch` for missing or +expired ids with `include_actions=false` and `include_model_descriptions=false`. Backend or +transport failures return an RPC error without replacing existing cache records. Its metadata +shape intentionally excludes runtime state, MCP tools, actions, model descriptions, and icon +fields. + Connected apps may override the thread's approval reviewer in `config.toml`. Use `apps._default.approvals_reviewer` to set the reviewer for all apps, and a per-app value to override that default. When both are omitted, the app inherits diff --git a/codex-rs/app-server/src/app_info.rs b/codex-rs/app-server/src/app_info.rs index 19dcad827416..a2db06d417f7 100644 --- a/codex-rs/app-server/src/app_info.rs +++ b/codex-rs/app-server/src/app_info.rs @@ -3,11 +3,13 @@ use codex_app_server_protocol::AppInfo as ApiAppInfo; use codex_app_server_protocol::AppMetadata as ApiAppMetadata; use codex_app_server_protocol::AppReview as ApiAppReview; use codex_app_server_protocol::AppScreenshot as ApiAppScreenshot; +use codex_app_server_protocol::ConnectorMetadata as ApiConnectorMetadata; use codex_connectors::AppBranding; use codex_connectors::AppInfo; use codex_connectors::AppMetadata; use codex_connectors::AppReview; use codex_connectors::AppScreenshot; +use codex_connectors::ConnectorMetadata; /// Converts connector-domain app metadata owned by `codex-connectors` into the app-server wire /// type owned by `codex-app-server-protocol`. @@ -52,6 +54,33 @@ pub(crate) fn app_info_to_api(app: AppInfo) -> ApiAppInfo { } } +/// Converts metadata-only connector data into the app-server wire type. +/// +/// Keeping this separate from app_info_to_api makes it impossible for app/read to accidentally +/// grow runtime state or icon fields from the broader app/list shape. +pub(crate) fn connector_metadata_to_api(metadata: ConnectorMetadata) -> ApiConnectorMetadata { + let ConnectorMetadata { + id, + name, + description, + distribution_channel, + branding, + app_metadata, + labels, + install_url, + } = metadata; + ApiConnectorMetadata { + id, + name, + description, + distribution_channel, + branding: branding.map(app_branding_to_api), + app_metadata: app_metadata.map(app_metadata_to_api), + labels, + install_url, + } +} + fn app_branding_to_api(branding: AppBranding) -> ApiAppBranding { let AppBranding { category, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6ae73784ff66..138d5ffe37d0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1290,6 +1290,7 @@ impl MessageProcessor { ClientRequest::PluginShareDelete { params, .. } => { self.plugin_processor.plugin_share_delete(params).await } + ClientRequest::AppsRead { params, .. } => self.apps_processor.apps_read(params).await, ClientRequest::AppsList { params, .. } => { self.apps_processor.apps_list(&request_id, params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f03351468faa..b0899a816643 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -35,6 +35,8 @@ use codex_app_server_protocol::AppTemplateSummary; use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; +use codex_app_server_protocol::AppsReadParams; +use codex_app_server_protocol::AppsReadResponse; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::CancelLoginAccountParams; 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 67d768fa57b0..b7fa185fc5d9 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 crate::app_info::connector_metadata_to_api; pub(crate) struct AppsRequestProcessor { auth_manager: Arc, @@ -32,6 +33,55 @@ impl AppsRequestProcessor { } } + pub(crate) async fn apps_read( + &self, + params: AppsReadParams, + ) -> Result, JSONRPCErrorError> { + let AppsReadParams { app_ids } = params; + if app_ids.len() > APP_READ_MAX_IDS { + return Err(invalid_params(format!( + "app/read accepts at most {APP_READ_MAX_IDS} appIds" + ))); + } + + let mut seen_app_ids = HashSet::new(); + let app_ids = app_ids + .into_iter() + .filter(|app_id| seen_app_ids.insert(app_id.clone())) + .collect::>(); + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let auth = self.auth_manager.auth().await; + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) + || !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(Some( + AppsReadResponse { + apps: Vec::new(), + missing_app_ids: app_ids, + } + .into(), + )); + } + + let connectors::ConnectorMetadataReadResult { + apps, + missing_app_ids, + } = connectors::read_connector_metadata(&config, &app_ids) + .await + .map_err(|err| internal_error(format!("failed to read app metadata: {err}")))?; + Ok(Some( + AppsReadResponse { + apps: apps.into_iter().map(connector_metadata_to_api).collect(), + missing_app_ids, + } + .into(), + )) + } + pub(crate) async fn apps_list( &self, request_id: &ConnectionRequestId, @@ -363,6 +413,7 @@ impl AppsRequestProcessor { } const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); +const APP_READ_MAX_IDS: usize = 100; enum AppListLoadResult { Accessible(Result), 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..ff6cf80470fc 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -14,6 +14,7 @@ use tokio::process::ChildStdout; use anyhow::Context; use anyhow::ensure; use codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::AppsReadParams; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; @@ -880,6 +881,12 @@ impl TestAppServer { self.send_request("app/list", params).await } + /// Send an `app/read` JSON-RPC request. + pub async fn send_apps_read_request(&mut self, params: AppsReadParams) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("app/read", 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_read.rs b/codex-rs/app-server/tests/suite/v2/app_read.rs new file mode 100644 index 000000000000..389c2a3abf8e --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/app_read.rs @@ -0,0 +1,378 @@ +use std::path::Path; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +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::extract::State; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::header::AUTHORIZATION; +use axum::routing::post; +use codex_app_server_protocol::AppBranding; +use codex_app_server_protocol::AppMetadata; +use codex_app_server_protocol::AppReview; +use codex_app_server_protocol::AppScreenshot; +use codex_app_server_protocol::AppsReadParams; +use codex_app_server_protocol::AppsReadResponse; +use codex_app_server_protocol::ConnectorMetadata; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn app_read_deduplicates_orders_partial_misses_and_reuses_cached_metadata() -> Result<()> { + let state = BatchServerState::new(json!({ + "connectors": [ + connector_response("alpha", "Alpha", Some("https://chatgpt.com/apps/alpha/alpha")), + connector_response("beta", "Beta", Some("https://chatgpt.com/apps/beta/beta")), + ] + })); + let (server_url, server_handle) = start_batch_server(state.clone()).await?; + let codex_home = TempDir::new()?; + write_apps_config(codex_home.path(), &server_url)?; + write_auth(codex_home.path())?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let response = read_apps( + &mut mcp, + vec!["beta", "missing", "alpha", "beta", "forbidden"], + ) + .await?; + assert_eq!( + response, + AppsReadResponse { + apps: vec![ + metadata("beta", "Beta", Some("https://chatgpt.com/apps/beta/beta")), + metadata( + "alpha", + "Alpha", + Some("https://chatgpt.com/apps/alpha/alpha") + ), + ], + missing_app_ids: vec!["missing".to_string(), "forbidden".to_string()], + } + ); + assert_eq!( + state.requests(), + vec![json!({ + "connector_ids": ["beta", "missing", "alpha", "forbidden"], + "include_actions": false, + "include_model_descriptions": false, + })] + ); + + let cached_response = read_apps(&mut mcp, vec!["alpha", "beta"]).await?; + assert_eq!( + cached_response, + AppsReadResponse { + apps: vec![ + metadata( + "alpha", + "Alpha", + Some("https://chatgpt.com/apps/alpha/alpha") + ), + metadata("beta", "Beta", Some("https://chatgpt.com/apps/beta/beta")), + ], + missing_app_ids: Vec::new(), + } + ); + assert_eq!(state.requests().len(), 1); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn app_read_backend_failure_preserves_fresh_cached_records() -> Result<()> { + let state = BatchServerState::new(json!({ + "connectors": [connector_response("cached", "Cached", None)] + })); + let (server_url, server_handle) = start_batch_server(state.clone()).await?; + let codex_home = TempDir::new()?; + write_apps_config(codex_home.path(), &server_url)?; + write_auth(codex_home.path())?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + assert_eq!( + read_apps(&mut mcp, vec!["cached"]).await?, + AppsReadResponse { + apps: vec![metadata("cached", "Cached", None)], + missing_app_ids: Vec::new(), + } + ); + state.set_status(StatusCode::INTERNAL_SERVER_ERROR); + + let request_id = mcp + .send_apps_read_request(AppsReadParams { + app_ids: vec!["cached".to_string(), "uncached".to_string()], + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert!( + error.error.message.contains("failed to read app metadata"), + "unexpected error: {error:?}" + ); + + assert_eq!( + read_apps(&mut mcp, vec!["cached"]).await?, + AppsReadResponse { + apps: vec![metadata("cached", "Cached", None)], + missing_app_ids: Vec::new(), + } + ); + assert_eq!(state.requests().len(), 2); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn app_read_rejects_more_than_one_hundred_input_ids() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_read_request(AppsReadParams { + app_ids: (0..101).map(|index| format!("app-{index}")).collect(), + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.message, "app/read accepts at most 100 appIds"); + Ok(()) +} + +async fn read_apps(mcp: &mut TestAppServer, app_ids: Vec<&str>) -> Result { + let request_id = mcp + .send_apps_read_request(AppsReadParams { + app_ids: app_ids.into_iter().map(str::to_string).collect(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +fn metadata(id: &str, name: &str, install_url: Option<&str>) -> ConnectorMetadata { + ConnectorMetadata { + id: id.to_string(), + name: name.to_string(), + description: Some(format!("{name} description")), + distribution_channel: Some("ECOSYSTEM_DIRECTORY".to_string()), + branding: Some(AppBranding { + category: Some("PRODUCTIVITY".to_string()), + developer: Some("Test Developer".to_string()), + website: Some("https://example.com".to_string()), + privacy_policy: Some("https://example.com/privacy".to_string()), + terms_of_service: Some("https://example.com/terms".to_string()), + is_discoverable_app: true, + }), + app_metadata: Some(AppMetadata { + review: Some(AppReview { + status: "RELEASED".to_string(), + }), + categories: Some(vec!["PRODUCTIVITY".to_string()]), + sub_categories: Some(vec!["CALENDAR".to_string()]), + seo_description: Some("Search description".to_string()), + screenshots: Some(vec![AppScreenshot { + url: Some("https://example.com/screenshot.png".to_string()), + file_id: Some("file-1".to_string()), + user_prompt: "Use this app".to_string(), + }]), + developer: Some("Test Developer".to_string()), + version: Some("1.0.0".to_string()), + version_id: Some("version-1".to_string()), + version_notes: Some("Initial release".to_string()), + first_party_type: Some("test".to_string()), + first_party_requires_install: Some(true), + show_in_composer_when_unlinked: None, + }), + labels: None, + install_url: install_url.map(str::to_string), + } +} + +fn connector_response(id: &str, name: &str, install_url: Option<&str>) -> Value { + let mut response = json!({ + "id": id, + "name": name, + "description": format!("{name} description"), + "distribution_channel": "ECOSYSTEM_DIRECTORY", + "branding": { + "category": "PRODUCTIVITY", + "developer": "Test Developer", + "website": "https://example.com", + "privacy_policy": "https://example.com/privacy", + "terms_of_service": "https://example.com/terms", + "is_discoverable_app": true, + }, + "app_metadata": { + "review": { "status": "RELEASED" }, + "categories": ["PRODUCTIVITY"], + "sub_categories": ["CALENDAR"], + "seo_description": "Search description", + "screenshots": [{ + "url": "https://example.com/screenshot.png", + "cdn_url": "must-not-escape", + "file_id": "file-1", + "user_prompt": "Use this app", + }], + "developer": "Test Developer", + "version": "1.0.0", + "version_id": "version-1", + "version_notes": "Initial release", + "first_party_type": "test", + "first_party_requires_install": true, + "subtitle": "must-not-escape", + "mcp_server_instructions": "must-not-escape", + }, + "labels": null, + "actions": [{ "name": "must_not_escape_metadata_boundary" }], + "model_description": "must not escape metadata boundary", + "icon_assets": { "256_square": "must-not-escape" }, + }); + if let Some(install_url) = install_url { + response["install_url"] = json!(install_url); + } + response +} + +fn write_apps_config(codex_home: &Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +connectors = true +"# + ), + ) +} + +fn write_auth(codex_home: &Path) -> Result<()> { + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("plus"), + AuthCredentialsStoreMode::File, + ) +} + +#[derive(Clone)] +struct BatchServerState { + requests: Arc>>, + response: Arc>, + status: Arc>, +} + +impl BatchServerState { + fn new(response: Value) -> Self { + Self { + requests: Arc::new(StdMutex::new(Vec::new())), + response: Arc::new(StdMutex::new(response)), + status: Arc::new(StdMutex::new(StatusCode::OK)), + } + } + + fn requests(&self) -> Vec { + self.requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn set_status(&self, status: StatusCode) { + *self + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = status; + } +} + +async fn start_batch_server(state: BatchServerState) -> Result<(String, JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let router = Router::new() + .route("/ps/connectors/batch", post(batch_connectors)) + .with_state(state); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + Ok((format!("http://{addr}"), handle)) +} + +async fn batch_connectors( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, StatusCode> { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "Bearer chatgpt-token"); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "account-123"); + if !bearer_ok || !account_ok { + return Err(StatusCode::UNAUTHORIZED); + } + + state + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(body); + let status = *state + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if status != StatusCode::OK { + return Err(status); + } + Ok(Json( + state + .response + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + )) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 3d55079dc6f9..add802c0d4d0 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,6 +1,7 @@ mod account; mod analytics; mod app_list; +mod app_read; mod attestation; mod auto_env; mod client_metadata; diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 372f62e6966d..4e8af3417984 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -1,8 +1,10 @@ use codex_core::config::Config; use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_login::default_client::create_client; use anyhow::Context; +use serde::Serialize; use serde::de::DeserializeOwned; use std::time::Duration; @@ -70,3 +72,57 @@ pub(crate) async fn chatgpt_get_request_with_timeout( anyhow::bail!("Request failed with status {status}: {body}") } } + +/// Make a POST request to the ChatGPT backend API. +pub(crate) async fn chatgpt_post_request_with_timeout< + TRequest: Serialize + ?Sized, + TResponse: DeserializeOwned, +>( + config: &Config, + auth: &CodexAuth, + path: &str, + body: &TRequest, + timeout: Option, +) -> anyhow::Result { + let chatgpt_base_url = &config.chatgpt_base_url; + anyhow::ensure!( + auth.uses_codex_backend(), + "ChatGPT backend requests require Codex backend auth" + ); + anyhow::ensure!( + auth.get_account_id().is_some(), + "ChatGPT account ID not available, please re-run codex login" + ); + + let client = create_client(); + let url = format!( + "{}/{}", + chatgpt_base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ); + + let mut request = client + .post(&url) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()) + .header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU) + .header("Content-Type", "application/json") + .json(body); + + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + + let response = request.send().await.context("Failed to send request")?; + + if response.status().is_success() { + let result: TResponse = response + .json() + .await + .context("Failed to parse JSON response")?; + Ok(result) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Request failed with status {status}: {body}") + } +} diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index bc84e5126dc7..279fde2921bb 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -3,10 +3,17 @@ use std::collections::HashSet; use std::time::Duration; use crate::chatgpt_client::chatgpt_get_request_with_timeout; +use crate::chatgpt_client::chatgpt_post_request_with_timeout; +use codex_connectors::AppBranding; use codex_connectors::AppInfo; +use codex_connectors::AppMetadata; +use codex_connectors::AppReview; +use codex_connectors::AppScreenshot; use codex_connectors::ConnectorDirectoryCacheContext; use codex_connectors::ConnectorDirectoryCacheKey; +use codex_connectors::ConnectorMetadata; +use codex_connectors::ConnectorMetadataStore; use codex_connectors::DirectoryListResponse; use codex_connectors::merge::merge_connectors; use codex_connectors::merge::merge_plugin_connectors; @@ -21,8 +28,11 @@ pub use codex_core::connectors::with_app_enabled_state; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; +use serde::Deserialize; +use serde::Serialize; const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); +const CONNECTOR_METADATA_TIMEOUT: Duration = Duration::from_secs(60); async fn apps_enabled(config: &Config) -> bool { let auth_manager = @@ -116,6 +126,226 @@ pub async fn list_all_connectors_with_options( )) } +pub struct ConnectorMetadataReadResult { + pub apps: Vec, + pub missing_app_ids: Vec, +} + +/// Reads metadata without loading connector tools or runtime state. +/// +/// The store is created before awaiting the backend request, so a response that arrives after an +/// account or backend change can only commit to the scope under which it was requested. +pub async fn read_connector_metadata( + config: &Config, + app_ids: &[String], +) -> anyhow::Result { + let auth = connector_auth(config).await?; + let store = ConnectorMetadataStore::new( + config.chatgpt_base_url.clone(), + auth.get_account_id(), + auth.get_chatgpt_user_id(), + auth.is_workspace_account(), + ); + let mut metadata_by_id = store.fresh_records(app_ids); + let missing_ids = app_ids + .iter() + .filter(|app_id| !metadata_by_id.contains_key(app_id.as_str())) + .cloned() + .collect::>(); + + if !missing_ids.is_empty() { + let response: GetConnectorsResponse = chatgpt_post_request_with_timeout( + config, + &auth, + "/ps/connectors/batch", + &GetConnectorsRequest { + connector_ids: &missing_ids, + include_actions: false, + include_model_descriptions: false, + }, + Some(CONNECTOR_METADATA_TIMEOUT), + ) + .await?; + let mut requested_ids = missing_ids.iter().cloned().collect::>(); + let fetched = response + .connectors + .into_iter() + .map(batch_connector_to_metadata) + .filter(|metadata| requested_ids.remove(&metadata.id)) + .collect::>(); + store.commit(&fetched); + metadata_by_id.extend( + fetched + .into_iter() + .map(|metadata| (metadata.id.clone(), metadata)), + ); + } + + let mut apps = Vec::new(); + let mut missing_app_ids = Vec::new(); + for app_id in app_ids { + if let Some(metadata) = metadata_by_id.remove(app_id) { + apps.push(metadata); + } else { + missing_app_ids.push(app_id.clone()); + } + } + + Ok(ConnectorMetadataReadResult { + apps, + missing_app_ids, + }) +} + +#[derive(Serialize)] +struct GetConnectorsRequest<'a> { + connector_ids: &'a [String], + include_actions: bool, + include_model_descriptions: bool, +} + +#[derive(Deserialize)] +struct GetConnectorsResponse { + connectors: Vec, +} + +/// The explicit metadata-only projection of plugin-service's broader Connector response. +/// +/// Serde ignores all other backend fields, including actions, model descriptions, runtime state, +/// and icons. +#[derive(Deserialize)] +struct BatchConnector { + id: String, + name: String, + description: Option, + distribution_channel: Option, + branding: Option, + app_metadata: Option, + labels: Option>, + install_url: Option, +} + +fn batch_connector_to_metadata(connector: BatchConnector) -> ConnectorMetadata { + let BatchConnector { + id, + name, + description, + distribution_channel, + branding, + app_metadata, + labels, + install_url, + } = connector; + ConnectorMetadata { + id, + name, + description, + distribution_channel, + branding: branding.map(batch_app_branding_to_app_branding), + app_metadata: app_metadata.map(batch_app_metadata_to_app_metadata), + labels, + install_url, + } +} + +#[derive(Deserialize)] +struct BatchAppBranding { + category: Option, + developer: Option, + website: Option, + privacy_policy: Option, + terms_of_service: Option, + #[serde(default)] + is_discoverable_app: bool, +} + +fn batch_app_branding_to_app_branding(branding: BatchAppBranding) -> AppBranding { + let BatchAppBranding { + category, + developer, + website, + privacy_policy, + terms_of_service, + is_discoverable_app, + } = branding; + AppBranding { + category, + developer, + website, + privacy_policy, + terms_of_service, + is_discoverable_app, + } +} + +#[derive(Deserialize)] +struct BatchAppMetadata { + review: Option, + categories: Option>, + sub_categories: Option>, + seo_description: Option, + screenshots: Option>, + developer: Option, + version: Option, + version_id: Option, + version_notes: Option, + first_party_type: Option, + first_party_requires_install: Option, +} + +fn batch_app_metadata_to_app_metadata(metadata: BatchAppMetadata) -> AppMetadata { + let BatchAppMetadata { + review, + categories, + sub_categories, + seo_description, + screenshots, + developer, + version, + version_id, + version_notes, + first_party_type, + first_party_requires_install, + } = metadata; + AppMetadata { + review: review.map(|review| AppReview { + status: review.status, + }), + categories, + sub_categories, + seo_description, + screenshots: screenshots.map(|screenshots| { + screenshots + .into_iter() + .map(|screenshot| AppScreenshot { + url: screenshot.url, + file_id: screenshot.file_id, + user_prompt: screenshot.user_prompt, + }) + .collect() + }), + developer, + version, + version_id, + version_notes, + first_party_type, + first_party_requires_install, + show_in_composer_when_unlinked: None, + } +} + +#[derive(Deserialize)] +struct BatchAppReview { + status: String, +} + +#[derive(Deserialize)] +struct BatchAppScreenshot { + url: Option, + file_id: Option, + user_prompt: String, +} + fn connector_directory_cache_context( config: &Config, auth: &CodexAuth, diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs index f77936e88fab..22abb3750a48 100644 --- a/codex-rs/connectors/src/lib.rs +++ b/codex-rs/connectors/src/lib.rs @@ -15,6 +15,7 @@ mod directory_cache; pub mod filter; pub mod merge; pub mod metadata; +mod metadata_store; mod plugin_config; mod snapshot; @@ -29,6 +30,8 @@ pub use app_tool_policy::AppToolPolicyInput; pub use app_tool_policy::app_is_enabled; pub use app_tool_policy::apps_config_from_layer_stack; pub use directory_cache::ConnectorDirectoryCacheContext; +pub use metadata_store::ConnectorMetadata; +pub use metadata_store::ConnectorMetadataStore; pub use plugin_config::parse_plugin_app_config; pub use plugin_config::parse_plugin_app_config_value; pub use snapshot::ConnectorSnapshot; diff --git a/codex-rs/connectors/src/metadata_store.rs b/codex-rs/connectors/src/metadata_store.rs new file mode 100644 index 000000000000..47f7ecbecefa --- /dev/null +++ b/codex-rs/connectors/src/metadata_store.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; +use std::sync::LazyLock; +use std::sync::Mutex as StdMutex; +use std::time::Instant; + +use crate::AppBranding; +use crate::AppMetadata; +use crate::CONNECTORS_CACHE_TTL; + +/// Metadata returned by the connector batch-read API. +/// +/// This intentionally excludes connector runtime state, tools, actions, model descriptions, and +/// icon fields. Consumers that need those concepts must use their owning APIs instead of growing +/// this cache boundary. +#[derive(Debug, Clone, PartialEq)] +pub struct ConnectorMetadata { + pub id: String, + pub name: String, + pub description: Option, + pub distribution_channel: Option, + pub branding: Option, + pub app_metadata: Option, + pub labels: Option>, + pub install_url: Option, +} + +/// A view of the process-wide metadata cache bound to one backend and auth identity. +/// +/// The active ChatGPT account id represents the selected personal account or workspace, while the +/// ChatGPT user id identifies the account principal. Keeping both plus workspace classification +/// matches the existing connector-directory cache partition. +pub struct ConnectorMetadataStore { + scope: ConnectorMetadataStoreScope, +} + +impl ConnectorMetadataStore { + pub fn new( + backend_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, + ) -> Self { + Self { + scope: ConnectorMetadataStoreScope { + backend_base_url, + account_id, + chatgpt_user_id, + is_workspace_account, + }, + } + } + + /// Returns only unexpired records for the requested ids. + /// + /// Expired entries are deliberately left in place so a failed refresh cannot mutate prior + /// cache state. + pub fn fresh_records(&self, ids: &[String]) -> HashMap { + let cache = CONNECTOR_METADATA_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(records) = cache.get(&self.scope) else { + return HashMap::new(); + }; + let now = Instant::now(); + ids.iter() + .filter_map(|id| { + records + .get(id) + .filter(|record| now < record.expires_at) + .map(|record| (id.clone(), record.metadata.clone())) + }) + .collect() + } + + /// Commits successfully fetched records to this store's captured scope. + pub fn commit(&self, records: &[ConnectorMetadata]) { + if records.is_empty() { + return; + } + + let expires_at = Instant::now() + CONNECTORS_CACHE_TTL; + let mut cache = CONNECTOR_METADATA_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let scoped_records = cache.entry(self.scope.clone()).or_default(); + for metadata in records { + scoped_records.insert( + metadata.id.clone(), + CachedConnectorMetadata { + metadata: metadata.clone(), + expires_at, + }, + ); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ConnectorMetadataStoreScope { + backend_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +struct CachedConnectorMetadata { + metadata: ConnectorMetadata, + expires_at: Instant, +} + +static CONNECTOR_METADATA_CACHE: LazyLock< + StdMutex>>, +> = LazyLock::new(|| StdMutex::new(HashMap::new())); + +#[cfg(test)] +#[path = "metadata_store_tests.rs"] +mod tests; diff --git a/codex-rs/connectors/src/metadata_store_tests.rs b/codex-rs/connectors/src/metadata_store_tests.rs new file mode 100644 index 000000000000..9d441ab8a91d --- /dev/null +++ b/codex-rs/connectors/src/metadata_store_tests.rs @@ -0,0 +1,63 @@ +use pretty_assertions::assert_eq; + +use super::ConnectorMetadata; +use super::ConnectorMetadataStore; + +fn metadata(id: &str) -> ConnectorMetadata { + ConnectorMetadata { + id: id.to_string(), + name: format!("{id} name"), + description: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + } +} + +#[test] +fn records_are_isolated_by_backend_account_user_and_workspace_scope() { + let requested_scope = ConnectorMetadataStore::new( + "https://backend-a.example".to_string(), + Some("account-a".to_string()), + Some("user-a".to_string()), + true, + ); + let other_backend = ConnectorMetadataStore::new( + "https://backend-b.example".to_string(), + Some("account-a".to_string()), + Some("user-a".to_string()), + true, + ); + let other_account = ConnectorMetadataStore::new( + "https://backend-a.example".to_string(), + Some("account-b".to_string()), + Some("user-a".to_string()), + true, + ); + let other_user = ConnectorMetadataStore::new( + "https://backend-a.example".to_string(), + Some("account-a".to_string()), + Some("user-b".to_string()), + true, + ); + let personal_account = ConnectorMetadataStore::new( + "https://backend-a.example".to_string(), + Some("account-a".to_string()), + Some("user-a".to_string()), + false, + ); + let ids = vec!["scoped-app".to_string()]; + + requested_scope.commit(&[metadata("scoped-app")]); + + assert_eq!( + requested_scope.fresh_records(&ids), + std::collections::HashMap::from([("scoped-app".to_string(), metadata("scoped-app"))]) + ); + assert_eq!(other_backend.fresh_records(&ids), Default::default()); + assert_eq!(other_account.fresh_records(&ids), Default::default()); + assert_eq!(other_user.fresh_records(&ids), Default::default()); + assert_eq!(personal_account.fresh_records(&ids), Default::default()); +} From ec21597c50f9a70a11f6158f5c761df6120a112b Mon Sep 17 00:00:00 2001 From: Steven Lee Date: Tue, 7 Jul 2026 03:29:18 +0000 Subject: [PATCH 2/2] codex: fix CI failure on PR #31343 --- codex-rs/app-server/tests/suite/v2/app_read.rs | 6 +++--- codex-rs/connectors/src/metadata_store_tests.rs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/app_read.rs b/codex-rs/app-server/tests/suite/v2/app_read.rs index 389c2a3abf8e..a90f1354a258 100644 --- a/codex-rs/app-server/tests/suite/v2/app_read.rs +++ b/codex-rs/app-server/tests/suite/v2/app_read.rs @@ -104,7 +104,7 @@ async fn app_read_deduplicates_orders_partial_misses_and_reuses_cached_metadata( #[tokio::test] async fn app_read_backend_failure_preserves_fresh_cached_records() -> Result<()> { let state = BatchServerState::new(json!({ - "connectors": [connector_response("cached", "Cached", None)] + "connectors": [connector_response("cached", "Cached", /*install_url*/ None)] })); let (server_url, server_handle) = start_batch_server(state.clone()).await?; let codex_home = TempDir::new()?; @@ -116,7 +116,7 @@ async fn app_read_backend_failure_preserves_fresh_cached_records() -> Result<()> assert_eq!( read_apps(&mut mcp, vec!["cached"]).await?, AppsReadResponse { - apps: vec![metadata("cached", "Cached", None)], + apps: vec![metadata("cached", "Cached", /*install_url*/ None)], missing_app_ids: Vec::new(), } ); @@ -140,7 +140,7 @@ async fn app_read_backend_failure_preserves_fresh_cached_records() -> Result<()> assert_eq!( read_apps(&mut mcp, vec!["cached"]).await?, AppsReadResponse { - apps: vec![metadata("cached", "Cached", None)], + apps: vec![metadata("cached", "Cached", /*install_url*/ None)], missing_app_ids: Vec::new(), } ); diff --git a/codex-rs/connectors/src/metadata_store_tests.rs b/codex-rs/connectors/src/metadata_store_tests.rs index 9d441ab8a91d..8278644c5d1e 100644 --- a/codex-rs/connectors/src/metadata_store_tests.rs +++ b/codex-rs/connectors/src/metadata_store_tests.rs @@ -22,31 +22,31 @@ fn records_are_isolated_by_backend_account_user_and_workspace_scope() { "https://backend-a.example".to_string(), Some("account-a".to_string()), Some("user-a".to_string()), - true, + /*is_workspace_account*/ true, ); let other_backend = ConnectorMetadataStore::new( "https://backend-b.example".to_string(), Some("account-a".to_string()), Some("user-a".to_string()), - true, + /*is_workspace_account*/ true, ); let other_account = ConnectorMetadataStore::new( "https://backend-a.example".to_string(), Some("account-b".to_string()), Some("user-a".to_string()), - true, + /*is_workspace_account*/ true, ); let other_user = ConnectorMetadataStore::new( "https://backend-a.example".to_string(), Some("account-a".to_string()), Some("user-b".to_string()), - true, + /*is_workspace_account*/ true, ); let personal_account = ConnectorMetadataStore::new( "https://backend-a.example".to_string(), Some("account-a".to_string()), Some("user-a".to_string()), - false, + /*is_workspace_account*/ false, ); let ids = vec!["scoped-app".to_string()];