diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index b79aefc68c2a..d5e250d31331 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -120,6 +120,26 @@ }, "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" + }, + "includeTools": { + "description": "When true, include display-only public tool summaries in the returned metadata.", + "type": "boolean" + } + }, + "required": [ + "appIds" + ], + "type": "object" + }, "AskForApproval": { "oneOf": [ { @@ -5765,6 +5785,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 24ef81e0dc2d..2f2dde5afd3d 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": { @@ -6801,6 +6825,28 @@ }, "type": "object" }, + "AppToolSummary": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "name" + ], + "type": "object" + }, "AppToolsConfig": { "type": "object" }, @@ -6925,6 +6971,52 @@ "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" + }, + "includeTools": { + "description": "When true, include display-only public tool summaries in the returned metadata.", + "type": "boolean" + } + }, + "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": [ { @@ -8708,6 +8800,43 @@ ], "type": "object" }, + "ConnectorMetadata": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "iconUrl": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "toolSummaries": { + "items": { + "$ref": "#/definitions/v2/AppToolSummary" + }, + "type": [ + "array", + "null" + ] + } + }, + "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 02ebffd26ff9..93e9ea5edc5f 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 @@ -880,6 +880,28 @@ }, "type": "object" }, + "AppToolSummary": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "name" + ], + "type": "object" + }, "AppToolsConfig": { "type": "object" }, @@ -1004,6 +1026,52 @@ "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" + }, + "includeTools": { + "description": "When true, include display-only public tool summaries in the returned metadata.", + "type": "boolean" + } + }, + "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": [ { @@ -2107,6 +2175,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": { @@ -4901,6 +4993,43 @@ ], "type": "object" }, + "ConnectorMetadata": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "iconUrl": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "toolSummaries": { + "items": { + "$ref": "#/definitions/AppToolSummary" + }, + "type": [ + "array", + "null" + ] + } + }, + "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..95450d852d88 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsReadParams.json @@ -0,0 +1,22 @@ +{ + "$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" + }, + "includeTools": { + "description": "When true, include display-only public tool summaries in the returned metadata.", + "type": "boolean" + } + }, + "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..8ab4ca7747eb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsReadResponse.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AppToolSummary": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "name" + ], + "type": "object" + }, + "ConnectorMetadata": { + "description": "EXPERIMENTAL - metadata returned by app/read.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "iconUrl": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "toolSummaries": { + "items": { + "$ref": "#/definitions/AppToolSummary" + }, + "type": [ + "array", + "null" + ] + } + }, + "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/AppToolSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolSummary.ts new file mode 100644 index 000000000000..15677b0cce24 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolSummary.ts @@ -0,0 +1,8 @@ +// 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 - metadata returned by app/read. + */ +export type AppToolSummary = { name: string, title: string | null, description: string, }; 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..4d58f86bcdbf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsReadParams.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. + +/** + * 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, +/** + * When true, include display-only public tool summaries in the returned metadata. + */ +includeTools?: boolean, }; 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..e62374802a7e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConnectorMetadata.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 { AppToolSummary } from "./AppToolSummary"; + +/** + * EXPERIMENTAL - metadata returned by app/read. + */ +export type ConnectorMetadata = { id: string, name: string, description: string | null, iconUrl: string | null, toolSummaries: Array | 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 e5dd528f3875..0078744a181a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -26,12 +26,15 @@ export type { AppSummary } from "./AppSummary"; export type { AppTemplateSummary } from "./AppTemplateSummary"; export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; export type { AppToolApproval } from "./AppToolApproval"; +export type { AppToolSummary } from "./AppToolSummary"; export type { AppToolsConfig } from "./AppToolsConfig"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; 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 +88,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 1602af1d3294..6199e779bfd5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -735,6 +735,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, @@ -3103,6 +3108,26 @@ 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()], + include_tools: true, + }, + }; + assert_eq!( + json!({ + "method": "app/read", + "id": 9, + "params": { "appIds": ["app-a", "app-b"], "includeTools": true } + }), + 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..6e9b3cfe8068 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,50 @@ 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, + /// When true, include display-only public tool summaries in the returned metadata. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_tools: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - metadata returned by app/read. +pub struct AppToolSummary { + pub name: String, + pub title: Option, + pub description: String, +} + +#[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 icon_url: Option, + pub tool_summaries: 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 3acb1f2e8558..38527a786826 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1875,6 +1875,44 @@ The server also emits `app/list/updated` notifications when newly loaded accessi } ``` +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"], + "includeTools": true +} } +{ "id": 51, "result": { + "apps": [ + { + "id": "demo-app", + "name": "Demo App", + "description": "Example app for documentation.", + "iconUrl": "https://files.openai.com/content?id=demo-app", + "toolSummaries": [ + { + "name": "search", + "title": "Search", + "description": "Search the 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/apps/batch` for missing or +expired ids. `includeTools` defaults to false and is forwarded as `include_tools`; a fresh +metadata-only cache entry is refetched when tool summaries are requested. Backend or transport +failures return an RPC error without replacing existing cache records. Its metadata shape can +include display-only public tool summaries and intentionally excludes runtime state, MCP tool +state, full actions, and model descriptions. + 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..c86b4946637b 100644 --- a/codex-rs/app-server/src/app_info.rs +++ b/codex-rs/app-server/src/app_info.rs @@ -3,11 +3,15 @@ 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::AppToolSummary as ApiAppToolSummary; +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; +use codex_connectors::ConnectorToolSummary; /// Converts connector-domain app metadata owned by `codex-connectors` into the app-server wire /// type owned by `codex-app-server-protocol`. @@ -52,6 +56,43 @@ 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 from the broader app/list shape. +pub(crate) fn connector_metadata_to_api(metadata: ConnectorMetadata) -> ApiConnectorMetadata { + let ConnectorMetadata { + id, + name, + description, + icon_url, + tool_summaries, + } = metadata; + ApiConnectorMetadata { + id, + name, + description, + icon_url, + tool_summaries: tool_summaries.map(|tools| { + tools + .into_iter() + .map(|tool| { + let ConnectorToolSummary { + name, + title, + description, + } = tool; + ApiAppToolSummary { + name, + title, + description, + } + }) + .collect() + }), + } +} + 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 4d1e7b1cedc6..ba9906d678eb 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1219,6 +1219,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 0f8e3a0effb3..b681fd0fb243 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -34,6 +34,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 a84e7cc987bd..a713d8f42b39 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,61 @@ impl AppsRequestProcessor { } } + pub(crate) async fn apps_read( + &self, + params: AppsReadParams, + ) -> Result, JSONRPCErrorError> { + let AppsReadParams { + app_ids, + include_tools, + } = 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 auth = auth + .as_ref() + .ok_or_else(|| internal_error("app/read requires ChatGPT auth".to_string()))?; + + let connectors::ConnectorMetadataReadResult { + apps, + missing_app_ids, + } = connectors::read_connector_metadata(&config, auth, &app_ids, include_tools) + .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, @@ -400,6 +456,7 @@ fn record_legacy_apps_installed_duration(started_at: Instant, reload: bool) { ); } } +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 3214c9ef44e1..2e7cf924c59a 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -16,6 +16,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; @@ -775,6 +776,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..a359789d64ce --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/app_read.rs @@ -0,0 +1,541 @@ +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::ChatGptIdTokenClaims; +use app_test_support::TestAppServer; +use app_test_support::encode_id_token; +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::AppToolSummary; +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::LoginAccountResponse; +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 access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("external@example.com") + .plan_type("plus") + .chatgpt_account_id("account-123"), + )?; + let state = BatchServerState::new( + json!({ + "apps": [ + app_response("alpha", "Alpha", Some("https://files.openai.com/content?id=alpha")), + app_response("beta", "Beta", Some("https://files.openai.com/content?id=beta")), + ] + }), + &access_token, + "tpp", + ); + let (server_url, server_handle) = start_batch_server(state.clone()).await?; + let codex_home = TempDir::new()?; + write_apps_config(codex_home.path(), &server_url, Some("tpp"))?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let login_id = mcp + .send_chatgpt_auth_tokens_login_request( + access_token, + "account-123".to_string(), + Some("plus".to_string()), + ) + .await?; + let login_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(login_id)), + ) + .await??; + assert_eq!( + to_response::(login_response)?, + LoginAccountResponse::ChatgptAuthTokens {} + ); + + let response = read_apps( + &mut mcp, + vec!["beta", "missing", "alpha", "beta", "forbidden"], + /*include_tools*/ true, + ) + .await?; + assert_eq!( + response, + AppsReadResponse { + apps: vec![ + metadata( + "beta", + "Beta", + Some("https://files.openai.com/content?id=beta") + ), + metadata( + "alpha", + "Alpha", + Some("https://files.openai.com/content?id=alpha") + ), + ], + missing_app_ids: vec!["missing".to_string(), "forbidden".to_string()], + } + ); + assert_eq!( + state.requests(), + vec![json!({ + "app_ids": ["beta", "missing", "alpha", "forbidden"], + "include_tools": true, + })] + ); + + let cached_response = + read_apps(&mut mcp, vec!["alpha", "beta"], /*include_tools*/ true).await?; + assert_eq!( + cached_response, + AppsReadResponse { + apps: vec![ + metadata( + "alpha", + "Alpha", + Some("https://files.openai.com/content?id=alpha") + ), + metadata( + "beta", + "Beta", + Some("https://files.openai.com/content?id=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_refetches_metadata_only_cache_entries_when_tools_are_requested() -> Result<()> { + let state = BatchServerState::new( + json!({ + "apps": [app_response("cached", "Cached", /*icon_url*/ None)] + }), + "chatgpt-token", + "codex", + ); + let (server_url, server_handle) = start_batch_server(state.clone()).await?; + let codex_home = TempDir::new()?; + write_apps_config( + codex_home.path(), + &server_url, + /*apps_mcp_product_sku*/ None, + )?; + write_auth(codex_home.path())?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + assert_eq!( + read_apps(&mut mcp, vec!["cached"], /*include_tools*/ false).await?, + AppsReadResponse { + apps: vec![metadata_without_tools( + "cached", "Cached", /*icon_url*/ None + )], + missing_app_ids: Vec::new(), + } + ); + assert_eq!( + state.requests(), + vec![json!({ + "app_ids": ["cached"], + "include_tools": false, + })] + ); + + assert_eq!( + read_apps(&mut mcp, vec!["cached"], /*include_tools*/ true).await?, + AppsReadResponse { + apps: vec![metadata("cached", "Cached", /*icon_url*/ None)], + missing_app_ids: Vec::new(), + } + ); + assert_eq!( + state.requests(), + vec![ + json!({ + "app_ids": ["cached"], + "include_tools": false, + }), + json!({ + "app_ids": ["cached"], + "include_tools": true, + }), + ] + ); + + assert_eq!( + read_apps(&mut mcp, vec!["cached"], /*include_tools*/ false).await?, + AppsReadResponse { + apps: vec![metadata_without_tools( + "cached", "Cached", /*icon_url*/ None + )], + missing_app_ids: Vec::new(), + } + ); + assert_eq!( + read_apps(&mut mcp, vec!["cached"], /*include_tools*/ true).await?, + AppsReadResponse { + apps: vec![metadata("cached", "Cached", /*icon_url*/ 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_backend_failure_preserves_fresh_cached_records() -> Result<()> { + let state = BatchServerState::new( + json!({ + "apps": [app_response("cached", "Cached", /*icon_url*/ None)] + }), + "chatgpt-token", + "codex", + ); + let (server_url, server_handle) = start_batch_server(state.clone()).await?; + let codex_home = TempDir::new()?; + write_apps_config( + codex_home.path(), + &server_url, + /*apps_mcp_product_sku*/ None, + )?; + write_auth(codex_home.path())?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .build() + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + assert_eq!( + read_apps(&mut mcp, vec!["cached"], /*include_tools*/ true).await?, + AppsReadResponse { + apps: vec![metadata("cached", "Cached", /*icon_url*/ 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()], + include_tools: true, + }) + .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"], /*include_tools*/ true).await?, + AppsReadResponse { + apps: vec![metadata("cached", "Cached", /*icon_url*/ 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::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .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(), + include_tools: false, + }) + .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>, + include_tools: bool, +) -> Result { + let request_id = mcp + .send_apps_read_request(AppsReadParams { + app_ids: app_ids.into_iter().map(str::to_string).collect(), + include_tools, + }) + .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, icon_url: Option<&str>) -> ConnectorMetadata { + ConnectorMetadata { + id: id.to_string(), + name: name.to_string(), + description: Some(format!("{name} description")), + icon_url: icon_url.map(str::to_string), + tool_summaries: Some(vec![AppToolSummary { + name: format!("{id}_tool"), + title: Some(format!("{name} Tool")), + description: format!("Use {name}"), + }]), + } +} + +fn metadata_without_tools(id: &str, name: &str, icon_url: Option<&str>) -> ConnectorMetadata { + ConnectorMetadata { + tool_summaries: None, + ..metadata(id, name, icon_url) + } +} + +fn app_response(id: &str, name: &str, icon_url: Option<&str>) -> Value { + let mut response = json!({ + "id": id, + "name": name, + "description": format!("{name} description"), + "icon_url": null, + "tools": [{ + "name": format!("{id}_tool"), + "title": format!("{name} Tool"), + "description": format!("Use {name}"), + }], + "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, + "show_in_composer_when_unlinked": 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(icon_url) = icon_url { + response["icon_url"] = json!(icon_url); + } + response +} + +fn write_apps_config( + codex_home: &Path, + base_url: &str, + apps_mcp_product_sku: Option<&str>, +) -> std::io::Result<()> { + let apps_mcp_product_sku = apps_mcp_product_sku + .map(|product_sku| format!("apps_mcp_product_sku = \"{product_sku}\"\n")) + .unwrap_or_default(); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" +{apps_mcp_product_sku} + +[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>, + access_token: String, + expected_product_sku: String, +} + +impl BatchServerState { + fn new(response: Value, access_token: &str, expected_product_sku: &str) -> Self { + Self { + requests: Arc::new(StdMutex::new(Vec::new())), + response: Arc::new(StdMutex::new(response)), + status: Arc::new(StdMutex::new(StatusCode::OK)), + access_token: access_token.to_string(), + expected_product_sku: expected_product_sku.to_string(), + } + } + + 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/apps/batch", post(batch_apps)) + .with_state(state); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + Ok((format!("http://{addr}"), handle)) +} + +async fn batch_apps( + 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 == format!("Bearer {}", state.access_token)); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "account-123"); + // Rejecting a mismatch makes both the configured override and default fallback observable. + let product_sku_ok = headers + .get("oai-product-sku") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_product_sku); + if !bearer_ok || !account_ok || !product_sku_ok { + return Err(StatusCode::UNAUTHORIZED); + } + + let include_tools = body + .get("include_tools") + .and_then(Value::as_bool) + .unwrap_or_default(); + 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); + } + let mut response = state + .response + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if !include_tools && let Some(apps) = response.get_mut("apps").and_then(Value::as_array_mut) { + for app in apps { + app["tools"] = Value::Null; + } + } + Ok(Json(response)) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index ffb1e26c86e3..464b78286c7e 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..b4c614392f98 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,55 @@ 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 with an already-captured auth identity. +/// +/// Callers that bind other state to the auth snapshot should pass that same snapshot here rather +/// than reacquiring auth while the request is in flight. +pub(crate) async fn chatgpt_post_request_with_timeout< + TResponse: DeserializeOwned, + TRequest: Serialize + ?Sized, +>( + config: &Config, + auth: &CodexAuth, + path: String, + body: &TRequest, + timeout: Duration, + product_sku: &str, +) -> anyhow::Result { + 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 url = format!( + "{}/{}", + config.chatgpt_base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ); + let response = create_client() + .post(&url) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()) + .header(OAI_PRODUCT_SKU_HEADER, product_sku) + .header("Content-Type", "application/json") + .timeout(timeout) + .json(body) + .send() + .await + .context("Failed to send request")?; + + if response.status().is_success() { + response + .json() + .await + .context("Failed to parse JSON response") + } 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..25529c66f215 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -3,10 +3,14 @@ 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::AppInfo; use codex_connectors::ConnectorDirectoryCacheContext; use codex_connectors::ConnectorDirectoryCacheKey; +use codex_connectors::ConnectorMetadata; +use codex_connectors::ConnectorMetadataStore; +use codex_connectors::ConnectorToolSummary; use codex_connectors::DirectoryListResponse; use codex_connectors::merge::merge_connectors; use codex_connectors::merge::merge_plugin_connectors; @@ -21,8 +25,12 @@ 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); +const DEFAULT_APPS_PRODUCT_SKU: &str = "codex"; async fn apps_enabled(config: &Config) -> bool { let auth_manager = @@ -116,6 +124,159 @@ pub async fn list_all_connectors_with_options( )) } +pub struct ConnectorMetadataReadResult { + pub apps: Vec, + pub missing_app_ids: Vec, +} + +/// Reads display metadata without loading MCP 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, + auth: &CodexAuth, + app_ids: &[String], + include_tools: bool, +) -> anyhow::Result { + 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 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, include_tools); + 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 product_sku = config + .apps_mcp_product_sku + .as_deref() + .unwrap_or(DEFAULT_APPS_PRODUCT_SKU); + let response: GetAppsResponse = chatgpt_post_request_with_timeout( + config, + auth, + "/ps/apps/batch".to_string(), + &GetAppsRequest { + app_ids: &missing_ids, + include_tools, + }, + CONNECTOR_METADATA_TIMEOUT, + product_sku, + ) + .await?; + let mut requested_ids = missing_ids.iter().cloned().collect::>(); + let fetched = response + .apps + .into_iter() + .map(batch_app_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(mut metadata) = metadata_by_id.remove(app_id) { + if !include_tools { + metadata.tool_summaries = None; + } + apps.push(metadata); + } else { + missing_app_ids.push(app_id.clone()); + } + } + + Ok(ConnectorMetadataReadResult { + apps, + missing_app_ids, + }) +} + +#[derive(Serialize)] +struct GetAppsRequest<'a> { + app_ids: &'a [String], + include_tools: bool, +} + +#[derive(Deserialize)] +struct GetAppsResponse { + apps: Vec, +} + +/// The explicit metadata-only projection of Plugin Service's public app response. +/// +/// Serde ignores all other backend fields, including full actions, model descriptions, and +/// runtime state. +#[derive(Deserialize)] +struct BatchApp { + id: String, + name: String, + description: Option, + icon_url: Option, + #[serde(default)] + tools: Option>, +} + +#[derive(Deserialize)] +struct BatchAppToolSummary { + name: String, + title: Option, + description: String, +} + +fn batch_app_to_metadata(app: BatchApp) -> ConnectorMetadata { + let BatchApp { + id, + name, + description, + icon_url, + tools, + } = app; + ConnectorMetadata { + id, + name, + description, + icon_url, + tool_summaries: tools.map(|tools| { + tools + .into_iter() + .map(|tool| { + let BatchAppToolSummary { + name, + title, + description, + } = tool; + ConnectorToolSummary { + name, + title, + description, + } + }) + .collect() + }), + } +} + 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 b8e0110432e9..2d8c9899c0f3 100644 --- a/codex-rs/connectors/src/lib.rs +++ b/codex-rs/connectors/src/lib.rs @@ -16,6 +16,7 @@ mod directory_cache; pub mod filter; pub mod merge; pub mod metadata; +mod metadata_store; mod plugin_config; mod snapshot; @@ -39,12 +40,17 @@ pub use connector_runtime::ConnectorRuntimeSnapshot; pub use connector_runtime::connector_runtime_cache_path; pub use connector_runtime::connector_runtime_context_key; pub use directory_cache::ConnectorDirectoryCacheContext; +pub use metadata_store::ConnectorMetadata; +pub use metadata_store::ConnectorMetadataStore; +pub use metadata_store::ConnectorToolSummary; pub use plugin_config::parse_plugin_app_config; pub use plugin_config::parse_plugin_app_config_value; pub use snapshot::ConnectorSnapshot; pub use snapshot::PluginConnectorSource; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); +/// TTL for app/read metadata; it starts aligned with the connector directory cache. +pub const CONNECTOR_METADATA_CACHE_TTL: Duration = CONNECTORS_CACHE_TTL; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectorDirectoryCacheKey { diff --git a/codex-rs/connectors/src/metadata_store.rs b/codex-rs/connectors/src/metadata_store.rs new file mode 100644 index 000000000000..354eb7b3ad3b --- /dev/null +++ b/codex-rs/connectors/src/metadata_store.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; +use std::sync::LazyLock; +use std::sync::Mutex as StdMutex; +use std::time::Instant; + +use crate::CONNECTOR_METADATA_CACHE_TTL; + +/// Display-only summary of one app tool returned by the app batch-read API. +#[derive(Debug, Clone, PartialEq)] +pub struct ConnectorToolSummary { + pub name: String, + pub title: Option, + pub description: String, +} + +/// Metadata returned by the app batch-read API. +/// +/// This intentionally excludes connector runtime state, full actions, and model descriptions. +/// Tool summaries contain display text only, and the icon URL is already projected as a public +/// URL by the backend. +#[derive(Debug, Clone, PartialEq)] +pub struct ConnectorMetadata { + pub id: String, + pub name: String, + pub description: Option, + pub icon_url: Option, + pub tool_summaries: 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, requiring tool summaries when asked. + /// + /// Expired entries are deliberately left in place so a failed refresh cannot mutate prior + /// cache state. + pub fn fresh_records( + &self, + ids: &[String], + include_tools: bool, + ) -> 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 + && (!include_tools || record.metadata.tool_summaries.is_some()) + }) + .map(|record| (id.clone(), record.metadata.clone())) + }) + .collect() + } + + /// Commits successfully fetched records without letting a late metadata-only response + /// replace fresh tool summaries. + pub fn commit(&self, records: &[ConnectorMetadata]) { + if records.is_empty() { + return; + } + + let now = Instant::now(); + let expires_at = now + CONNECTOR_METADATA_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 { + if metadata.tool_summaries.is_none() + && scoped_records.get(&metadata.id).is_some_and(|record| { + now < record.expires_at && record.metadata.tool_summaries.is_some() + }) + { + continue; + } + 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..fdc425b7b1cc --- /dev/null +++ b/codex-rs/connectors/src/metadata_store_tests.rs @@ -0,0 +1,144 @@ +use pretty_assertions::assert_eq; + +use super::ConnectorMetadata; +use super::ConnectorMetadataStore; +use super::ConnectorToolSummary; + +fn metadata(id: &str) -> ConnectorMetadata { + ConnectorMetadata { + id: id.to_string(), + name: format!("{id} name"), + description: None, + icon_url: None, + tool_summaries: 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()), + /*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()), + /*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()), + /*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()), + /*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()), + /*is_workspace_account*/ false, + ); + let ids = vec!["scoped-app".to_string()]; + + requested_scope.commit(&[metadata("scoped-app")]); + + assert_eq!( + requested_scope.fresh_records(&ids, /*include_tools*/ false), + std::collections::HashMap::from([("scoped-app".to_string(), metadata("scoped-app"))]) + ); + assert_eq!( + other_backend.fresh_records(&ids, /*include_tools*/ false), + Default::default() + ); + assert_eq!( + other_account.fresh_records(&ids, /*include_tools*/ false), + Default::default() + ); + assert_eq!( + other_user.fresh_records(&ids, /*include_tools*/ false), + Default::default() + ); + assert_eq!( + personal_account.fresh_records(&ids, /*include_tools*/ false), + Default::default() + ); +} + +#[test] +fn tool_inclusive_reads_require_cached_tool_summaries() { + let store = ConnectorMetadataStore::new( + "https://backend-tools.example".to_string(), + Some("account-tools".to_string()), + Some("user-tools".to_string()), + /*is_workspace_account*/ false, + ); + let metadata_only = metadata("metadata-only"); + let mut empty_tools = metadata("empty-tools"); + empty_tools.tool_summaries = Some(Vec::new()); + let mut with_tools = metadata("with-tools"); + with_tools.tool_summaries = Some(vec![ConnectorToolSummary { + name: "search".to_string(), + title: Some("Search".to_string()), + description: "Search the app".to_string(), + }]); + let ids = vec![ + "metadata-only".to_string(), + "empty-tools".to_string(), + "with-tools".to_string(), + ]; + + store.commit(&[ + metadata_only.clone(), + empty_tools.clone(), + with_tools.clone(), + ]); + + assert_eq!( + store.fresh_records(&ids, /*include_tools*/ false), + std::collections::HashMap::from([ + ("metadata-only".to_string(), metadata_only), + ("empty-tools".to_string(), empty_tools.clone()), + ("with-tools".to_string(), with_tools.clone()), + ]) + ); + assert_eq!( + store.fresh_records(&ids, /*include_tools*/ true), + std::collections::HashMap::from([ + ("empty-tools".to_string(), empty_tools), + ("with-tools".to_string(), with_tools), + ]) + ); +} + +#[test] +fn metadata_only_commit_does_not_replace_fresh_tool_summaries() { + let store = ConnectorMetadataStore::new( + "https://backend-tools-race.example".to_string(), + Some("account-tools-race".to_string()), + Some("user-tools-race".to_string()), + /*is_workspace_account*/ false, + ); + let mut with_tools = metadata("with-tools"); + with_tools.tool_summaries = Some(vec![ConnectorToolSummary { + name: "search".to_string(), + title: Some("Search".to_string()), + description: "Search the app".to_string(), + }]); + let ids = vec!["with-tools".to_string()]; + + store.commit(&[with_tools.clone()]); + store.commit(&[metadata("with-tools")]); + + assert_eq!( + store.fresh_records(&ids, /*include_tools*/ true), + std::collections::HashMap::from([("with-tools".to_string(), with_tools)]) + ); +}