mirror of
https://github.com/openai/codex.git
synced 2026-09-28 08:43:01 +08:00
Add explicit gateway OAuth sign-in to app-server (#47207)
## Why Clients with a gateway sign-in UI need to control when browser authorization starts and inspect credential readiness before making authenticated requests. ## What changed - Add `initialize.capabilities.explicitGatewayOauth` to require explicit login while allowing existing credentials to refresh. Preserve automatic authorization for clients that omit the capability, and prevent later connections from undoing explicit opt-in. - Add `account/gatewayOAuth/read`, `account/gatewayOAuth/login`, and `account/gatewayOAuth/cancel`, plus `account/gatewayOAuth/changed` notifications. Send the authorization URL only to the initiating connection and cancel its login on disconnect. - Check gateway authentication before returning cached results from `model/list`, with restart guidance when provider settings have changed. - Update protocol schemas, TypeScript and Python bindings, and document capability probing and the sign-in lifecycle. ## Testing Add tests for passive readiness reads, credential reuse after login, token exchange failures, cancellation and immediate retry, connection ownership, legacy versus explicit login, and model-list authentication checks. GitOrigin-RevId: a26e116a62c9f3db84dc238a40b7f4e816eb1594
This commit is contained in:
@@ -256,6 +256,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
|
||||
@@ -217,6 +217,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
|
||||
@@ -139,6 +139,7 @@ async fn compaction_event_ingests_custom_fact() {
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
|
||||
@@ -585,6 +585,7 @@ pub(super) fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
|
||||
@@ -214,6 +214,7 @@ impl InProcessClientStartArgs {
|
||||
/// Builds initialize params from caller-provided metadata.
|
||||
pub fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: self.experimental_api,
|
||||
request_attestation: false,
|
||||
extensions: None,
|
||||
|
||||
@@ -92,6 +92,7 @@ pub struct RemoteAppServerConnectArgs {
|
||||
impl RemoteAppServerConnectArgs {
|
||||
pub(crate) fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: self.experimental_api,
|
||||
request_attestation: false,
|
||||
extensions: None,
|
||||
|
||||
@@ -1634,6 +1634,10 @@
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"explicitGatewayOauth": {
|
||||
"description": "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"extensions": {
|
||||
"additionalProperties": true,
|
||||
"description": "MCP extension settings declared by the app-server client.",
|
||||
@@ -7504,6 +7508,75 @@
|
||||
"title": "Model/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/read"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/login"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/loginRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/loginRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/cancel"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/cancelRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/cancelRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -1933,6 +1933,43 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthChangedNotification": {
|
||||
"properties": {
|
||||
"authUrl": {
|
||||
"description": "Authorization handoff, sent only to the connection that started login.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/GatewayOAuthStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthStatus": {
|
||||
"enum": [
|
||||
"notReady",
|
||||
"started",
|
||||
"succeeded",
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"GitInfo": {
|
||||
"properties": {
|
||||
"branch": {
|
||||
@@ -7791,6 +7828,26 @@
|
||||
"title": "Account/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/changed"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/GatewayOAuthChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
+182
@@ -1813,6 +1813,75 @@
|
||||
"title": "Model/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/read"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/login"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/loginRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/loginRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/cancel"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/cancelRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/cancelRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -3290,6 +3359,10 @@
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"explicitGatewayOauth": {
|
||||
"description": "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"extensions": {
|
||||
"additionalProperties": true,
|
||||
"description": "MCP extension settings declared by the app-server client.",
|
||||
@@ -5451,6 +5524,26 @@
|
||||
"title": "Account/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/changed"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/GatewayOAuthChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -12452,6 +12545,95 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"GatewayOAuthCancelResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "GatewayOAuthCancelResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"authUrl": {
|
||||
"description": "Authorization handoff, sent only to the connection that started login.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/v2/GatewayOAuthStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"status"
|
||||
],
|
||||
"title": "GatewayOAuthChangedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthLoginResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "GatewayOAuthLoginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthReadResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Current effective gateway policy and credential readiness; never contains credentials.",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerName": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"description": "Whether the selected provider uses gateway OAuth, even when already signed in.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/GatewayOAuthStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Null when the effective provider does not use gateway OAuth."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"providerName",
|
||||
"required"
|
||||
],
|
||||
"title": "GatewayOAuthReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthStatus": {
|
||||
"enum": [
|
||||
"notReady",
|
||||
"started",
|
||||
"succeeded",
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"GetAccountParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
|
||||
+182
@@ -3362,6 +3362,75 @@
|
||||
"title": "Model/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/read"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/login"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/loginRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/loginRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/cancel"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/cancelRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/cancelRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -8443,6 +8512,95 @@
|
||||
"title": "FuzzyFileSearchSessionUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthCancelResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "GatewayOAuthCancelResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"authUrl": {
|
||||
"description": "Authorization handoff, sent only to the connection that started login.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/GatewayOAuthStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"status"
|
||||
],
|
||||
"title": "GatewayOAuthChangedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthLoginResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "GatewayOAuthLoginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthReadResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Current effective gateway policy and credential readiness; never contains credentials.",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerName": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"description": "Whether the selected provider uses gateway OAuth, even when already signed in.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GatewayOAuthStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Null when the effective provider does not use gateway OAuth."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"providerName",
|
||||
"required"
|
||||
],
|
||||
"title": "GatewayOAuthReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GatewayOAuthStatus": {
|
||||
"enum": [
|
||||
"notReady",
|
||||
"started",
|
||||
"succeeded",
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"GetAccountParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -9502,6 +9660,10 @@
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"explicitGatewayOauth": {
|
||||
"description": "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"extensions": {
|
||||
"additionalProperties": true,
|
||||
"description": "MCP extension settings declared by the app-server client.",
|
||||
@@ -16749,6 +16911,26 @@
|
||||
"title": "Account/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/gatewayOAuth/changed"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/GatewayOAuthChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/gatewayOAuth/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"explicitGatewayOauth": {
|
||||
"description": "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"extensions": {
|
||||
"additionalProperties": true,
|
||||
"description": "MCP extension settings declared by the app-server client.",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "GatewayOAuthCancelResponse",
|
||||
"type": "object"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"GatewayOAuthStatus": {
|
||||
"enum": [
|
||||
"notReady",
|
||||
"started",
|
||||
"succeeded",
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"authUrl": {
|
||||
"description": "Authorization handoff, sent only to the connection that started login.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/GatewayOAuthStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"status"
|
||||
],
|
||||
"title": "GatewayOAuthChangedNotification",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "GatewayOAuthLoginResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"GatewayOAuthStatus": {
|
||||
"enum": [
|
||||
"notReady",
|
||||
"started",
|
||||
"succeeded",
|
||||
"failed"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Current effective gateway policy and credential readiness; never contains credentials.",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerName": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"description": "Whether the selected provider uses gateway OAuth, even when already signed in.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GatewayOAuthStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Null when the effective provider does not use gateway OAuth."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"providerName",
|
||||
"required"
|
||||
],
|
||||
"title": "GatewayOAuthReadResponse",
|
||||
"type": "object"
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -7,6 +7,11 @@ import type { JsonValue } from "./serde_json/JsonValue";
|
||||
* Client-declared capabilities negotiated during initialize.
|
||||
*/
|
||||
export type InitializeCapabilities = {
|
||||
/**
|
||||
* Use explicit gateway OAuth login instead of automatic browser authorization.
|
||||
* Applies to this app-server's gateway runtime; later connections cannot undo it.
|
||||
*/
|
||||
explicitGatewayOauth?: boolean,
|
||||
/**
|
||||
* Opt into receiving experimental API methods and fields.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
+5
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type GatewayOAuthCancelResponse = Record<string, never>;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { GatewayOAuthStatus } from "./GatewayOAuthStatus";
|
||||
|
||||
export type GatewayOAuthChangedNotification = {
|
||||
/**
|
||||
* Authorization handoff, sent only to the connection that started login.
|
||||
*/
|
||||
authUrl: string | null, providerId: string, status: GatewayOAuthStatus, error: string | null, };
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type GatewayOAuthLoginResponse = Record<string, never>;
|
||||
+17
@@ -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.
|
||||
import type { GatewayOAuthStatus } from "./GatewayOAuthStatus";
|
||||
|
||||
/**
|
||||
* Current effective gateway policy and credential readiness; never contains credentials.
|
||||
*/
|
||||
export type GatewayOAuthReadResponse = { providerId: string, providerName: string,
|
||||
/**
|
||||
* Whether the selected provider uses gateway OAuth, even when already signed in.
|
||||
*/
|
||||
required: boolean,
|
||||
/**
|
||||
* Null when the effective provider does not use gateway OAuth.
|
||||
*/
|
||||
status: GatewayOAuthStatus | null, error: string | null, };
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type GatewayOAuthStatus = "notReady" | "started" | "succeeded" | "failed";
|
||||
@@ -194,6 +194,11 @@ export type { FsWatchParams } from "./FsWatchParams";
|
||||
export type { FsWatchResponse } from "./FsWatchResponse";
|
||||
export type { FsWriteFileParams } from "./FsWriteFileParams";
|
||||
export type { FsWriteFileResponse } from "./FsWriteFileResponse";
|
||||
export type { GatewayOAuthCancelResponse } from "./GatewayOAuthCancelResponse";
|
||||
export type { GatewayOAuthChangedNotification } from "./GatewayOAuthChangedNotification";
|
||||
export type { GatewayOAuthLoginResponse } from "./GatewayOAuthLoginResponse";
|
||||
export type { GatewayOAuthReadResponse } from "./GatewayOAuthReadResponse";
|
||||
export type { GatewayOAuthStatus } from "./GatewayOAuthStatus";
|
||||
export type { GetAccountParams } from "./GetAccountParams";
|
||||
export type { GetAccountRateLimitsParams } from "./GetAccountRateLimitsParams";
|
||||
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
|
||||
|
||||
@@ -1105,6 +1105,21 @@ client_request_definitions! {
|
||||
serialization: None,
|
||||
response: v2::ModelListResponse,
|
||||
},
|
||||
GatewayOAuthRead => "account/gatewayOAuth/read" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
serialization: None,
|
||||
response: v2::GatewayOAuthReadResponse,
|
||||
},
|
||||
GatewayOAuthLogin => "account/gatewayOAuth/login" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
serialization: None,
|
||||
response: v2::GatewayOAuthLoginResponse,
|
||||
},
|
||||
GatewayOAuthCancel => "account/gatewayOAuth/cancel" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
serialization: None,
|
||||
response: v2::GatewayOAuthCancelResponse,
|
||||
},
|
||||
ModelProviderCapabilitiesRead => "modelProvider/capabilities/read" {
|
||||
params: v2::ModelProviderCapabilitiesReadParams,
|
||||
serialization: None,
|
||||
@@ -1965,6 +1980,7 @@ server_notification_definitions! {
|
||||
#[experimental("mcpServer/event/stream/notification")]
|
||||
McpServerEventStream => "mcpServer/event/stream/notification" (v2::McpServerEventStreamNotification),
|
||||
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
|
||||
GatewayOAuthChanged => "account/gatewayOAuth/changed" (v2::GatewayOAuthChangedNotification),
|
||||
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
|
||||
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
|
||||
RemoteControlStatusChanged => "remoteControl/status/changed" (v2::RemoteControlStatusChangedNotification),
|
||||
@@ -2726,6 +2742,7 @@ mod tests {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
@@ -2813,6 +2830,7 @@ mod tests {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct ClientInfo {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeCapabilities {
|
||||
/// Use explicit gateway OAuth login instead of automatic browser authorization.
|
||||
/// Applies to this app-server's gateway runtime; later connections cannot undo it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub explicit_gateway_oauth: bool,
|
||||
/// Opt into receiving experimental API methods and fields.
|
||||
#[serde(default)]
|
||||
pub experimental_api: bool,
|
||||
|
||||
@@ -590,6 +590,62 @@ pub struct AccountUpdatedNotification {
|
||||
pub plan_type: Option<PlanType>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/", rename_all = "camelCase")]
|
||||
pub enum GatewayOAuthStatus {
|
||||
NotReady,
|
||||
Started,
|
||||
Succeeded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GatewayOAuthChangedNotification {
|
||||
/// Authorization handoff, sent only to the connection that started login.
|
||||
pub auth_url: Option<String>,
|
||||
pub provider_id: String,
|
||||
pub status: GatewayOAuthStatus,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GatewayOAuthChangedNotification {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GatewayOAuthChangedNotification")
|
||||
.field("auth_url", &self.auth_url.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("provider_id", &self.provider_id)
|
||||
.field("status", &self.status)
|
||||
.field("error", &self.error)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Current effective gateway policy and credential readiness; never contains credentials.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GatewayOAuthReadResponse {
|
||||
pub provider_id: String,
|
||||
pub provider_name: String,
|
||||
/// Whether the selected provider uses gateway OAuth, even when already signed in.
|
||||
pub required: bool,
|
||||
/// Null when the effective provider does not use gateway OAuth.
|
||||
pub status: Option<GatewayOAuthStatus>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GatewayOAuthLoginResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GatewayOAuthCancelResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -1752,6 +1752,7 @@ impl CodexClient {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: Some(
|
||||
|
||||
@@ -330,3 +330,65 @@ compatibility but is not emitted. Non-Windows hosts report `notConfigured`.
|
||||
MXC uses the standard `command/exec` streaming and process-control path, including
|
||||
ConPTY when `tty` is enabled. The buffered legacy Windows sandbox restrictions on
|
||||
process control and custom output caps do not apply to MXC.
|
||||
|
||||
### Gateway OAuth sign-in
|
||||
|
||||
Providers configured with `gateway_oauth` require a secondary OAuth credential in
|
||||
addition to their primary authentication. Clients with a gateway sign-in UI set
|
||||
`initialize.capabilities.explicitGatewayOauth: true`, complete initialization, and
|
||||
successfully call `account/gatewayOAuth/read` before sending authenticated requests,
|
||||
including startup `model/list` and inference requests. Repeat this probe on each
|
||||
new connection. A successful `initialize` alone does not confirm support: older
|
||||
servers can ignore the unknown capability and retain automatic browser login.
|
||||
|
||||
Support for `account/gatewayOAuth/read` and `explicitGatewayOauth` is introduced
|
||||
together, so a successful read confirms support even when `required` is `false`
|
||||
or `status` is `notReady`. The returned status determines whether sign-in is needed;
|
||||
it is separate from the capability check. If the probe fails because the method is
|
||||
unsupported, require a server upgrade. Other errors and timeouts also leave
|
||||
authenticated requests blocked until a probe succeeds; do not silently fall back
|
||||
to automatic login.
|
||||
|
||||
With explicit login enabled, app-server refreshes existing credentials, but only the
|
||||
`account/gatewayOAuth/login` RPC starts browser authorization. Requests needing
|
||||
sign-in fail promptly so the client can offer that flow.
|
||||
|
||||
Clients that omit the capability or set it to `false`, including the TUI, retain
|
||||
automatic browser authorization after initialization. Startup credential reads
|
||||
cannot open a browser before initialization. Explicit opt-in is shared by gateway
|
||||
managers using the same home and network configuration within the process and
|
||||
cannot be undone by a later connection that omits the capability.
|
||||
|
||||
- `account/gatewayOAuth/read` returns the current effective `providerId`,
|
||||
`providerName`, `required`, `status`, and `error`. `required` indicates that this
|
||||
provider uses gateway OAuth, including when already signed in. This operation
|
||||
does not refresh tokens or open a browser; `status` is null for other providers.
|
||||
`notReady` means credentials are not ready. `succeeded` means saved credentials
|
||||
are locally usable, not that a gateway request has been verified. Reads observe
|
||||
usable replacement credentials saved by another process sharing the same home.
|
||||
- `account/gatewayOAuth/login` starts authorization and returns `{}` after
|
||||
the credential has been saved. Providers requiring OpenAI authentication need a
|
||||
primary account first. A second login request fails while a login is active.
|
||||
The initiating connection receives a `started` notification with `authUrl`; the
|
||||
client must open that URL in a browser that can reach the server callback port.
|
||||
Other status notifications set `authUrl` to null. Login is rejected if the
|
||||
initiating connection opted out of `account/gatewayOAuth/changed` notifications.
|
||||
- `account/gatewayOAuth/cancel` cancels the calling connection's login and returns
|
||||
`{}` after the active login releases its slot, so the client can immediately
|
||||
start another login. Closing that connection also cancels its login and releases
|
||||
the callback listener. Cancellation makes the pending login request fail.
|
||||
- `account/gatewayOAuth/changed` reports `notReady`, `started`, `succeeded`, or
|
||||
`failed`, with an optional `error`. Notifications apply to the current effective
|
||||
gateway configuration. Clients can read readiness when connecting and after
|
||||
changing configuration. Notifications follow the standard per-connection
|
||||
`optOutNotificationMethods` setting.
|
||||
These payloads never contain credentials.
|
||||
|
||||
Read, login, and cancel take no params. Read and login use the app's current
|
||||
provider, reloading configuration and returning an error if it cannot be loaded.
|
||||
Status notifications include `providerId`. During browser sign-in, inference
|
||||
requests fail promptly and can be retried when sign-in succeeds.
|
||||
|
||||
`model/list` also checks gateway authentication before returning cached models.
|
||||
If authentication fails after the provider configuration changes, it asks the client
|
||||
to restart Codex so the retained catalog and gateway sign-in use the same provider.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Forward OAuth progress for the app's current gateway.
|
||||
|
||||
use crate::config_manager::ConfigManager;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use codex_app_server_protocol::GatewayOAuthChangedNotification;
|
||||
use codex_app_server_protocol::GatewayOAuthStatus;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::GatewayAuthStatus;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
pub(crate) fn status_fields(status: GatewayAuthStatus) -> (GatewayOAuthStatus, Option<String>) {
|
||||
match status {
|
||||
GatewayAuthStatus::NotReady => (GatewayOAuthStatus::NotReady, None),
|
||||
GatewayAuthStatus::Started => (GatewayOAuthStatus::Started, None),
|
||||
GatewayAuthStatus::Succeeded => (GatewayOAuthStatus::Succeeded, None),
|
||||
GatewayAuthStatus::Failed { message } => (GatewayOAuthStatus::Failed, Some(message)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
config_manager: ConfigManager,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
) -> AbortOnDropHandle<()> {
|
||||
let mut events = codex_login::subscribe_gateway_auth_status(&auth_manager.runtime_config());
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
loop {
|
||||
let change = match events.recv().await {
|
||||
Ok(change) => change,
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => break,
|
||||
};
|
||||
let Ok(config) = config_manager
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Some(manager)) = codex_model_provider::create_model_provider(
|
||||
config.model_provider.clone(),
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
)
|
||||
.gateway_auth_manager() else {
|
||||
continue;
|
||||
};
|
||||
if manager.config() != &change.config {
|
||||
continue;
|
||||
}
|
||||
let (status, error) = status_fields(change.status);
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::GatewayOAuthChanged(
|
||||
GatewayOAuthChangedNotification {
|
||||
auth_url: None,
|
||||
provider_id: config.model_provider_id,
|
||||
status,
|
||||
error,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -116,6 +116,7 @@ mod external_auth;
|
||||
mod filters;
|
||||
mod fs_watch;
|
||||
mod fuzzy_file_search;
|
||||
mod gateway_oauth_notifications;
|
||||
mod image_url;
|
||||
pub mod in_process;
|
||||
mod mcp_refresh;
|
||||
|
||||
@@ -290,6 +290,10 @@ impl MessageProcessor {
|
||||
remote_control_handle,
|
||||
plugin_startup_tasks,
|
||||
} = args;
|
||||
// Startup credential reads must not open a browser before initialize selects the policy.
|
||||
let gateway_login_control =
|
||||
codex_login::GatewayLoginControl::for_runtime(&auth_manager.runtime_config());
|
||||
gateway_login_control.require_explicit_login();
|
||||
let thread_state_manager = ThreadStateManager::new();
|
||||
outgoing.watch_user_verification_auth(Arc::clone(&auth_manager));
|
||||
// The thread store is intentionally process-scoped. Config reloads can
|
||||
@@ -450,6 +454,7 @@ impl MessageProcessor {
|
||||
);
|
||||
let git_processor = GitRequestProcessor::new();
|
||||
let initialize_processor = InitializeRequestProcessor::new(
|
||||
gateway_login_control,
|
||||
outgoing.clone(),
|
||||
analytics_events_client.clone(),
|
||||
Arc::clone(&config),
|
||||
@@ -862,6 +867,8 @@ impl MessageProcessor {
|
||||
session_state: &ConnectionSessionState,
|
||||
) {
|
||||
session_state.rpc_gate.close().await;
|
||||
self.account_processor
|
||||
.gateway_connection_closed(connection_id);
|
||||
self.request_serialization_queues.discard_closed().await;
|
||||
self.outgoing
|
||||
.disconnect_user_verification_connection(connection_id)
|
||||
@@ -1730,6 +1737,33 @@ impl MessageProcessor {
|
||||
ClientRequest::BedrockSetup { params, .. } => {
|
||||
self.account_processor.bedrock_setup(params).await
|
||||
}
|
||||
ClientRequest::GatewayOAuthRead { .. } => {
|
||||
Box::pin(self.account_processor.gateway_oauth_read())
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
ClientRequest::GatewayOAuthLogin { .. } => {
|
||||
if session
|
||||
.opted_out_notification_methods()
|
||||
.contains("account/gatewayOAuth/changed")
|
||||
{
|
||||
Err(invalid_request(
|
||||
"Gateway login requires account/gatewayOAuth/changed notifications",
|
||||
))
|
||||
} else {
|
||||
Box::pin(
|
||||
self.account_processor
|
||||
.gateway_oauth_login(connection_id, &session.rpc_gate),
|
||||
)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
}
|
||||
ClientRequest::GatewayOAuthCancel { .. } => {
|
||||
Box::pin(self.account_processor.gateway_oauth_cancel(connection_id))
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
ClientRequest::LogoutAccount { .. } => {
|
||||
self.account_processor
|
||||
.logout_account(request_id.clone())
|
||||
@@ -1849,3 +1883,7 @@ impl MessageProcessor {
|
||||
#[cfg(test)]
|
||||
#[path = "message_processor_tracing_tests.rs"]
|
||||
mod message_processor_tracing_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "message_processor_gateway_oauth_tests.rs"]
|
||||
mod gateway_oauth_tests;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Exercise gateway sign-in and managed-provider checks through JSON-RPC with a mock keyring.
|
||||
|
||||
use super::ConnectionSessionState;
|
||||
use super::MessageProcessor;
|
||||
use super::message_processor_tracing_tests::TEST_CONNECTION_ID;
|
||||
use super::message_processor_tracing_tests::build_test_processor;
|
||||
use super::message_processor_tracing_tests::read_response;
|
||||
use crate::outgoing_message::OutgoingEnvelope;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use crate::transport::AppServerTransport;
|
||||
use anyhow::Result;
|
||||
use codex_app_server_protocol::GatewayOAuthLoginResponse;
|
||||
use codex_app_server_protocol::GatewayOAuthReadResponse;
|
||||
use codex_app_server_protocol::GatewayOAuthStatus;
|
||||
use codex_app_server_protocol::InitializeResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider::test_support::seed_gateway_auth;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_string_contains;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
async fn send_request(
|
||||
processor: &Arc<MessageProcessor>,
|
||||
session: &Arc<ConnectionSessionState>,
|
||||
request: serde_json::Value,
|
||||
) {
|
||||
processor
|
||||
.process_request(
|
||||
TEST_CONNECTION_ID,
|
||||
serde_json::from_value(request).expect("JSON-RPC request"),
|
||||
&AppServerTransport::Stdio,
|
||||
Arc::clone(session),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_oauth_login_updates_existing_requests() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.and(body_string_contains("grant_type=authorization_code"))
|
||||
.and(body_string_contains("code=accepted"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
|
||||
"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 3600
|
||||
})))
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model_provider = "gateway"
|
||||
[model_providers.gateway]
|
||||
name = "OpenAI"
|
||||
requires_openai_auth = true
|
||||
base_url = "{url}/v1"
|
||||
wire_api = "responses"
|
||||
[model_providers.gateway.gateway_oauth]
|
||||
authorization_url = "{url}/authorize"
|
||||
token_url = "{url}/token"
|
||||
client_id = "app-server-login-success"
|
||||
delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
|
||||
"#,
|
||||
url = server.uri()
|
||||
),
|
||||
)?;
|
||||
let config = Arc::new(
|
||||
ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?,
|
||||
);
|
||||
let auth_manager = AuthManager::from_auth_for_testing_with_home(
|
||||
CodexAuth::from_api_key("primary"),
|
||||
home.path().to_path_buf(),
|
||||
);
|
||||
let observer = seed_gateway_auth(
|
||||
&config.model_provider,
|
||||
&auth_manager,
|
||||
json!({
|
||||
"access_token": "old-access", "refresh_token": "old-refresh", "expires_at": null,
|
||||
}),
|
||||
);
|
||||
assert_eq!(observer.resolve_access_token().await?, "old-access");
|
||||
let provider = codex_model_provider::create_model_provider(
|
||||
config.model_provider.clone(),
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
);
|
||||
assert_eq!(
|
||||
provider.api_auth().await?.to_auth_headers()["x-gateway-authorization"],
|
||||
"Bearer old-access"
|
||||
);
|
||||
let (processor, mut outgoing) = build_test_processor(config, auth_manager).await;
|
||||
let session = Arc::new(ConnectionSessionState::new(
|
||||
crate::transport::ConnectionOrigin::Stdio,
|
||||
));
|
||||
send_request(
|
||||
&processor,
|
||||
&session,
|
||||
json!({
|
||||
"id": 1, "method": "initialize", "params": {
|
||||
"clientInfo": {"name": "gateway-test", "version": "1"}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let _: InitializeResponse = read_response(&mut outgoing, /*request_id*/ 1).await;
|
||||
send_request(
|
||||
&processor,
|
||||
&session,
|
||||
json!({"id": 2, "method": "account/gatewayOAuth/login"}),
|
||||
)
|
||||
.await;
|
||||
let authorization_url = timeout(Duration::from_secs(/*secs*/ 10), async {
|
||||
loop {
|
||||
let envelope = outgoing
|
||||
.recv()
|
||||
.await
|
||||
.expect("outgoing channel open during login");
|
||||
if let OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::AppServerNotification(notification),
|
||||
..
|
||||
} = envelope
|
||||
&& let ServerNotification::GatewayOAuthChanged(changed) = notification.notification
|
||||
&& let Some(url) = changed.auth_url
|
||||
{
|
||||
assert_eq!(connection_id, TEST_CONNECTION_ID);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let query = url::Url::parse(&authorization_url)?
|
||||
.query_pairs()
|
||||
.into_owned()
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let mut callback = url::Url::parse(&query["redirect_uri"])?;
|
||||
callback
|
||||
.query_pairs_mut()
|
||||
.append_pair("code", "accepted")
|
||||
.append_pair("state", &query["state"]);
|
||||
codex_login::default_client::create_client_without_request_logging()
|
||||
.get(callback.as_str())
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let response: GatewayOAuthLoginResponse = read_response(&mut outgoing, /*request_id*/ 2).await;
|
||||
assert_eq!(response, GatewayOAuthLoginResponse {});
|
||||
send_request(
|
||||
&processor,
|
||||
&session,
|
||||
json!({"id": 3, "method": "account/gatewayOAuth/read"}),
|
||||
)
|
||||
.await;
|
||||
let response: GatewayOAuthReadResponse = read_response(&mut outgoing, /*request_id*/ 3).await;
|
||||
assert_eq!(
|
||||
response,
|
||||
GatewayOAuthReadResponse {
|
||||
provider_id: "gateway".to_string(),
|
||||
provider_name: "OpenAI".to_string(),
|
||||
required: true,
|
||||
status: Some(GatewayOAuthStatus::Succeeded),
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(observer.resolve_access_token().await?, "new-access");
|
||||
assert_eq!(
|
||||
provider.api_auth().await?.to_auth_headers()["x-gateway-authorization"],
|
||||
"Bearer new-access"
|
||||
);
|
||||
processor.shutdown_threads().await;
|
||||
processor.drain_background_tasks().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_oauth_model_list_checks_requirements_before_refreshing_token() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
|
||||
"access_token": "refreshed", "expires_in": 3600,
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let home = TempDir::new()?;
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config.model_provider_id = "gateway".into();
|
||||
config.model_provider = toml::from_str(&format!(
|
||||
r#"
|
||||
name = "Gateway"
|
||||
base_url = "{url}/v1"
|
||||
[gateway_oauth]
|
||||
authorization_url = "{url}/authorize"
|
||||
token_url = "{url}/token"
|
||||
client_id = "app-server-requirements-test"
|
||||
delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
|
||||
"#,
|
||||
url = server.uri(),
|
||||
))?;
|
||||
let auth_manager = AuthManager::from_auth_for_testing_with_home(
|
||||
CodexAuth::from_api_key("primary"),
|
||||
home.path().to_path_buf(),
|
||||
);
|
||||
let _gateway = seed_gateway_auth(
|
||||
&config.model_provider,
|
||||
&auth_manager,
|
||||
json!({"access_token": "expired", "refresh_token": "old-refresh", "expires_at": 0}),
|
||||
);
|
||||
let (processor, mut outgoing) = build_test_processor(Arc::new(config), auth_manager).await;
|
||||
let session = Arc::new(ConnectionSessionState::new(
|
||||
crate::transport::ConnectionOrigin::Stdio,
|
||||
));
|
||||
send_request(
|
||||
&processor,
|
||||
&session,
|
||||
json!({"id": 1, "method": "initialize", "params": {
|
||||
"clientInfo": {"name": "gateway-test", "version": "1"},
|
||||
"capabilities": {"explicitGatewayOauth": true},
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
let _: InitializeResponse = read_response(&mut outgoing, /*request_id*/ 1).await;
|
||||
std::fs::write(
|
||||
home.path().join("requirements.toml"),
|
||||
"model_provider = 'openai'",
|
||||
)?;
|
||||
send_request(
|
||||
&processor,
|
||||
&session,
|
||||
json!({"id": 2, "method": "model/list", "params": {}}),
|
||||
)
|
||||
.await;
|
||||
let error = timeout(Duration::from_secs(/*secs*/ 10), async {
|
||||
loop {
|
||||
if let OutgoingEnvelope::ToConnection {
|
||||
message: OutgoingMessage::Error(error),
|
||||
..
|
||||
} = outgoing
|
||||
.recv()
|
||||
.await
|
||||
.expect("outgoing channel open during request")
|
||||
{
|
||||
break error.error;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(error, JSONRPCErrorError {
|
||||
code: -32600,
|
||||
message: "failed to load configuration: Your organization's required model provider settings changed. Restart Codex to apply them; this request was not sent".to_string(),
|
||||
data: None,
|
||||
});
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
processor.shutdown_threads().await;
|
||||
processor.drain_background_tasks().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -49,12 +49,13 @@ use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use wiremock::MockServer;
|
||||
|
||||
const TEST_CONNECTION_ID: ConnectionId = ConnectionId(7);
|
||||
pub(super) const TEST_CONNECTION_ID: ConnectionId = ConnectionId(7);
|
||||
|
||||
struct TestTracing {
|
||||
exporter: InMemorySpanExporter,
|
||||
@@ -120,7 +121,12 @@ impl TracingHarness {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let config = Arc::new(build_test_config(codex_home.path(), &server.uri()).await?);
|
||||
let (processor, outgoing_rx) = build_test_processor(config).await;
|
||||
let auth_manager = AuthManager::shared_from_config(
|
||||
config.as_ref(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
)
|
||||
.await?;
|
||||
let (processor, outgoing_rx) = build_test_processor(config, auth_manager).await;
|
||||
let tracing = init_test_tracing();
|
||||
tracing.exporter.reset();
|
||||
tracing::callsite::rebuild_interest_cache();
|
||||
@@ -229,21 +235,20 @@ async fn build_test_config(codex_home: &Path, server_uri: &str) -> Result<Config
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn build_test_processor(
|
||||
pub(super) async fn build_test_processor(
|
||||
config: Arc<Config>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> (
|
||||
Arc<MessageProcessor>,
|
||||
mpsc::Receiver<crate::outgoing_message::OutgoingEnvelope>,
|
||||
) {
|
||||
let (outgoing_tx, outgoing_rx) = mpsc::channel(16);
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false)
|
||||
.await
|
||||
.expect("test auth manager");
|
||||
let config_manager = ConfigManager::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
Vec::new(),
|
||||
LoaderOverrides::default(),
|
||||
LoaderOverrides::with_managed_config_path_for_tests(
|
||||
config.codex_home.join("managed_config.toml").to_path_buf(),
|
||||
),
|
||||
/*strict_config*/ false,
|
||||
CloudConfigBundleLoader::default(),
|
||||
Arg0DispatchPaths::default(),
|
||||
@@ -432,12 +437,20 @@ fn assert_has_internal_descendant_at_min_depth(
|
||||
);
|
||||
}
|
||||
|
||||
async fn read_response<T: serde::de::DeserializeOwned>(
|
||||
pub(super) async fn read_response<T: serde::de::DeserializeOwned>(
|
||||
outgoing_rx: &mut mpsc::Receiver<crate::outgoing_message::OutgoingEnvelope>,
|
||||
request_id: i64,
|
||||
) -> T {
|
||||
read_response_from(outgoing_rx, TEST_CONNECTION_ID, request_id).await
|
||||
}
|
||||
|
||||
pub(super) async fn read_response_from<T: serde::de::DeserializeOwned>(
|
||||
outgoing_rx: &mut mpsc::Receiver<crate::outgoing_message::OutgoingEnvelope>,
|
||||
expected_connection_id: ConnectionId,
|
||||
request_id: i64,
|
||||
) -> T {
|
||||
loop {
|
||||
let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), outgoing_rx.recv())
|
||||
let envelope = tokio::time::timeout(Duration::from_secs(/*secs*/ 30), outgoing_rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for response")
|
||||
.expect("outgoing channel closed");
|
||||
@@ -449,7 +462,7 @@ async fn read_response<T: serde::de::DeserializeOwned>(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if connection_id != TEST_CONNECTION_ID {
|
||||
if connection_id != expected_connection_id {
|
||||
continue;
|
||||
}
|
||||
let crate::outgoing_message::OutgoingMessage::Response(response) = message else {
|
||||
|
||||
@@ -134,7 +134,8 @@ pub(crate) fn without_notification_media(notification: ServerNotification) -> Se
|
||||
| ServerNotification::ThreadRealtimeClosed(_)
|
||||
| ServerNotification::WindowsWorldWritableWarning(_)
|
||||
| ServerNotification::WindowsSandboxSetupCompleted(_)
|
||||
| ServerNotification::AccountLoginCompleted(_) => notification,
|
||||
| ServerNotification::AccountLoginCompleted(_)
|
||||
| ServerNotification::GatewayOAuthChanged(_) => notification,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use codex_login::login_with_bedrock_access_keys;
|
||||
use codex_model_provider::is_supported_amazon_bedrock_region;
|
||||
|
||||
mod bedrock_setup;
|
||||
mod gateway_oauth;
|
||||
mod rate_limit_resets;
|
||||
mod workspace_routing;
|
||||
|
||||
@@ -96,6 +97,9 @@ pub(crate) struct AccountRequestProcessor {
|
||||
workspace_routing: Arc<Mutex<Option<workspace_routing::CachedWorkspaceRouting>>>,
|
||||
workspace_routing_fetches: Arc<Mutex<workspace_routing::WorkspaceRoutingFetches>>,
|
||||
workspace_routing_shutdown: CancellationToken,
|
||||
gateway_login: Arc<std::sync::Mutex<Option<gateway_oauth::ActiveGatewayLogin>>>,
|
||||
gateway_client: Arc<std::sync::Mutex<Option<Arc<codex_login::GatewayAuthManager>>>>,
|
||||
_gateway_notifications: Arc<tokio_util::task::AbortOnDropHandle<()>>,
|
||||
}
|
||||
|
||||
impl AccountRequestProcessor {
|
||||
@@ -106,13 +110,21 @@ impl AccountRequestProcessor {
|
||||
config: Arc<Config>,
|
||||
config_manager: ConfigManager,
|
||||
) -> Arc<Self> {
|
||||
let gateway_notifications = crate::gateway_oauth_notifications::spawn(
|
||||
Arc::clone(&auth_manager),
|
||||
config_manager.clone(),
|
||||
Arc::clone(&outgoing),
|
||||
);
|
||||
let processor = Arc::new(Self {
|
||||
_gateway_notifications: Arc::new(gateway_notifications),
|
||||
auth_manager,
|
||||
thread_manager,
|
||||
outgoing,
|
||||
config,
|
||||
config_manager,
|
||||
active_login: Arc::new(Mutex::new(None)),
|
||||
gateway_login: Arc::new(std::sync::Mutex::new(/*t*/ None)),
|
||||
gateway_client: Arc::new(std::sync::Mutex::new(/*t*/ None)),
|
||||
workspace_routing: Arc::new(Mutex::new(None)),
|
||||
workspace_routing_fetches: Arc::new(Mutex::new(HashMap::new())),
|
||||
workspace_routing_shutdown: CancellationToken::new(),
|
||||
@@ -197,6 +209,7 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
|
||||
pub(crate) async fn cancel_active_login(&self) {
|
||||
self.cancel_gateway_login();
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if let Some(active_login) = guard.take() {
|
||||
drop(active_login);
|
||||
@@ -954,13 +967,7 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
let config = self.load_latest_config().await;
|
||||
|
||||
// Cancel any active login attempt.
|
||||
{
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if let Some(active) = guard.take() {
|
||||
drop(active);
|
||||
}
|
||||
}
|
||||
self.cancel_active_login().await;
|
||||
|
||||
match self.auth_manager.logout_with_revoke().await {
|
||||
Ok(_) => {}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Gateway sign-in RPCs share credentials with inference and bind cancellation to the initiating connection.
|
||||
//! Cancellation is acknowledged only after the active login releases its slot.
|
||||
|
||||
use super::*;
|
||||
use crate::gateway_oauth_notifications::status_fields;
|
||||
use crate::transport::ConnectionId;
|
||||
use codex_app_server_protocol::GatewayOAuthCancelResponse;
|
||||
use codex_app_server_protocol::GatewayOAuthLoginResponse;
|
||||
use codex_app_server_protocol::GatewayOAuthReadResponse;
|
||||
use codex_login::GatewayAuthManager;
|
||||
use std::sync::PoisonError;
|
||||
|
||||
pub(super) struct ActiveGatewayLogin {
|
||||
owner: ConnectionId,
|
||||
cancel: CancellationToken,
|
||||
finished: CancellationToken,
|
||||
}
|
||||
|
||||
struct LoginGuard(Arc<std::sync::Mutex<Option<ActiveGatewayLogin>>>);
|
||||
|
||||
impl Drop for LoginGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(active) = self.0.lock().unwrap_or_else(PoisonError::into_inner).take() {
|
||||
active.finished.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountRequestProcessor {
|
||||
async fn gateway_client(
|
||||
&self,
|
||||
) -> Result<(Config, Option<Arc<GatewayAuthManager>>), JSONRPCErrorError> {
|
||||
let config = self
|
||||
.config_manager
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!("failed to load gateway configuration: {err}"))
|
||||
})?;
|
||||
let manager = codex_model_provider::create_model_provider(
|
||||
config.model_provider.clone(),
|
||||
Some(Arc::clone(&self.auth_manager)),
|
||||
)
|
||||
.gateway_auth_manager()
|
||||
.map_err(|error| internal_error(error.to_string()))?;
|
||||
*self
|
||||
.gateway_client
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner) = manager.clone();
|
||||
Ok((config, manager))
|
||||
}
|
||||
|
||||
pub(crate) async fn gateway_oauth_read(
|
||||
&self,
|
||||
) -> Result<GatewayOAuthReadResponse, JSONRPCErrorError> {
|
||||
let (config, client) = self.gateway_client().await?;
|
||||
let state = if let Some(client) = client {
|
||||
Some(status_fields(
|
||||
client
|
||||
.status()
|
||||
.await
|
||||
.map_err(|err| internal_error(err.to_string()))?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(GatewayOAuthReadResponse {
|
||||
provider_id: config.model_provider_id,
|
||||
provider_name: config.model_provider.name,
|
||||
required: state.is_some(),
|
||||
status: state.as_ref().map(|state| state.0),
|
||||
error: state.and_then(|state| state.1),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn gateway_oauth_login(
|
||||
&self,
|
||||
owner: ConnectionId,
|
||||
connection_gate: &crate::connection_rpc_gate::ConnectionRpcGate,
|
||||
) -> Result<GatewayOAuthLoginResponse, JSONRPCErrorError> {
|
||||
let cancel = CancellationToken::new();
|
||||
let guard = {
|
||||
let mut active = self
|
||||
.gateway_login
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
if connection_gate.is_closed() {
|
||||
return Err(invalid_request("The connection is closed"));
|
||||
}
|
||||
if active.is_some() {
|
||||
return Err(invalid_request("Gateway sign-in is already in progress"));
|
||||
}
|
||||
*active = Some(ActiveGatewayLogin {
|
||||
owner,
|
||||
cancel: cancel.clone(),
|
||||
finished: CancellationToken::new(),
|
||||
});
|
||||
LoginGuard(Arc::clone(&self.gateway_login))
|
||||
};
|
||||
let (config, client) = self.gateway_client().await?;
|
||||
let client = client
|
||||
.ok_or_else(|| invalid_request("The current provider does not use gateway OAuth"))?;
|
||||
if config.model_provider.requires_openai_auth
|
||||
&& config.model_provider.env_key.is_none()
|
||||
&& config.model_provider.experimental_bearer_token.is_none()
|
||||
&& config.model_provider.auth.is_none()
|
||||
&& self.auth_manager.auth_cached().is_none()
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"Sign in to your primary account before signing in to the gateway",
|
||||
));
|
||||
}
|
||||
let (url_tx, url_rx) = tokio::sync::oneshot::channel();
|
||||
let login = client.login_with_browser(cancel.cancelled(), |url| {
|
||||
let _ = url_tx.send(url.to_string());
|
||||
});
|
||||
tokio::pin!(login);
|
||||
let result = tokio::select! {
|
||||
result = &mut login => result,
|
||||
url = url_rx => {
|
||||
if let Ok(url) = url {
|
||||
self.outgoing.send_server_notification_to_connections(&[owner], ServerNotification::GatewayOAuthChanged(codex_app_server_protocol::GatewayOAuthChangedNotification {
|
||||
auth_url: Some(url), provider_id: config.model_provider_id.clone(),
|
||||
status: codex_app_server_protocol::GatewayOAuthStatus::Started, error: None,
|
||||
})).await;
|
||||
}
|
||||
login.await
|
||||
}
|
||||
};
|
||||
result.map_err(|err| internal_error(err.to_string()))?;
|
||||
drop(guard);
|
||||
Ok(GatewayOAuthLoginResponse {})
|
||||
}
|
||||
|
||||
pub(crate) async fn gateway_oauth_cancel(
|
||||
&self,
|
||||
owner: ConnectionId,
|
||||
) -> Result<GatewayOAuthCancelResponse, JSONRPCErrorError> {
|
||||
let finished = {
|
||||
let active = self
|
||||
.gateway_login
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
let Some(active) = active.as_ref() else {
|
||||
return Ok(GatewayOAuthCancelResponse {});
|
||||
};
|
||||
if active.owner != owner {
|
||||
return Err(invalid_request(
|
||||
"Gateway sign-in belongs to another connection",
|
||||
));
|
||||
}
|
||||
active.cancel.cancel();
|
||||
active.finished.clone()
|
||||
};
|
||||
finished.cancelled().await;
|
||||
Ok(GatewayOAuthCancelResponse {})
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_connection_closed(&self, owner: ConnectionId) {
|
||||
let active = self
|
||||
.gateway_login
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(active) = active.as_ref()
|
||||
&& active.owner == owner
|
||||
{
|
||||
active.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cancel_gateway_login(&self) {
|
||||
let active = self
|
||||
.gateway_login
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(active) = active.as_ref() {
|
||||
active.cancel.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,39 @@ impl CatalogRequestProcessor {
|
||||
&self,
|
||||
params: ModelListParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
// Gate the same provider used by the catalog, including when its model cache is warm.
|
||||
// Resolving credentials may refresh them, but explicit host policy prevents browser login.
|
||||
if let Some(gateway) = codex_model_provider::create_model_provider(
|
||||
self.config.model_provider.clone(),
|
||||
Some(self.thread_manager.auth_manager()),
|
||||
)
|
||||
.gateway_auth_manager()
|
||||
.map_err(|error| internal_error(error.to_string()))?
|
||||
{
|
||||
// Refreshing credentials can contact the gateway before the catalog's own check.
|
||||
self.config_manager
|
||||
.check_thread_model_provider(&self.config)
|
||||
.await
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
if gateway.resolve_access_token().await.is_err() {
|
||||
let current = self
|
||||
.config_manager
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
.await
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
// Login RPCs use current config, while the catalog retains its startup provider.
|
||||
if current.model_provider_id != self.config.model_provider_id
|
||||
|| current.model_provider != self.config.model_provider
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"Model provider settings changed. Restart Codex to apply them, then retry fetching the model list",
|
||||
));
|
||||
}
|
||||
return Err(invalid_request(
|
||||
"Gateway sign-in required or unavailable. Complete gateway sign-in and retry fetching the model list",
|
||||
));
|
||||
}
|
||||
}
|
||||
self.list_models(params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
|
||||
@@ -20,6 +20,8 @@ const NON_ORIGINATING_CLIENT_NAMES: &[&str] = &["codex_app_server_daemon", "code
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct InitializeRequestProcessor {
|
||||
gateway_login_control: Arc<codex_login::GatewayLoginControl>,
|
||||
gateway_login_initialized: Arc<std::sync::OnceLock<()>>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
analytics_events_client: AnalyticsEventsClient,
|
||||
config: Arc<Config>,
|
||||
@@ -30,6 +32,7 @@ pub(crate) struct InitializeRequestProcessor {
|
||||
|
||||
impl InitializeRequestProcessor {
|
||||
pub(crate) fn new(
|
||||
gateway_login_control: Arc<codex_login::GatewayLoginControl>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
analytics_events_client: AnalyticsEventsClient,
|
||||
config: Arc<Config>,
|
||||
@@ -38,6 +41,8 @@ impl InitializeRequestProcessor {
|
||||
user_verification: Arc<crate::user_verification::Service>,
|
||||
) -> Self {
|
||||
Self {
|
||||
gateway_login_control,
|
||||
gateway_login_initialized: Arc::new(std::sync::OnceLock::new()),
|
||||
outgoing,
|
||||
analytics_events_client,
|
||||
config,
|
||||
@@ -144,6 +149,19 @@ impl InitializeRequestProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
if capabilities.explicit_gateway_oauth || mutates_global_identity {
|
||||
// Only the first originating client may restore legacy automatic login.
|
||||
// Any explicit opt-in is sticky across subsequent connections.
|
||||
self.gateway_login_initialized.get_or_init(|| {
|
||||
if !capabilities.explicit_gateway_oauth {
|
||||
self.gateway_login_control.allow_automatic_login();
|
||||
}
|
||||
});
|
||||
if capabilities.explicit_gateway_oauth {
|
||||
self.gateway_login_control.require_explicit_login();
|
||||
}
|
||||
}
|
||||
|
||||
if mutates_global_identity {
|
||||
// Only real client initialization may mutate process-global client metadata.
|
||||
if let Err(error) = set_default_originator(originator.clone()) {
|
||||
|
||||
@@ -81,6 +81,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
opt_out_notification_methods: None,
|
||||
|
||||
@@ -40,6 +40,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
@@ -77,6 +78,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
@@ -134,6 +136,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
@@ -176,6 +179,7 @@ async fn settings_update_requires_experimental_api_capability(method: &str) -> R
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
@@ -215,6 +219,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
@@ -275,6 +280,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
@@ -318,6 +324,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
@@ -360,6 +367,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Gateway OAuth RPCs expose login progress, cancellation, and initialization policy.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::GatewayOAuthCancelResponse;
|
||||
use codex_app_server_protocol::GatewayOAuthChangedNotification;
|
||||
use codex_app_server_protocol::GatewayOAuthReadResponse;
|
||||
use codex_app_server_protocol::GatewayOAuthStatus;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_string_contains;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn gateway_config(oauth: &MockServer) -> String {
|
||||
format!(
|
||||
r#"
|
||||
model_provider = "gateway"
|
||||
[features]
|
||||
secret_auth_storage = true
|
||||
[model_providers.gateway]
|
||||
name = "Test gateway"
|
||||
base_url = "{url}/v1"
|
||||
wire_api = "responses"
|
||||
[model_providers.gateway.gateway_oauth]
|
||||
authorization_url = "{url}/authorize"
|
||||
token_url = "{url}/token"
|
||||
client_id = "app-server-explicit-test"
|
||||
delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
|
||||
"#,
|
||||
url = oauth.uri()
|
||||
)
|
||||
}
|
||||
|
||||
async fn gateway_server(home: &std::path::Path) -> Result<TestAppServer> {
|
||||
let mut server = TestAppServer::builder()
|
||||
.with_codex_home(home)
|
||||
.build()
|
||||
.await?;
|
||||
server
|
||||
.initialize_with_capabilities(
|
||||
ClientInfo {
|
||||
name: "gateway-test".to_string(),
|
||||
title: None,
|
||||
version: "1".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: true,
|
||||
experimental_api: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
async fn read_gateway(server: &mut TestAppServer) -> Result<GatewayOAuthReadResponse> {
|
||||
server
|
||||
.request(|id| ClientRequest::GatewayOAuthRead {
|
||||
request_id: id,
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn cancel_gateway(server: &mut TestAppServer) -> Result<GatewayOAuthCancelResponse> {
|
||||
server
|
||||
.request(|id| ClientRequest::GatewayOAuthCancel {
|
||||
request_id: id,
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn authorization_url(server: &mut TestAppServer) -> Result<url::Url> {
|
||||
timeout(TIMEOUT, async {
|
||||
loop {
|
||||
let changed: GatewayOAuthChangedNotification = server
|
||||
.read_notification("account/gatewayOAuth/changed")
|
||||
.await?;
|
||||
if let Some(url) = changed.auth_url {
|
||||
return Ok(url::Url::parse(&url)?);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_list_requests_restart_after_gateway_provider_changes() -> Result<()> {
|
||||
let oauth = MockServer::start().await;
|
||||
let original = gateway_config(&oauth);
|
||||
for replacement in [
|
||||
String::new(), // Switch to the default provider without gateway OAuth.
|
||||
original.replace("/authorize", "/replacement-authorize"),
|
||||
] {
|
||||
let home = TempDir::new()?;
|
||||
let config_path = home.path().join("config.toml");
|
||||
std::fs::write(&config_path, &original)?;
|
||||
let mut server = gateway_server(home.path()).await?;
|
||||
std::fs::write(&config_path, replacement)?;
|
||||
let models = server
|
||||
.send_raw_request("model/list", Some(serde_json::json!({})))
|
||||
.await?;
|
||||
let error = timeout(
|
||||
TIMEOUT,
|
||||
server.read_stream_until_error_message(RequestId::Integer(models)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error, JSONRPCErrorError {
|
||||
code: -32600,
|
||||
message: "Model provider settings changed. Restart Codex to apply them, then retry fetching the model list".to_string(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_exchange_failure_completes_login_and_updates_readiness() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let oauth = MockServer::start().await;
|
||||
let secret = "issuer-query-credential";
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/token"))
|
||||
.and(body_string_contains("grant_type=authorization_code"))
|
||||
.and(body_string_contains("code=accepted"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(/*s*/ 400)
|
||||
.insert_header("x-request-id", secret)
|
||||
.set_body_json(serde_json::json!({
|
||||
"error": "invalid_grant", "error_description": secret,
|
||||
})),
|
||||
)
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&oauth)
|
||||
.await;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
gateway_config(&oauth).replace(
|
||||
"/authorize\"",
|
||||
&format!("/authorize?issuer_secret={secret}\""),
|
||||
),
|
||||
)?;
|
||||
let mut server = gateway_server(home.path()).await?;
|
||||
let login = server
|
||||
.send_raw_request("account/gatewayOAuth/login", /*params*/ None)
|
||||
.await?;
|
||||
let authorization_url = authorization_url(&mut server).await?;
|
||||
let query = authorization_url
|
||||
.query_pairs()
|
||||
.into_owned()
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let mut callback = url::Url::parse(query.get("redirect_uri").context("callback URI")?)?;
|
||||
callback
|
||||
.query_pairs_mut()
|
||||
.append_pair("code", "accepted")
|
||||
.append_pair("state", query.get("state").context("OAuth state")?);
|
||||
codex_login::default_client::create_client_without_request_logging()
|
||||
.get(callback.as_str())
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let error = timeout(
|
||||
TIMEOUT,
|
||||
server.read_stream_until_error_message(RequestId::Integer(login)),
|
||||
)
|
||||
.await??;
|
||||
let message = "Gateway sign-in failed; check the gateway configuration and credential store.";
|
||||
assert_eq!(error.error.message, message);
|
||||
let changed = timeout(
|
||||
TIMEOUT,
|
||||
server.read_stream_until_matching_notification("gateway login failure", |notification| {
|
||||
notification.method == "account/gatewayOAuth/changed"
|
||||
&& notification
|
||||
.params
|
||||
.as_ref()
|
||||
.is_some_and(|params| params["status"] == "failed")
|
||||
}),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
serde_json::from_value::<GatewayOAuthChangedNotification>(
|
||||
changed.params.context("gateway status")?
|
||||
)?,
|
||||
GatewayOAuthChangedNotification {
|
||||
provider_id: "gateway".into(),
|
||||
status: GatewayOAuthStatus::Failed,
|
||||
auth_url: None,
|
||||
error: Some(message.into()),
|
||||
}
|
||||
);
|
||||
let state: GatewayOAuthReadResponse = read_gateway(&mut server).await?;
|
||||
assert_eq!(
|
||||
state,
|
||||
GatewayOAuthReadResponse {
|
||||
provider_id: "gateway".to_string(),
|
||||
provider_name: "Test gateway".to_string(),
|
||||
required: true,
|
||||
status: Some(GatewayOAuthStatus::Failed),
|
||||
error: Some(error.error.message),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_and_cancel_without_gateway_oauth() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let mut server = gateway_server(home.path()).await?;
|
||||
let state: GatewayOAuthReadResponse = read_gateway(&mut server).await?;
|
||||
assert_eq!(
|
||||
(state.required, state.status, state.error),
|
||||
(false, None, None)
|
||||
);
|
||||
let response: GatewayOAuthCancelResponse = cancel_gateway(&mut server).await?;
|
||||
assert_eq!(response, GatewayOAuthCancelResponse {});
|
||||
let login = server
|
||||
.send_raw_request("account/gatewayOAuth/login", /*params*/ None)
|
||||
.await?;
|
||||
let error = timeout(
|
||||
TIMEOUT,
|
||||
server.read_stream_until_error_message(RequestId::Integer(login)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"The current provider does not use gateway OAuth"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn primary_account_is_required_before_gateway_login() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let oauth = MockServer::start().await;
|
||||
let config = gateway_config(&oauth).replace(
|
||||
"wire_api = \"responses\"",
|
||||
"wire_api = \"responses\"\nrequires_openai_auth = true",
|
||||
);
|
||||
std::fs::write(home.path().join("config.toml"), config)?;
|
||||
let mut server = gateway_server(home.path()).await?;
|
||||
let login = server
|
||||
.send_raw_request("account/gatewayOAuth/login", /*params*/ None)
|
||||
.await?;
|
||||
let error = timeout(
|
||||
TIMEOUT,
|
||||
server.read_stream_until_error_message(RequestId::Integer(login)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Sign in to your primary account before signing in to the gateway"
|
||||
);
|
||||
assert!(oauth.received_requests().await.unwrap().is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_is_passive_and_canceled_login_can_be_retried_immediately() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let oauth = MockServer::start().await;
|
||||
let config = gateway_config(&oauth);
|
||||
std::fs::write(home.path().join("config.toml"), &config)?;
|
||||
let mut server = gateway_server(home.path()).await?;
|
||||
let state: GatewayOAuthReadResponse = read_gateway(&mut server).await?;
|
||||
assert_eq!(
|
||||
state,
|
||||
GatewayOAuthReadResponse {
|
||||
provider_id: "gateway".to_string(),
|
||||
provider_name: "Test gateway".to_string(),
|
||||
required: true,
|
||||
status: Some(GatewayOAuthStatus::NotReady),
|
||||
error: None
|
||||
}
|
||||
);
|
||||
assert!(oauth.received_requests().await.unwrap().is_empty());
|
||||
let expected = GatewayOAuthChangedNotification {
|
||||
provider_id: "gateway".to_string(),
|
||||
status: GatewayOAuthStatus::NotReady,
|
||||
auth_url: None,
|
||||
error: None,
|
||||
};
|
||||
// Consume the initial readiness change before checking repeated request failures.
|
||||
let changed: GatewayOAuthChangedNotification = timeout(
|
||||
TIMEOUT,
|
||||
server.read_notification("account/gatewayOAuth/changed"),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(changed, expected);
|
||||
for _ in 0..2 {
|
||||
let models = server
|
||||
.send_raw_request("model/list", Some(serde_json::json!({})))
|
||||
.await?;
|
||||
let error = timeout(
|
||||
TIMEOUT,
|
||||
server.read_stream_until_error_message(RequestId::Integer(models)),
|
||||
)
|
||||
.await??;
|
||||
assert!(error.error.message.contains("Gateway sign-in required"));
|
||||
let changed: GatewayOAuthChangedNotification = timeout(
|
||||
TIMEOUT,
|
||||
server.read_notification("account/gatewayOAuth/changed"),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(changed, expected);
|
||||
}
|
||||
let login = server
|
||||
.send_raw_request("account/gatewayOAuth/login", /*params*/ None)
|
||||
.await?;
|
||||
let url = authorization_url(&mut server).await?;
|
||||
assert!(
|
||||
url.as_str()
|
||||
.starts_with(&format!("{}/authorize?", oauth.uri()))
|
||||
);
|
||||
let state: GatewayOAuthReadResponse = read_gateway(&mut server).await?;
|
||||
assert_eq!(state.status, Some(GatewayOAuthStatus::Started));
|
||||
let duplicate = server
|
||||
.send_raw_request("account/gatewayOAuth/login", /*params*/ None)
|
||||
.await?;
|
||||
let error = timeout(
|
||||
TIMEOUT,
|
||||
server.read_stream_until_error_message(RequestId::Integer(duplicate)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Gateway sign-in is already in progress"
|
||||
);
|
||||
let _: GatewayOAuthCancelResponse = cancel_gateway(&mut server).await?;
|
||||
// Retry on the cancel acknowledgment, without waiting for the old login response.
|
||||
let retry = server
|
||||
.send_raw_request("account/gatewayOAuth/login", /*params*/ None)
|
||||
.await?;
|
||||
authorization_url(&mut server).await?;
|
||||
let _: GatewayOAuthCancelResponse = cancel_gateway(&mut server).await?;
|
||||
for request_id in [login, retry] {
|
||||
let error = timeout(
|
||||
TIMEOUT,
|
||||
server.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error.message, "Gateway sign-in was canceled");
|
||||
}
|
||||
assert!(oauth.received_requests().await.unwrap().is_empty());
|
||||
|
||||
let state: GatewayOAuthReadResponse = read_gateway(&mut server).await?;
|
||||
assert_eq!(
|
||||
(state.status, state.error),
|
||||
(
|
||||
Some(GatewayOAuthStatus::Failed),
|
||||
Some("Gateway sign-in was canceled".to_string())
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_cancels_only_the_owning_connections_login() -> Result<()> {
|
||||
use super::connection_handling_websocket::connect_websocket;
|
||||
use super::connection_handling_websocket::read_error_for_id;
|
||||
use super::connection_handling_websocket::read_notification_for_method;
|
||||
use super::connection_handling_websocket::read_response_for_id;
|
||||
use super::connection_handling_websocket::send_request;
|
||||
use super::connection_handling_websocket::spawn_websocket_server;
|
||||
let home = TempDir::new()?;
|
||||
let oauth = MockServer::start().await;
|
||||
std::fs::write(home.path().join("config.toml"), gateway_config(&oauth))?;
|
||||
let (mut process, address) = spawn_websocket_server(home.path()).await?;
|
||||
let mut owner = connect_websocket(address).await?;
|
||||
let mut observer = connect_websocket(address).await?;
|
||||
send_request(&mut owner, "initialize", /*id*/ 1, Some(serde_json::json!({"clientInfo": {"name": "gateway-owner", "version": "1"}, "capabilities": {"experimentalApi": true, "explicitGatewayOauth": true}}))).await?;
|
||||
read_response_for_id(&mut owner, /*id*/ 1).await?;
|
||||
send_request(&mut observer, "initialize", /*id*/ 1, Some(serde_json::json!({"clientInfo": {"name": "gateway-observer", "version": "1"}, "capabilities": {"experimentalApi": true}}))).await?;
|
||||
read_response_for_id(&mut observer, /*id*/ 1).await?;
|
||||
// A legacy connection cannot undo another connection's explicit opt-in.
|
||||
send_request(
|
||||
&mut observer,
|
||||
"model/list",
|
||||
/*id*/ 0,
|
||||
Some(serde_json::json!({})),
|
||||
)
|
||||
.await?;
|
||||
let error = read_error_for_id(&mut observer, /*id*/ 0).await?;
|
||||
assert!(error.error.message.contains("Gateway sign-in required"));
|
||||
send_request(
|
||||
&mut owner,
|
||||
"account/gatewayOAuth/login",
|
||||
/*id*/ 2,
|
||||
/*params*/ None,
|
||||
)
|
||||
.await?;
|
||||
// Startup can publish readiness before the login request starts authorization.
|
||||
timeout(TIMEOUT, async {
|
||||
loop {
|
||||
let changed =
|
||||
read_notification_for_method(&mut observer, "account/gatewayOAuth/changed").await?;
|
||||
if changed
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params["status"].as_str())
|
||||
== Some("started")
|
||||
{
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
send_request(
|
||||
&mut observer,
|
||||
"account/gatewayOAuth/cancel",
|
||||
/*id*/ 2,
|
||||
/*params*/ None,
|
||||
)
|
||||
.await?;
|
||||
let error = read_error_for_id(&mut observer, /*id*/ 2).await?;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Gateway sign-in belongs to another connection"
|
||||
);
|
||||
owner.close(/*msg*/ None).await?;
|
||||
let canceled =
|
||||
read_notification_for_method(&mut observer, "account/gatewayOAuth/changed").await?;
|
||||
assert_eq!(canceled.params.unwrap()["status"], "failed");
|
||||
send_request(
|
||||
&mut observer,
|
||||
"account/gatewayOAuth/login",
|
||||
/*id*/ 3,
|
||||
/*params*/ None,
|
||||
)
|
||||
.await?;
|
||||
let started =
|
||||
read_notification_for_method(&mut observer, "account/gatewayOAuth/changed").await?;
|
||||
assert_eq!(started.params.unwrap()["status"], "started");
|
||||
send_request(
|
||||
&mut observer,
|
||||
"account/gatewayOAuth/cancel",
|
||||
/*id*/ 4,
|
||||
/*params*/ None,
|
||||
)
|
||||
.await?;
|
||||
// Stop the test process after observing that a new connection can own a new login.
|
||||
process.kill().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialization_selects_legacy_or_explicit_gateway_login() -> Result<()> {
|
||||
for explicit in [false, true] {
|
||||
let home = TempDir::new()?;
|
||||
let oauth = MockServer::start().await;
|
||||
// Force automatic authorization to fail before launching a real browser.
|
||||
let callback = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = callback.local_addr()?.port();
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!("{}\nredirect_port = {port}\n", gateway_config(&oauth)),
|
||||
)?;
|
||||
let mut server = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
.build()
|
||||
.await?;
|
||||
server
|
||||
.initialize_with_capabilities(
|
||||
ClientInfo {
|
||||
name: "gateway-compat-test".into(),
|
||||
title: None,
|
||||
version: "1".into(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: explicit,
|
||||
experimental_api: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!("{}\nredirect_port = {port}\n", gateway_config(&oauth))
|
||||
.replace("app-server-explicit-test", "gateway-after-initialize"),
|
||||
)?;
|
||||
let thread = server
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
|
||||
.await?;
|
||||
let thread: ThreadStartResponse = server.read_response(thread).await?;
|
||||
let turn = server
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.thread.id,
|
||||
input: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: vec![],
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let _: TurnStartResponse = server.read_response(turn).await?;
|
||||
let completed: TurnCompletedNotification =
|
||||
timeout(TIMEOUT, server.read_notification("turn/completed")).await??;
|
||||
assert_eq!(completed.turn.status, TurnStatus::Failed);
|
||||
assert_eq!(
|
||||
completed.turn.error.context("gateway failure")?.message,
|
||||
if explicit {
|
||||
"Gateway sign-in required. Choose Sign in or Reconnect, then retry your request"
|
||||
} else {
|
||||
"Gateway OAuth authentication failed; check the gateway configuration and credential store."
|
||||
}
|
||||
);
|
||||
assert!(oauth.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -240,6 +240,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: Some(vec!["thread/started".to_string()]),
|
||||
|
||||
@@ -259,6 +259,7 @@ async fn mcp_server_tool_call_forwards_only_server_extensions() -> Result<()> {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
@@ -354,6 +355,7 @@ async fn model_mcp_tool_call_uses_session_client_extensions() -> Result<()> {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
mcp_server_openai_form_elicitation: true,
|
||||
|
||||
@@ -52,6 +52,8 @@ mod external_agent_config;
|
||||
mod external_agent_import_sync;
|
||||
mod feedback;
|
||||
mod fs;
|
||||
#[path = "gateway_oauth_tests.rs"]
|
||||
mod gateway_oauth;
|
||||
mod git_attribution;
|
||||
mod guardian_v2;
|
||||
mod history_notes_extension;
|
||||
|
||||
@@ -655,6 +655,7 @@ async fn initialize_experimental(mcp: &mut TestAppServer) -> Result<()> {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
|
||||
@@ -148,6 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]),
|
||||
|
||||
@@ -194,6 +194,7 @@ pub(super) fn server_notification_thread_target(
|
||||
| ServerNotification::SkillsChanged(_)
|
||||
| ServerNotification::McpServerOauthLoginCompleted(_)
|
||||
| ServerNotification::AccountUpdated(_)
|
||||
| ServerNotification::GatewayOAuthChanged(_)
|
||||
| ServerNotification::AccountRateLimitsUpdated(_)
|
||||
| ServerNotification::AppListUpdated(_)
|
||||
| ServerNotification::EnvironmentConnected(_)
|
||||
|
||||
@@ -341,6 +341,7 @@ impl ChatWidget {
|
||||
}
|
||||
ServerNotification::ServerRequestResolved(_)
|
||||
| ServerNotification::AccountUpdated(_)
|
||||
| ServerNotification::GatewayOAuthChanged(_)
|
||||
| ServerNotification::AccountRateLimitsUpdated(_)
|
||||
| ServerNotification::ThreadStarted(_)
|
||||
| ServerNotification::ThreadStatusChanged(_)
|
||||
|
||||
@@ -27,6 +27,7 @@ from .v2_all import FileChangePatchUpdatedNotification
|
||||
from .v2_all import FsChangedNotification
|
||||
from .v2_all import FuzzyFileSearchSessionCompletedNotification
|
||||
from .v2_all import FuzzyFileSearchSessionUpdatedNotification
|
||||
from .v2_all import GatewayOAuthChangedNotification
|
||||
from .v2_all import GuardianWarningNotification
|
||||
from .v2_all import HookCompletedNotification
|
||||
from .v2_all import HookStartedNotification
|
||||
@@ -109,6 +110,7 @@ KnownNotificationPayload: TypeAlias = (
|
||||
| FsChangedNotification
|
||||
| FuzzyFileSearchSessionCompletedNotification
|
||||
| FuzzyFileSearchSessionUpdatedNotification
|
||||
| GatewayOAuthChangedNotification
|
||||
| GuardianWarningNotification
|
||||
| HookCompletedNotification
|
||||
| HookStartedNotification
|
||||
@@ -172,6 +174,7 @@ KnownNotificationPayload: TypeAlias = (
|
||||
)
|
||||
|
||||
NOTIFICATION_MODELS: dict[str, type[KnownNotificationPayload]] = {
|
||||
"account/gatewayOAuth/changed": GatewayOAuthChangedNotification,
|
||||
"account/login/completed": AccountLoginCompletedNotification,
|
||||
"account/rateLimits/updated": AccountRateLimitsUpdatedNotification,
|
||||
"account/updated": AccountUpdatedNotification,
|
||||
|
||||
@@ -1830,6 +1830,27 @@ class FuzzyFileSearchSessionUpdatedNotification(BaseModel):
|
||||
session_id: Annotated[str, Field(alias="sessionId")]
|
||||
|
||||
|
||||
class GatewayOAuthCancelResponse(BaseModel):
|
||||
pass
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
|
||||
class GatewayOAuthLoginResponse(BaseModel):
|
||||
pass
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
|
||||
class GatewayOAuthStatus(Enum):
|
||||
not_ready = "notReady"
|
||||
started = "started"
|
||||
succeeded = "succeeded"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class GetAccountParams(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -2094,6 +2115,13 @@ class InitializeCapabilities(BaseModel):
|
||||
description="Opt into receiving experimental API methods and fields.",
|
||||
),
|
||||
] = False
|
||||
explicit_gateway_oauth: Annotated[
|
||||
bool | None,
|
||||
Field(
|
||||
alias="explicitGatewayOauth",
|
||||
description="Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.",
|
||||
),
|
||||
] = None
|
||||
extensions: Annotated[
|
||||
dict[str, Any] | None,
|
||||
Field(description="MCP extension settings declared by the app-server client."),
|
||||
@@ -7249,6 +7277,41 @@ class ModelListRequest(BaseModel):
|
||||
params: ModelListParams
|
||||
|
||||
|
||||
class AccountGatewayOAuthReadRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
id: RequestId
|
||||
method: Annotated[
|
||||
Literal["account/gatewayOAuth/read"], Field(title="Account/gatewayOAuth/readRequestMethod")
|
||||
]
|
||||
params: None = None
|
||||
|
||||
|
||||
class AccountGatewayOAuthLoginRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
id: RequestId
|
||||
method: Annotated[
|
||||
Literal["account/gatewayOAuth/login"],
|
||||
Field(title="Account/gatewayOAuth/loginRequestMethod"),
|
||||
]
|
||||
params: None = None
|
||||
|
||||
|
||||
class AccountGatewayOAuthCancelRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
id: RequestId
|
||||
method: Annotated[
|
||||
Literal["account/gatewayOAuth/cancel"],
|
||||
Field(title="Account/gatewayOAuth/cancelRequestMethod"),
|
||||
]
|
||||
params: None = None
|
||||
|
||||
|
||||
class ModelProviderCapabilitiesReadRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -8092,6 +8155,41 @@ class FunctionCallOutputContentItem(
|
||||
]
|
||||
|
||||
|
||||
class GatewayOAuthChangedNotification(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
auth_url: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
alias="authUrl",
|
||||
description="Authorization handoff, sent only to the connection that started login.",
|
||||
),
|
||||
] = None
|
||||
error: str | None = None
|
||||
provider_id: Annotated[str, Field(alias="providerId")]
|
||||
status: GatewayOAuthStatus
|
||||
|
||||
|
||||
class GatewayOAuthReadResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
error: str | None = None
|
||||
provider_id: Annotated[str, Field(alias="providerId")]
|
||||
provider_name: Annotated[str, Field(alias="providerName")]
|
||||
required: Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Whether the selected provider uses gateway OAuth, even when already signed in."
|
||||
),
|
||||
]
|
||||
status: Annotated[
|
||||
GatewayOAuthStatus | None,
|
||||
Field(description="Null when the effective provider does not use gateway OAuth."),
|
||||
] = None
|
||||
|
||||
|
||||
class GetAccountResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -9207,6 +9305,24 @@ class AccountUpdatedServerNotification(BaseModel):
|
||||
params: AccountUpdatedNotification
|
||||
|
||||
|
||||
class AccountGatewayOAuthChangedServerNotification(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
emitted_at_ms: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
alias="emittedAtMs",
|
||||
description="Unix timestamp (in milliseconds) when app-server emitted this notification.",
|
||||
),
|
||||
] = None
|
||||
method: Annotated[
|
||||
Literal["account/gatewayOAuth/changed"],
|
||||
Field(title="Account/gatewayOAuth/changedNotificationMethod"),
|
||||
]
|
||||
params: GatewayOAuthChangedNotification
|
||||
|
||||
|
||||
class TurnModerationMetadataServerNotification(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -12337,6 +12453,9 @@ class ClientRequest(
|
||||
| TurnInterruptRequest
|
||||
| ReviewStartRequest
|
||||
| ModelListRequest
|
||||
| AccountGatewayOAuthReadRequest
|
||||
| AccountGatewayOAuthLoginRequest
|
||||
| AccountGatewayOAuthCancelRequest
|
||||
| ModelProviderCapabilitiesReadRequest
|
||||
| ExperimentalFeatureListRequest
|
||||
| PermissionProfileListRequest
|
||||
@@ -12444,6 +12563,9 @@ class ClientRequest(
|
||||
| TurnInterruptRequest
|
||||
| ReviewStartRequest
|
||||
| ModelListRequest
|
||||
| AccountGatewayOAuthReadRequest
|
||||
| AccountGatewayOAuthLoginRequest
|
||||
| AccountGatewayOAuthCancelRequest
|
||||
| ModelProviderCapabilitiesReadRequest
|
||||
| ExperimentalFeatureListRequest
|
||||
| PermissionProfileListRequest
|
||||
@@ -12701,6 +12823,7 @@ class ServerNotification(
|
||||
| McpServerStartupStatusUpdatedServerNotification
|
||||
| McpServerEventStreamNotificationServerNotification
|
||||
| AccountUpdatedServerNotification
|
||||
| AccountGatewayOAuthChangedServerNotification
|
||||
| AccountRateLimitsUpdatedServerNotification
|
||||
| AppListUpdatedServerNotification
|
||||
| RemoteControlStatusChangedServerNotification
|
||||
@@ -12789,6 +12912,7 @@ class ServerNotification(
|
||||
| McpServerStartupStatusUpdatedServerNotification
|
||||
| McpServerEventStreamNotificationServerNotification
|
||||
| AccountUpdatedServerNotification
|
||||
| AccountGatewayOAuthChangedServerNotification
|
||||
| AccountRateLimitsUpdatedServerNotification
|
||||
| AppListUpdatedServerNotification
|
||||
| RemoteControlStatusChangedServerNotification
|
||||
|
||||
Reference in New Issue
Block a user