mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Add an app-server API for reading app metadata (#33651)
## What changed - Add the experimental `app/read` request for fetching metadata for up to 100 app IDs, with optional display-only tool summaries. - Preserve first-request order while deduplicating IDs, and report unknown or unauthorized apps as partial misses. - Cache metadata by backend and ChatGPT account/workspace identity, refetch metadata-only entries when tools are requested, and preserve cached records when a backend request fails. ## Testing - Cover request serialization, ordering, deduplication, partial misses, caching, tool-summary refetches, backend failures, cache isolation, and the ID limit. GitOrigin-RevId: 55fc3281d99291e76d6c4a581e8d11569d3cb1ac
This commit is contained in:
@@ -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": {
|
||||
|
||||
+129
@@ -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": [
|
||||
{
|
||||
|
||||
+129
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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, };
|
||||
@@ -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<string>,
|
||||
/**
|
||||
* When true, include display-only public tool summaries in the returned metadata.
|
||||
*/
|
||||
includeTools?: boolean, };
|
||||
@@ -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<ConnectorMetadata>, missingAppIds: Array<string>, };
|
||||
@@ -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<AppToolSummary> | null, };
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -131,6 +131,50 @@ fn non_empty_category(category: Option<&str>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub tool_summaries: Option<Vec<AppToolSummary>>,
|
||||
}
|
||||
|
||||
#[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<ConnectorMetadata>,
|
||||
pub missing_app_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AuthManager>,
|
||||
@@ -32,6 +33,61 @@ impl AppsRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn apps_read(
|
||||
&self,
|
||||
params: AppsReadParams,
|
||||
) -> Result<Option<ClientResponsePayload>, 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::<Vec<_>>();
|
||||
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<AccessibleConnectorsStatus, String>),
|
||||
|
||||
@@ -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<i64> {
|
||||
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,
|
||||
|
||||
@@ -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::<LoginAccountResponse>(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<AppsReadResponse> {
|
||||
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<StdMutex<Vec<Value>>>,
|
||||
response: Arc<StdMutex<Value>>,
|
||||
status: Arc<StdMutex<StatusCode>>,
|
||||
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<Value> {
|
||||
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<BatchServerState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Json<Value>, 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))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod account;
|
||||
mod analytics;
|
||||
mod app_list;
|
||||
mod app_read;
|
||||
mod attestation;
|
||||
mod auto_env;
|
||||
mod client_metadata;
|
||||
|
||||
@@ -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<T: DeserializeOwned>(
|
||||
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<TResponse> {
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ConnectorMetadata>,
|
||||
pub missing_app_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<ConnectorMetadataReadResult> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<HashSet<_>>();
|
||||
let fetched = response
|
||||
.apps
|
||||
.into_iter()
|
||||
.map(batch_app_to_metadata)
|
||||
.filter(|metadata| requested_ids.remove(&metadata.id))
|
||||
.collect::<Vec<_>>();
|
||||
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<BatchApp>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
icon_url: Option<String>,
|
||||
#[serde(default)]
|
||||
tools: Option<Vec<BatchAppToolSummary>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BatchAppToolSummary {
|
||||
name: String,
|
||||
title: Option<String>,
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub tool_summaries: Option<Vec<ConnectorToolSummary>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
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<String, ConnectorMetadata> {
|
||||
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<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
is_workspace_account: bool,
|
||||
}
|
||||
|
||||
struct CachedConnectorMetadata {
|
||||
metadata: ConnectorMetadata,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
static CONNECTOR_METADATA_CACHE: LazyLock<
|
||||
StdMutex<HashMap<ConnectorMetadataStoreScope, HashMap<String, CachedConnectorMetadata>>>,
|
||||
> = LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "metadata_store_tests.rs"]
|
||||
mod tests;
|
||||
@@ -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)])
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user