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:
alexsong-oai
2026-09-22 06:36:09 +00:00
committed by copyberry
parent 6ea62c4396
commit 064e701b0f
54 changed files with 2134 additions and 22 deletions
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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": {
@@ -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": {
@@ -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"
}
@@ -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"
}
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
File diff suppressed because one or more lines are too long
@@ -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>;
@@ -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, };
@@ -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>;
@@ -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(
+62
View File
@@ -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;
}
}))
}
+1
View File
@@ -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(_)
+1
View File
@@ -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