Compare commits

...

10 Commits

Author SHA1 Message Date
Dhruv
e39598b663 Document workspace token exchange during session switch 2026-06-02 04:04:24 +05:30
Dhruv
bc2dd9faf5 Exchange workspace token when switching account sessions 2026-06-02 04:00:43 +05:30
Dhruv
9f70baed4a Register account session lifecycle routes 2026-06-02 01:55:58 +05:30
Dhruv
08abda2726 Add app server account session lifecycle 2026-06-02 01:41:35 +05:30
Dhruv
fd36ea04e2 Isolate saved account session storage 2026-06-02 01:41:20 +05:30
Dhruv
62190604cf Fix account session storage argument comment 2026-06-01 08:00:12 +05:30
Dhruv
c4c1505bbf Harden saved account session storage 2026-06-01 07:44:49 +05:30
Dhruv
409afc2046 Add saved account session credential slots 2026-06-01 07:43:44 +05:30
Dhruv
5ae17fb80f Fix account session protocol layering 2026-06-01 07:43:39 +05:30
Dhruv
b7bce9beb7 Add app server account session protocol 2026-06-01 06:11:19 +05:30
37 changed files with 2303 additions and 9 deletions

View File

@@ -5,6 +5,48 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AccountSessionsAddParams": {
"properties": {
"switchToAddedAccount": {
"type": "boolean"
}
},
"type": "object"
},
"AccountSessionsListParams": {
"properties": {
"refreshWorkspaceMetadata": {
"type": "boolean"
}
},
"type": "object"
},
"AccountSessionsLogoutParams": {
"properties": {
"sessionId": {
"type": "string"
}
},
"required": [
"sessionId"
],
"type": "object"
},
"AccountSessionsSwitchParams": {
"properties": {
"accountId": {
"type": "string"
},
"sessionId": {
"type": "string"
}
},
"required": [
"accountId",
"sessionId"
],
"type": "object"
},
"AddCreditsNudgeCreditType": {
"enum": [
"credits",
@@ -5875,6 +5917,102 @@
"title": "Account/login/cancelRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"accountSession/add"
],
"title": "AccountSession/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AccountSessionsAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/addRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"accountSession/list"
],
"title": "AccountSession/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AccountSessionsListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"accountSession/logout"
],
"title": "AccountSession/logoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AccountSessionsLogoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/logoutRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"accountSession/switch"
],
"title": "AccountSession/switchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AccountSessionsSwitchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/switchRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1787,6 +1787,102 @@
"title": "Account/login/cancelRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"accountSession/add"
],
"title": "AccountSession/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AccountSessionsAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/addRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"accountSession/list"
],
"title": "AccountSession/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AccountSessionsListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"accountSession/logout"
],
"title": "AccountSession/logoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AccountSessionsLogoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/logoutRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"accountSession/switch"
],
"title": "AccountSession/switchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AccountSessionsSwitchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/switchRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -5731,6 +5827,193 @@
"title": "AccountRateLimitsUpdatedNotification",
"type": "object"
},
"AccountSession": {
"properties": {
"displayName": {
"type": [
"string",
"null"
]
},
"email": {
"type": [
"string",
"null"
]
},
"imageUrl": {
"type": [
"string",
"null"
]
},
"isActive": {
"type": "boolean"
},
"lastUsedAt": {
"format": "int64",
"type": "integer"
},
"plan": {
"type": [
"string",
"null"
]
},
"selectedWorkspaceAccountId": {
"type": [
"string",
"null"
]
},
"sessionId": {
"type": "string"
},
"userId": {
"type": [
"string",
"null"
]
},
"workspaces": {
"items": {
"$ref": "#/definitions/v2/AccountSessionWorkspace"
},
"type": "array"
}
},
"required": [
"isActive",
"lastUsedAt",
"sessionId",
"workspaces"
],
"type": "object"
},
"AccountSessionWorkspace": {
"properties": {
"accountId": {
"type": "string"
},
"imageUrl": {
"type": [
"string",
"null"
]
},
"kind": {
"anyOf": [
{
"$ref": "#/definitions/v2/AccountSessionWorkspaceKind"
},
{
"type": "null"
}
]
},
"name": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/v2/AccountSessionWorkspaceStatus"
}
},
"required": [
"accountId",
"status"
],
"type": "object"
},
"AccountSessionWorkspaceKind": {
"enum": [
"personal",
"workspace"
],
"type": "string"
},
"AccountSessionWorkspaceStatus": {
"enum": [
"active",
"disabled",
"deactivated"
],
"type": "string"
},
"AccountSessionsAddParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"switchToAddedAccount": {
"type": "boolean"
}
},
"title": "AccountSessionsAddParams",
"type": "object"
},
"AccountSessionsListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"refreshWorkspaceMetadata": {
"type": "boolean"
}
},
"title": "AccountSessionsListParams",
"type": "object"
},
"AccountSessionsLogoutParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"sessionId": {
"type": "string"
}
},
"required": [
"sessionId"
],
"title": "AccountSessionsLogoutParams",
"type": "object"
},
"AccountSessionsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"activeSessionId": {
"type": [
"string",
"null"
]
},
"sessions": {
"items": {
"$ref": "#/definitions/v2/AccountSession"
},
"type": "array"
}
},
"required": [
"sessions"
],
"title": "AccountSessionsResponse",
"type": "object"
},
"AccountSessionsSwitchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"accountId": {
"type": "string"
},
"sessionId": {
"type": "string"
}
},
"required": [
"accountId",
"sessionId"
],
"title": "AccountSessionsSwitchParams",
"type": "object"
},
"AccountUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {

View File

@@ -103,6 +103,193 @@
"title": "AccountRateLimitsUpdatedNotification",
"type": "object"
},
"AccountSession": {
"properties": {
"displayName": {
"type": [
"string",
"null"
]
},
"email": {
"type": [
"string",
"null"
]
},
"imageUrl": {
"type": [
"string",
"null"
]
},
"isActive": {
"type": "boolean"
},
"lastUsedAt": {
"format": "int64",
"type": "integer"
},
"plan": {
"type": [
"string",
"null"
]
},
"selectedWorkspaceAccountId": {
"type": [
"string",
"null"
]
},
"sessionId": {
"type": "string"
},
"userId": {
"type": [
"string",
"null"
]
},
"workspaces": {
"items": {
"$ref": "#/definitions/AccountSessionWorkspace"
},
"type": "array"
}
},
"required": [
"isActive",
"lastUsedAt",
"sessionId",
"workspaces"
],
"type": "object"
},
"AccountSessionWorkspace": {
"properties": {
"accountId": {
"type": "string"
},
"imageUrl": {
"type": [
"string",
"null"
]
},
"kind": {
"anyOf": [
{
"$ref": "#/definitions/AccountSessionWorkspaceKind"
},
{
"type": "null"
}
]
},
"name": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/AccountSessionWorkspaceStatus"
}
},
"required": [
"accountId",
"status"
],
"type": "object"
},
"AccountSessionWorkspaceKind": {
"enum": [
"personal",
"workspace"
],
"type": "string"
},
"AccountSessionWorkspaceStatus": {
"enum": [
"active",
"disabled",
"deactivated"
],
"type": "string"
},
"AccountSessionsAddParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"switchToAddedAccount": {
"type": "boolean"
}
},
"title": "AccountSessionsAddParams",
"type": "object"
},
"AccountSessionsListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"refreshWorkspaceMetadata": {
"type": "boolean"
}
},
"title": "AccountSessionsListParams",
"type": "object"
},
"AccountSessionsLogoutParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"sessionId": {
"type": "string"
}
},
"required": [
"sessionId"
],
"title": "AccountSessionsLogoutParams",
"type": "object"
},
"AccountSessionsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"activeSessionId": {
"type": [
"string",
"null"
]
},
"sessions": {
"items": {
"$ref": "#/definitions/AccountSession"
},
"type": "array"
}
},
"required": [
"sessions"
],
"title": "AccountSessionsResponse",
"type": "object"
},
"AccountSessionsSwitchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"accountId": {
"type": "string"
},
"sessionId": {
"type": "string"
}
},
"required": [
"accountId",
"sessionId"
],
"title": "AccountSessionsSwitchParams",
"type": "object"
},
"AccountUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -2535,6 +2722,102 @@
"title": "Account/login/cancelRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"accountSession/add"
],
"title": "AccountSession/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AccountSessionsAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/addRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"accountSession/list"
],
"title": "AccountSession/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AccountSessionsListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"accountSession/logout"
],
"title": "AccountSession/logoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AccountSessionsLogoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/logoutRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"accountSession/switch"
],
"title": "AccountSession/switchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AccountSessionsSwitchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "AccountSession/switchRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -0,0 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"switchToAddedAccount": {
"type": "boolean"
}
},
"title": "AccountSessionsAddParams",
"type": "object"
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"refreshWorkspaceMetadata": {
"type": "boolean"
}
},
"title": "AccountSessionsListParams",
"type": "object"
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"sessionId": {
"type": "string"
}
},
"required": [
"sessionId"
],
"title": "AccountSessionsLogoutParams",
"type": "object"
}

View File

@@ -0,0 +1,139 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AccountSession": {
"properties": {
"displayName": {
"type": [
"string",
"null"
]
},
"email": {
"type": [
"string",
"null"
]
},
"imageUrl": {
"type": [
"string",
"null"
]
},
"isActive": {
"type": "boolean"
},
"lastUsedAt": {
"format": "int64",
"type": "integer"
},
"plan": {
"type": [
"string",
"null"
]
},
"selectedWorkspaceAccountId": {
"type": [
"string",
"null"
]
},
"sessionId": {
"type": "string"
},
"userId": {
"type": [
"string",
"null"
]
},
"workspaces": {
"items": {
"$ref": "#/definitions/AccountSessionWorkspace"
},
"type": "array"
}
},
"required": [
"isActive",
"lastUsedAt",
"sessionId",
"workspaces"
],
"type": "object"
},
"AccountSessionWorkspace": {
"properties": {
"accountId": {
"type": "string"
},
"imageUrl": {
"type": [
"string",
"null"
]
},
"kind": {
"anyOf": [
{
"$ref": "#/definitions/AccountSessionWorkspaceKind"
},
{
"type": "null"
}
]
},
"name": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/AccountSessionWorkspaceStatus"
}
},
"required": [
"accountId",
"status"
],
"type": "object"
},
"AccountSessionWorkspaceKind": {
"enum": [
"personal",
"workspace"
],
"type": "string"
},
"AccountSessionWorkspaceStatus": {
"enum": [
"active",
"disabled",
"deactivated"
],
"type": "string"
}
},
"properties": {
"activeSessionId": {
"type": [
"string",
"null"
]
},
"sessions": {
"items": {
"$ref": "#/definitions/AccountSession"
},
"type": "array"
}
},
"required": [
"sessions"
],
"title": "AccountSessionsResponse",
"type": "object"
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"accountId": {
"type": "string"
},
"sessionId": {
"type": "string"
}
},
"required": [
"accountId",
"sessionId"
],
"title": "AccountSessionsSwitchParams",
"type": "object"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
// 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 { AccountSessionWorkspace } from "./AccountSessionWorkspace";
export type AccountSession = { sessionId: string, email: string | null, userId: string | null, displayName: string | null, imageUrl: string | null, plan: string | null, lastUsedAt: bigint, isActive: boolean, selectedWorkspaceAccountId: string | null, workspaces: Array<AccountSessionWorkspace>, };

View File

@@ -0,0 +1,7 @@
// 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 { AccountSessionWorkspaceKind } from "./AccountSessionWorkspaceKind";
import type { AccountSessionWorkspaceStatus } from "./AccountSessionWorkspaceStatus";
export type AccountSessionWorkspace = { accountId: string, name: string | null, imageUrl: string | null, kind: AccountSessionWorkspaceKind | null, status: AccountSessionWorkspaceStatus, };

View File

@@ -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 AccountSessionWorkspaceKind = "personal" | "workspace";

View File

@@ -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 AccountSessionWorkspaceStatus = "active" | "disabled" | "deactivated";

View File

@@ -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 AccountSessionsAddParams = { switchToAddedAccount?: boolean, };

View File

@@ -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 AccountSessionsListParams = { refreshWorkspaceMetadata?: boolean, };

View File

@@ -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 AccountSessionsLogoutParams = { sessionId: string, };

View File

@@ -0,0 +1,6 @@
// 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 { AccountSession } from "./AccountSession";
export type AccountSessionsResponse = { activeSessionId: string | null, sessions: Array<AccountSession>, };

View File

@@ -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 AccountSessionsSwitchParams = { sessionId: string, accountId: string, };

View File

@@ -3,6 +3,15 @@
export type { Account } from "./Account";
export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification";
export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification";
export type { AccountSession } from "./AccountSession";
export type { AccountSessionWorkspace } from "./AccountSessionWorkspace";
export type { AccountSessionWorkspaceKind } from "./AccountSessionWorkspaceKind";
export type { AccountSessionWorkspaceStatus } from "./AccountSessionWorkspaceStatus";
export type { AccountSessionsAddParams } from "./AccountSessionsAddParams";
export type { AccountSessionsListParams } from "./AccountSessionsListParams";
export type { AccountSessionsLogoutParams } from "./AccountSessionsLogoutParams";
export type { AccountSessionsResponse } from "./AccountSessionsResponse";
export type { AccountSessionsSwitchParams } from "./AccountSessionsSwitchParams";
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { ActivePermissionProfile } from "./ActivePermissionProfile";
export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType";

View File

@@ -919,6 +919,33 @@ client_request_definitions! {
response: v2::CancelLoginAccountResponse,
},
AccountSessionsAdd => "accountSession/add" {
params: v2::AccountSessionsAddParams,
serialization: global("account-auth"),
response: v2::AccountSessionsResponse,
},
AccountSessionsList => "accountSession/list" {
params: v2::AccountSessionsListParams,
serialization: global("account-auth"),
manual_payload_conversion: manual,
response: v2::AccountSessionsResponse,
},
AccountSessionsLogout => "accountSession/logout" {
params: v2::AccountSessionsLogoutParams,
serialization: global("account-auth"),
manual_payload_conversion: manual,
response: v2::AccountSessionsResponse,
},
AccountSessionsSwitch => "accountSession/switch" {
params: v2::AccountSessionsSwitchParams,
serialization: global("account-auth"),
manual_payload_conversion: manual,
response: v2::AccountSessionsResponse,
},
LogoutAccount => "account/logout" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("account-auth"),

View File

@@ -137,6 +137,89 @@ pub struct CancelLoginAccountResponse {
pub status: CancelLoginAccountStatus,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsAddParams {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub switch_to_added_account: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsListParams {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub refresh_workspace_metadata: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsLogoutParams {
pub session_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsSwitchParams {
pub session_id: String,
pub account_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsResponse {
pub active_session_id: Option<String>,
pub sessions: Vec<AccountSession>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSession {
pub session_id: String,
pub email: Option<String>,
pub user_id: Option<String>,
pub display_name: Option<String>,
pub image_url: Option<String>,
pub plan: Option<String>,
pub last_used_at: i64,
pub is_active: bool,
pub selected_workspace_account_id: Option<String>,
pub workspaces: Vec<AccountSessionWorkspace>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionWorkspace {
pub account_id: String,
pub name: Option<String>,
pub image_url: Option<String>,
pub kind: Option<AccountSessionWorkspaceKind>,
pub status: AccountSessionWorkspaceStatus,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum AccountSessionWorkspaceKind {
Personal,
Workspace,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum AccountSessionWorkspaceStatus {
Active,
Disabled,
Deactivated,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View File

@@ -1757,6 +1757,10 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`).
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `accountSession/add` — save the current managed ChatGPT auth session.
- `accountSession/list` — list saved managed ChatGPT auth sessions and optionally refresh their workspace metadata.
- `accountSession/switch` — make a saved ChatGPT auth session and workspace current.
- `accountSession/logout` — revoke and remove one saved ChatGPT auth session.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
@@ -1842,19 +1846,30 @@ Field notes:
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": false, "error": "…" } }
```
### 6) Logout
### 6) Save and switch ChatGPT account sessions
Desktop clients can save the current managed ChatGPT auth payload after login, list saved sessions, and switch the active workspace. Switching a workspace exchanges the saved access token for a newly signed workspace-scoped token without opening a browser. These methods return the full saved-session snapshot because the number of interactive account sessions is intentionally small.
```json
{ "method": "account/logout", "id": 6 }
{ "id": 6, "result": {} }
{ "method": "accountSession/add", "id": 6, "params": { "switchToAddedAccount": true } }
{ "method": "accountSession/list", "id": 7, "params": { "refreshWorkspaceMetadata": true } }
{ "method": "accountSession/switch", "id": 8, "params": { "sessionId": "<uuid>", "accountId": "<workspace-id>" } }
{ "method": "accountSession/logout", "id": 9, "params": { "sessionId": "<uuid>" } }
```
### 7) Logout
```json
{ "method": "account/logout", "id": 10 }
{ "id": 10, "result": {} }
{ "method": "account/updated", "params": { "authMode": null, "planType": null } }
```
### 7) Rate limits (ChatGPT)
### 8) Rate limits (ChatGPT)
```json
{ "method": "account/rateLimits/read", "id": 7 }
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } }
{ "method": "account/rateLimits/read", "id": 11 }
{ "id": 11, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } }
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
```

View File

@@ -0,0 +1,581 @@
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AccountSession;
use codex_app_server_protocol::AccountSessionWorkspace;
use codex_app_server_protocol::AccountSessionWorkspaceKind;
use codex_app_server_protocol::AccountSessionWorkspaceStatus;
use codex_app_server_protocol::AccountSessionsResponse;
use codex_backend_client::AccountEntry;
use codex_backend_client::Client as BackendClient;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthDotJson;
use codex_login::CodexAuth;
use codex_login::delete_account_session_auth;
use codex_login::load_account_session_auth;
use codex_login::load_auth_dot_json;
use codex_login::logout;
use codex_login::revoke_account_session_auth;
use codex_login::save_account_session_auth;
use codex_login::save_auth;
use codex_login::token_data::parse_chatgpt_jwt_claims;
use serde::Deserialize;
use serde::Serialize;
use std::fs::OpenOptions;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::path::PathBuf;
use uuid::Uuid;
const ACCOUNT_SESSIONS_FILE: &str = "account-sessions.json";
pub(crate) struct AccountSessionsStore<'a> {
codex_home: &'a Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: &'a str,
}
#[derive(Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct StoredAccountSessions {
active_session_id: Option<String>,
sessions: Vec<StoredAccountSession>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct StoredAccountSession {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
auth_json: Option<AuthDotJson>,
email: Option<String>,
user_id: Option<String>,
display_name: Option<String>,
image_url: Option<String>,
plan: Option<String>,
last_used_at: i64,
selected_workspace_account_id: Option<String>,
workspaces: Vec<AccountSessionWorkspace>,
}
#[derive(Default, Deserialize)]
struct AccessTokenClaims {
#[serde(rename = "https://api.openai.com/profile", default)]
profile: AccessTokenProfileClaims,
}
#[derive(Default, Deserialize)]
struct AccessTokenProfileClaims {
#[serde(default)]
image: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
picture: Option<String>,
}
impl<'a> AccountSessionsStore<'a> {
pub(crate) fn new(
codex_home: &'a Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: &'a str,
) -> Self {
Self {
codex_home,
auth_credentials_store_mode,
chatgpt_base_url,
}
}
pub(crate) async fn add(
&self,
switch_to_added_account: bool,
) -> std::io::Result<AccountSessionsResponse> {
let mut stored = self.load()?;
let mut auth_json = load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)?
.ok_or_else(|| std::io::Error::other("No active ChatGPT auth session to add"))?;
let mut session = Self::session_from_auth_json(&auth_json)
.ok_or_else(|| std::io::Error::other("No active ChatGPT auth session to add"))?;
self.refresh_workspace_metadata(&mut session, &mut auth_json)
.await;
let existing_index = stored.sessions.iter().position(|saved| {
session
.email
.as_ref()
.is_some_and(|email| saved.email.as_ref() == Some(email))
|| session
.user_id
.as_ref()
.is_some_and(|user_id| saved.user_id.as_ref() == Some(user_id))
});
if let Some(index) = existing_index {
session
.session_id
.clone_from(&stored.sessions[index].session_id);
}
let added_session_id = session.session_id.clone();
self.save_session_auth(&added_session_id, &auth_json)?;
if let Some(index) = existing_index {
stored.sessions[index] = session;
} else {
stored.sessions.push(session);
}
if switch_to_added_account || stored.active_session_id.is_none() {
stored.active_session_id = Some(added_session_id);
save_auth(
self.codex_home,
&auth_json,
self.auth_credentials_store_mode,
)?;
} else if let Some(active_session_id) = stored.active_session_id.as_deref() {
self.save_active_session_auth(active_session_id)?;
}
self.save(&stored)?;
Ok(Self::response(stored))
}
pub(crate) async fn list(
&self,
refresh_workspace_metadata: bool,
) -> std::io::Result<AccountSessionsResponse> {
self.sync_active_auth()?;
let mut stored = self.load()?;
if refresh_workspace_metadata {
for session in &mut stored.sessions {
let Some(mut auth_json) = self.load_session_auth(&session.session_id)? else {
continue;
};
self.refresh_workspace_metadata(session, &mut auth_json)
.await;
self.save_session_auth(&session.session_id, &auth_json)?;
}
self.save(&stored)?;
}
Ok(Self::response(stored))
}
pub(crate) async fn logout(
&self,
session_id: &str,
) -> std::io::Result<AccountSessionsResponse> {
self.sync_active_auth()?;
let mut stored = self.load()?;
let index = stored
.sessions
.iter()
.position(|session| session.session_id == session_id)
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session not found"))?;
let removed = stored.sessions.remove(index);
if let Err(err) = revoke_account_session_auth(
self.codex_home,
&removed.session_id,
self.auth_credentials_store_mode,
)
.await
{
tracing::warn!("failed to revoke saved account session during logout: {err}");
}
delete_account_session_auth(
self.codex_home,
&removed.session_id,
self.auth_credentials_store_mode,
)?;
if stored.active_session_id.as_deref() == Some(session_id) {
let newest = stored
.sessions
.iter()
.max_by_key(|session| session.last_used_at);
stored.active_session_id = newest.map(|session| session.session_id.clone());
match newest {
Some(session) => self.save_active_session_auth(&session.session_id)?,
None => {
logout(self.codex_home, self.auth_credentials_store_mode)?;
}
}
}
self.save(&stored)?;
Ok(Self::response(stored))
}
pub(crate) async fn switch(
&self,
session_id: &str,
account_id: &str,
) -> std::io::Result<AccountSessionsResponse> {
self.sync_active_auth()?;
let mut stored = self.load()?;
let index = stored
.sessions
.iter()
.position(|session| session.session_id == session_id)
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session not found"))?;
let mut auth_json = self
.load_session_auth(&stored.sessions[index].session_id)?
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?;
let auth = CodexAuth::from_account_session_auth_dot_json(
self.codex_home,
&stored.sessions[index].session_id,
auth_json.clone(),
self.auth_credentials_store_mode,
Some(self.chatgpt_base_url),
)
.await?;
let client = BackendClient::from_auth(self.chatgpt_base_url, &auth)
.map_err(std::io::Error::other)?;
// Changing only tokens.account_id would update the request header while leaving the
// bearer token scoped to the previous workspace. Exchange it first so backend routing
// uses the selected workspace from the newly signed token claims.
let replacement = client
.switch_workspace_token(account_id)
.await
.map_err(std::io::Error::other)?;
// The exchange does not return a replacement ID token. Refresh the workspace-specific
// plan from the new access token and keep the saved ID token intact.
let plan = parse_chatgpt_jwt_claims(&replacement.access_token)
.ok()
.and_then(|claims| claims.get_chatgpt_plan_type_raw());
let tokens = auth_json
.tokens
.as_mut()
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?;
tokens.access_token = replacement.access_token;
// Refresh-token rotation is optional, so keep the saved token when none is returned.
if let Some(refresh_token) = replacement.refresh_token {
tokens.refresh_token = refresh_token;
}
tokens.account_id = Some(account_id.to_string());
auth_json.last_refresh = Some(Utc::now());
let session = &mut stored.sessions[index];
if let Some(plan) = plan {
session.plan = Some(plan);
}
session.selected_workspace_account_id = Some(account_id.to_string());
session.last_used_at = Utc::now().timestamp();
stored.active_session_id = Some(session_id.to_string());
self.save_session_auth(session_id, &auth_json)?;
save_auth(
self.codex_home,
&auth_json,
self.auth_credentials_store_mode,
)?;
self.save(&stored)?;
Ok(Self::response(stored))
}
pub(crate) fn sync_active_auth(&self) -> std::io::Result<()> {
let mut stored = self.load()?;
if self.migrate_legacy_auth(&mut stored)? {
self.save(&stored)?;
}
let Some(active_session_id) = stored.active_session_id.as_ref() else {
return Ok(());
};
let Some(auth_json) =
load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)?
else {
return Ok(());
};
let Some(session) = stored
.sessions
.iter_mut()
.find(|session| &session.session_id == active_session_id)
else {
return Ok(());
};
if !Self::same_identity(session, &auth_json) {
return Ok(());
}
let saved_auth_json = self.load_session_auth(&session.session_id)?;
let selected_workspace_account_id = auth_json
.tokens
.as_ref()
.and_then(|tokens| tokens.account_id.clone());
if saved_auth_json.as_ref() == Some(&auth_json)
&& (selected_workspace_account_id.is_none()
|| session.selected_workspace_account_id == selected_workspace_account_id)
{
return Ok(());
}
self.save_session_auth(&session.session_id, &auth_json)?;
if selected_workspace_account_id.is_some() {
session.selected_workspace_account_id = selected_workspace_account_id;
}
self.save(&stored)
}
pub(crate) async fn revoke_all_and_clear(&self) -> std::io::Result<()> {
let Some(mut stored) = self.read()? else {
return self.clear();
};
self.migrate_legacy_auth(&mut stored)?;
for session in stored.sessions {
if let Err(err) = revoke_account_session_auth(
self.codex_home,
&session.session_id,
self.auth_credentials_store_mode,
)
.await
{
tracing::warn!("failed to revoke saved account session during logout: {err}");
}
delete_account_session_auth(
self.codex_home,
&session.session_id,
self.auth_credentials_store_mode,
)?;
}
self.clear()
}
fn load(&self) -> std::io::Result<StoredAccountSessions> {
match self.read()? {
Some(mut stored) => {
if self.migrate_legacy_auth(&mut stored)? {
self.save(&stored)?;
}
Ok(stored)
}
None => {
let auth_json =
load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)?;
let Some(auth_json) = auth_json else {
return Ok(StoredAccountSessions::default());
};
let Some(session) = Self::session_from_auth_json(&auth_json) else {
return Ok(StoredAccountSessions::default());
};
self.save_session_auth(&session.session_id, &auth_json)?;
let stored = StoredAccountSessions {
active_session_id: Some(session.session_id.clone()),
sessions: vec![session],
};
self.save(&stored)?;
Ok(stored)
}
}
}
fn migrate_legacy_auth(&self, stored: &mut StoredAccountSessions) -> std::io::Result<bool> {
let mut migrated = false;
for session in &mut stored.sessions {
if let Some(auth_json) = session.auth_json.take() {
self.save_session_auth(&session.session_id, &auth_json)?;
migrated = true;
}
}
Ok(migrated)
}
fn load_session_auth(&self, session_id: &str) -> std::io::Result<Option<AuthDotJson>> {
load_account_session_auth(
self.codex_home,
session_id,
self.auth_credentials_store_mode,
)
}
fn save_session_auth(&self, session_id: &str, auth_json: &AuthDotJson) -> std::io::Result<()> {
save_account_session_auth(
self.codex_home,
session_id,
auth_json,
self.auth_credentials_store_mode,
)
}
fn save_active_session_auth(&self, session_id: &str) -> std::io::Result<()> {
let auth_json = self
.load_session_auth(session_id)?
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?;
save_auth(
self.codex_home,
&auth_json,
self.auth_credentials_store_mode,
)
}
fn clear(&self) -> std::io::Result<()> {
match std::fs::remove_file(self.path()) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn read(&self) -> std::io::Result<Option<StoredAccountSessions>> {
match std::fs::read_to_string(self.path()) {
Ok(payload) => serde_json::from_str(&payload)
.map(Some)
.map_err(std::io::Error::other),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
fn save(&self, sessions: &StoredAccountSessions) -> std::io::Result<()> {
let path = self.path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut options = OpenOptions::new();
options.truncate(true).write(true).create(true);
#[cfg(unix)]
{
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(serde_json::to_string_pretty(sessions)?.as_bytes())?;
file.flush()
}
async fn refresh_workspace_metadata(
&self,
session: &mut StoredAccountSession,
auth_json: &mut AuthDotJson,
) {
let Ok(auth) = CodexAuth::from_account_session_auth_dot_json(
self.codex_home,
&session.session_id,
auth_json.clone(),
self.auth_credentials_store_mode,
Some(self.chatgpt_base_url),
)
.await
else {
return;
};
let Ok(client) = BackendClient::from_auth(self.chatgpt_base_url, &auth) else {
return;
};
let Ok(accounts) = client.get_accounts_check().await else {
return;
};
session.selected_workspace_account_id = session
.selected_workspace_account_id
.clone()
.or(accounts.default_account_id)
.or_else(|| accounts.account_ordering.first().cloned());
if let Some(account_id) = session.selected_workspace_account_id.as_ref()
&& let Some(tokens) = auth_json.tokens.as_mut()
{
tokens.account_id = Some(account_id.clone());
}
session.workspaces = accounts
.accounts
.into_iter()
.map(Self::workspace_from_account)
.collect();
}
fn session_from_auth_json(auth_json: &AuthDotJson) -> Option<StoredAccountSession> {
let tokens = auth_json.tokens.as_ref()?;
let claims = Self::access_token_claims(&tokens.access_token);
let selected_workspace_account_id = tokens
.account_id
.clone()
.or_else(|| tokens.id_token.chatgpt_account_id.clone());
let workspaces = selected_workspace_account_id
.as_ref()
.map(|account_id| {
vec![AccountSessionWorkspace {
account_id: account_id.clone(),
name: None,
image_url: None,
kind: None,
status: AccountSessionWorkspaceStatus::Active,
}]
})
.unwrap_or_default();
Some(StoredAccountSession {
session_id: Uuid::now_v7().to_string(),
auth_json: None,
email: tokens.id_token.email.clone(),
user_id: tokens.id_token.chatgpt_user_id.clone(),
display_name: claims.profile.name,
image_url: claims.profile.picture.or(claims.profile.image),
plan: tokens.id_token.get_chatgpt_plan_type_raw(),
last_used_at: Utc::now().timestamp(),
selected_workspace_account_id,
workspaces,
})
}
fn access_token_claims(access_token: &str) -> AccessTokenClaims {
let Some(payload) = access_token.split('.').nth(1) else {
return AccessTokenClaims::default();
};
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()
.and_then(|payload| serde_json::from_slice(&payload).ok())
.unwrap_or_default()
}
fn same_identity(session: &StoredAccountSession, auth_json: &AuthDotJson) -> bool {
let Some(tokens) = auth_json.tokens.as_ref() else {
return false;
};
session
.email
.as_ref()
.zip(tokens.id_token.email.as_ref())
.is_some_and(|(saved, active)| saved == active)
|| session
.user_id
.as_ref()
.zip(tokens.id_token.chatgpt_user_id.as_ref())
.is_some_and(|(saved, active)| saved == active)
}
fn workspace_from_account(account: AccountEntry) -> AccountSessionWorkspace {
let kind = match account.structure.as_str() {
"personal" => Some(AccountSessionWorkspaceKind::Personal),
"workspace" => Some(AccountSessionWorkspaceKind::Workspace),
_ => None,
};
AccountSessionWorkspace {
account_id: account.id,
name: account.name,
image_url: account.profile_picture_url,
kind,
status: AccountSessionWorkspaceStatus::Active,
}
}
fn response(stored: StoredAccountSessions) -> AccountSessionsResponse {
let active_session_id = stored.active_session_id;
let mut sessions = stored
.sessions
.into_iter()
.map(|session| AccountSession {
is_active: Some(&session.session_id) == active_session_id.as_ref(),
session_id: session.session_id,
email: session.email,
user_id: session.user_id,
display_name: session.display_name,
image_url: session.image_url,
plan: session.plan,
last_used_at: session.last_used_at,
selected_workspace_account_id: session.selected_workspace_account_id,
workspaces: session.workspaces,
})
.collect::<Vec<_>>();
sessions.sort_by_key(|session| std::cmp::Reverse(session.last_used_at));
AccountSessionsResponse {
active_session_id,
sessions,
}
}
fn path(&self) -> PathBuf {
self.codex_home.join(ACCOUNT_SESSIONS_FILE)
}
}

View File

@@ -73,6 +73,7 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
use tracing_subscriber::util::SubscriberInitExt;
mod account_sessions;
mod analytics_utils;
mod app_server_tracing;
mod attestation;

View File

@@ -1271,6 +1271,18 @@ impl MessageProcessor {
ClientRequest::CancelLoginAccount { params, .. } => {
self.account_processor.cancel_login_account(params).await
}
ClientRequest::AccountSessionsAdd { params, .. } => {
self.account_processor.add_account_session(params).await
}
ClientRequest::AccountSessionsList { params, .. } => {
self.account_processor.list_account_sessions(params).await
}
ClientRequest::AccountSessionsLogout { params, .. } => {
self.account_processor.logout_account_session(params).await
}
ClientRequest::AccountSessionsSwitch { params, .. } => {
self.account_processor.switch_account_session(params).await
}
ClientRequest::GetAccount { params, .. } => {
self.account_processor.get_account(params).await
}

View File

@@ -1,3 +1,4 @@
use crate::account_sessions::AccountSessionsStore;
use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed;
use crate::command_exec::CommandExecManager;
@@ -22,6 +23,10 @@ use codex_analytics::InputError;
use codex_analytics::TurnSteerRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountSessionsAddParams;
use codex_app_server_protocol::AccountSessionsListParams;
use codex_app_server_protocol::AccountSessionsLogoutParams;
use codex_app_server_protocol::AccountSessionsSwitchParams;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;

View File

@@ -105,6 +105,56 @@ impl AccountRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn add_account_session(
&self,
params: AccountSessionsAddParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let response = self
.account_sessions_store()
.add(params.switch_to_added_account)
.await
.map_err(|err| internal_error(format!("failed to add account session: {err}")))?;
self.sync_auth_after_account_session_change().await;
Ok(Some(response.into()))
}
pub(crate) async fn list_account_sessions(
&self,
params: AccountSessionsListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.account_sessions_store()
.list(params.refresh_workspace_metadata)
.await
.map(|response| Some(response.into()))
.map_err(|err| internal_error(format!("failed to list account sessions: {err}")))
}
pub(crate) async fn logout_account_session(
&self,
params: AccountSessionsLogoutParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let response = self
.account_sessions_store()
.logout(&params.session_id)
.await
.map_err(|err| internal_error(format!("failed to log out account session: {err}")))?;
self.sync_auth_after_account_session_change().await;
Ok(Some(response.into()))
}
pub(crate) async fn switch_account_session(
&self,
params: AccountSessionsSwitchParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let response = self
.account_sessions_store()
.switch(&params.session_id, &params.account_id)
.await
.map_err(|err| internal_error(format!("failed to switch account session: {err}")))?;
self.sync_auth_after_account_session_change().await;
Ok(Some(response.into()))
}
pub(crate) async fn get_account(
&self,
params: GetAccountParams,
@@ -159,6 +209,42 @@ impl AccountRequestProcessor {
}
}
fn account_sessions_store(&self) -> AccountSessionsStore<'_> {
AccountSessionsStore::new(
&self.config.codex_home,
self.config.cli_auth_credentials_store_mode,
&self.config.chatgpt_base_url,
)
}
fn sync_active_account_session(&self) -> Result<(), JSONRPCErrorError> {
self.account_sessions_store()
.sync_active_auth()
.map_err(|err| internal_error(format!("failed to sync active account session: {err}")))
}
async fn sync_auth_after_account_session_change(&self) {
self.auth_manager.reload().await;
self.config_manager.replace_cloud_requirements_loader(
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
);
self.config_manager
.sync_default_client_residency_requirement()
.await;
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
&self.config_manager,
&self.thread_manager,
self.auth_manager.auth_cached(),
)
.await;
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(
self.current_account_updated_notification(),
))
.await;
}
async fn maybe_refresh_remote_installed_plugins_cache_for_current_config(
config_manager: &ConfigManager,
thread_manager: &Arc<ThreadManager>,
@@ -265,6 +351,8 @@ impl AccountRequestProcessor {
));
}
self.sync_active_account_session()?;
// Cancel any active login attempt.
{
let mut guard = self.active_login.lock().await;
@@ -317,9 +405,12 @@ impl AccountRequestProcessor {
));
}
self.sync_active_account_session()?;
let opts = LoginServerOptions {
open_browser: false,
codex_streamlined_login,
revoke_previous_auth: false,
..LoginServerOptions::new(
config.codex_home.to_path_buf(),
CLIENT_ID.to_string(),
@@ -562,6 +653,8 @@ impl AccountRequestProcessor {
));
}
self.sync_active_account_session()?;
// Cancel any active login attempt to avoid persisting managed auth state.
{
let mut guard = self.active_login.lock().await;
@@ -676,6 +769,12 @@ impl AccountRequestProcessor {
}
}
self.sync_active_account_session()?;
self.account_sessions_store()
.revoke_all_and_clear()
.await
.map_err(|err| internal_error(format!("failed to clear account sessions: {err}")))?;
match self.auth_manager.logout_with_revoke().await {
Ok(_) => {}
Err(err) => {

View File

@@ -1,8 +1,10 @@
use crate::types::AccountsCheckResponse;
use crate::types::CodeTaskDetailsResponse;
use crate::types::ConfigFileResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
use crate::types::RateLimitStatusPayload;
use crate::types::SwitchWorkspaceTokenResponse;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use anyhow::Result;
use codex_api::SharedAuthProvider;
@@ -95,6 +97,11 @@ struct SendAddCreditsNudgeEmailRequest {
credit_type: AddCreditsNudgeCreditType,
}
#[derive(Serialize)]
struct SwitchWorkspaceTokenRequest<'a> {
workspace_id: &'a str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PathStyle {
/// /api/codex/…
@@ -301,6 +308,31 @@ impl Client {
Ok(Self::rate_limit_snapshots_from_payload(payload))
}
pub async fn get_accounts_check(&self) -> Result<AccountsCheckResponse> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/accounts/check", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/accounts/check", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
}
pub async fn switch_workspace_token(
&self,
workspace_id: &str,
) -> Result<SwitchWorkspaceTokenResponse> {
let url = format!("{}/accounts/switch-workspace-token", self.base_url);
let req = self
.http
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&SwitchWorkspaceTokenRequest { workspace_id });
let (body, ct) = self.exec_request(req, "POST", &url).await?;
self.decode_json(&url, &ct, &body)
}
pub async fn send_add_credits_nudge_email(
&self,
credit_type: AddCreditsNudgeCreditType,

View File

@@ -4,9 +4,12 @@ pub(crate) mod types;
pub use client::AddCreditsNudgeCreditType;
pub use client::Client;
pub use client::RequestError;
pub use types::AccountEntry;
pub use types::AccountsCheckResponse;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::ConfigFileResponse;
pub use types::PaginatedListTaskListItem;
pub use types::SwitchWorkspaceTokenResponse;
pub use types::TaskListItem;
pub use types::TurnAttemptsSiblingTurnsResponse;

View File

@@ -13,6 +13,100 @@ use serde::de::Deserializer;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct AccountsCheckResponse {
pub accounts: Vec<AccountEntry>,
pub account_ordering: Vec<String>,
pub default_account_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AccountEntry {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub profile_picture_url: Option<String>,
#[serde(default)]
pub structure: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SwitchWorkspaceTokenResponse {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
}
#[derive(Deserialize)]
struct RawAccountsCheckResponse {
#[serde(default)]
accounts: RawAccounts,
#[serde(default)]
account_ordering: Vec<String>,
#[serde(default)]
default_account_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawAccounts {
List(Vec<AccountEntry>),
Map(HashMap<String, ChatGptAccountEntry>),
}
impl Default for RawAccounts {
fn default() -> Self {
Self::List(Vec::new())
}
}
#[derive(Deserialize)]
struct ChatGptAccountEntry {
account: ChatGptAccountInfo,
}
#[derive(Deserialize)]
struct ChatGptAccountInfo {
account_id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
profile_picture_url: Option<String>,
#[serde(default)]
structure: String,
}
impl<'de> Deserialize<'de> for AccountsCheckResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = RawAccountsCheckResponse::deserialize(deserializer)?;
let accounts = match raw.accounts {
RawAccounts::List(accounts) => accounts,
RawAccounts::Map(mut accounts) => raw
.account_ordering
.iter()
.filter_map(|account_id| {
let account = accounts.remove(account_id)?.account;
Some(AccountEntry {
id: account.account_id?,
name: account.name,
profile_picture_url: account.profile_picture_url,
structure: account.structure,
})
})
.collect(),
};
Ok(Self {
accounts,
account_ordering: raw.account_ordering,
default_account_id: raw.default_account_id,
})
}
}
/// Hand-rolled models for the Cloud Tasks task-details response.
/// The generated OpenAPI models are pretty bad. This is a half-step
/// towards hand-rolling them.

View File

@@ -30,6 +30,7 @@ pub use crate::auth::agent_identity::AgentIdentityAuth;
pub use crate::auth::storage::AgentIdentityAuthRecord;
pub use crate::auth::storage::AuthDotJson;
use crate::auth::storage::AuthStorageBackend;
use crate::auth::storage::create_account_session_auth_storage;
use crate::auth::storage::create_auth_storage;
use crate::auth::util::try_parse_error_message;
use crate::default_client::build_reqwest_client;
@@ -240,6 +241,31 @@ impl CodexAuth {
}
}
pub async fn from_account_session_auth_dot_json(
codex_home: &Path,
session_id: &str,
auth_dot_json: AuthDotJson,
auth_credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: Option<&str>,
) -> std::io::Result<Self> {
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let mut auth = Self::from_auth_dot_json(
codex_home,
auth_dot_json,
auth_credentials_store_mode,
chatgpt_base_url,
)
.await?;
if let Self::Chatgpt(chatgpt_auth) = &mut auth {
chatgpt_auth.storage = create_account_session_auth_storage(
codex_home.to_path_buf(),
session_id,
storage_mode,
)?;
}
Ok(auth)
}
pub async fn from_auth_storage(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
@@ -607,6 +633,59 @@ pub fn load_auth_dot_json(
storage.load()
}
/// Persist a saved account session without replacing the active auth payload.
pub fn save_account_session_auth(
codex_home: &Path,
session_id: &str,
auth: &AuthDotJson,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let storage = create_account_session_auth_storage(
codex_home.to_path_buf(),
session_id,
auth_credentials_store_mode,
)?;
storage.save(auth)
}
/// Load a saved account session without changing the active auth payload.
pub fn load_account_session_auth(
codex_home: &Path,
session_id: &str,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<Option<AuthDotJson>> {
let storage = create_account_session_auth_storage(
codex_home.to_path_buf(),
session_id,
auth_credentials_store_mode,
)?;
storage.load()
}
/// Revoke a saved account session without changing the active auth payload.
pub async fn revoke_account_session_auth(
codex_home: &Path,
session_id: &str,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let auth = load_account_session_auth(codex_home, session_id, auth_credentials_store_mode)?;
revoke_auth_tokens(auth.as_ref()).await
}
/// Delete a saved account session without changing the active auth payload.
pub fn delete_account_session_auth(
codex_home: &Path,
session_id: &str,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<bool> {
let storage = create_account_session_auth_storage(
codex_home.to_path_buf(),
session_id,
auth_credentials_store_mode,
)?;
storage.delete()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthConfig {
pub codex_home: PathBuf,

View File

@@ -28,6 +28,10 @@ use codex_keyring_store::KeyringStore;
use codex_protocol::account::PlanType as AccountPlanType;
use once_cell::sync::Lazy;
mod account_session;
pub(super) use account_session::create_account_session_auth_storage;
/// Expected structure for $CODEX_HOME/auth.json.
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct AuthDotJson {
@@ -225,7 +229,6 @@ impl AuthStorageBackend for KeyringAuthStorage {
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
let key = compute_store_key(&self.codex_home)?;
// Simpler error mapping per style: prefer method reference over closure
let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?;
self.save_to_keyring(&key, &serialized)?;
if let Err(err) = delete_file_if_exists(&self.codex_home) {

View File

@@ -0,0 +1,285 @@
use super::AuthDotJson;
use super::AuthStorageBackend;
use super::EPHEMERAL_AUTH_STORE;
use super::KEYRING_SERVICE;
use super::compute_store_key;
use codex_config::types::AuthCredentialsStoreMode;
use codex_keyring_store::DefaultKeyringStore;
use codex_keyring_store::KeyringStore;
use std::collections::HashMap;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::warn;
pub(in crate::auth) fn create_account_session_auth_storage(
codex_home: PathBuf,
session_id: &str,
mode: AuthCredentialsStoreMode,
) -> std::io::Result<Arc<dyn AuthStorageBackend>> {
validate_session_id(session_id)?;
let keyring_store: Arc<dyn KeyringStore> = Arc::new(DefaultKeyringStore);
Ok(match mode {
AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(&codex_home, session_id)),
AuthCredentialsStoreMode::Keyring => Arc::new(KeyringAuthStorage::new(
&codex_home,
session_id,
keyring_store,
)?),
AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(
&codex_home,
session_id,
keyring_store,
)?),
AuthCredentialsStoreMode::Ephemeral => {
Arc::new(EphemeralAuthStorage::new(&codex_home, session_id)?)
}
})
}
fn validate_session_id(session_id: &str) -> std::io::Result<()> {
let mut components = Path::new(session_id).components();
if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() {
return Ok(());
}
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"account session ID must be a single path component",
))
}
fn auth_file(codex_home: &Path, session_id: &str) -> PathBuf {
codex_home
.join("account-sessions")
.join(format!("{session_id}.json"))
}
fn store_key(codex_home: &Path, session_id: &str) -> std::io::Result<String> {
Ok(format!(
"{}|account-session|{session_id}",
compute_store_key(codex_home)?
))
}
fn delete_file_if_exists(path: &Path) -> std::io::Result<bool> {
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
#[derive(Clone, Debug)]
struct FileAuthStorage {
auth_file: PathBuf,
}
impl FileAuthStorage {
fn new(codex_home: &Path, session_id: &str) -> Self {
Self {
auth_file: auth_file(codex_home, session_id),
}
}
}
impl AuthStorageBackend for FileAuthStorage {
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
let mut file = match File::open(&self.auth_file) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let mut contents = String::new();
file.read_to_string(&mut contents)?;
serde_json::from_str(&contents)
.map(Some)
.map_err(Into::into)
}
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
if let Some(parent) = self.auth_file.parent() {
std::fs::create_dir_all(parent)?;
}
let mut options = OpenOptions::new();
options.truncate(true).write(true).create(true);
#[cfg(unix)]
{
options.mode(0o600);
}
let mut file = options.open(&self.auth_file)?;
file.write_all(serde_json::to_string_pretty(auth)?.as_bytes())?;
file.flush()
}
fn delete(&self) -> std::io::Result<bool> {
delete_file_if_exists(&self.auth_file)
}
}
#[derive(Clone, Debug)]
struct KeyringAuthStorage {
auth_file: PathBuf,
store_key: String,
keyring_store: Arc<dyn KeyringStore>,
}
impl KeyringAuthStorage {
fn new(
codex_home: &Path,
session_id: &str,
keyring_store: Arc<dyn KeyringStore>,
) -> std::io::Result<Self> {
Ok(Self {
auth_file: auth_file(codex_home, session_id),
store_key: store_key(codex_home, session_id)?,
keyring_store,
})
}
}
impl AuthStorageBackend for KeyringAuthStorage {
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
match self.keyring_store.load(KEYRING_SERVICE, &self.store_key) {
Ok(Some(serialized)) => serde_json::from_str(&serialized).map(Some).map_err(|err| {
std::io::Error::other(format!(
"failed to deserialize CLI auth from keyring: {err}"
))
}),
Ok(None) => Ok(None),
Err(error) => Err(std::io::Error::other(format!(
"failed to load CLI auth from keyring: {}",
error.message()
))),
}
}
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?;
match self
.keyring_store
.save(KEYRING_SERVICE, &self.store_key, &serialized)
{
Ok(()) => {}
Err(error) => {
let message = format!(
"failed to write OAuth tokens to keyring: {}",
error.message()
);
warn!("{message}");
return Err(std::io::Error::other(message));
}
}
if let Err(err) = delete_file_if_exists(&self.auth_file) {
warn!("failed to remove CLI auth fallback file: {err}");
}
Ok(())
}
fn delete(&self) -> std::io::Result<bool> {
let keyring_removed = self
.keyring_store
.delete(KEYRING_SERVICE, &self.store_key)
.map_err(|err| {
std::io::Error::other(format!("failed to delete auth from keyring: {err}"))
});
let file_removed = delete_file_if_exists(&self.auth_file);
Ok(keyring_removed? || file_removed?)
}
}
#[derive(Clone, Debug)]
struct AutoAuthStorage {
keyring_storage: Arc<KeyringAuthStorage>,
file_storage: Arc<FileAuthStorage>,
}
impl AutoAuthStorage {
fn new(
codex_home: &Path,
session_id: &str,
keyring_store: Arc<dyn KeyringStore>,
) -> std::io::Result<Self> {
Ok(Self {
keyring_storage: Arc::new(KeyringAuthStorage::new(
codex_home,
session_id,
keyring_store,
)?),
file_storage: Arc::new(FileAuthStorage::new(codex_home, session_id)),
})
}
}
impl AuthStorageBackend for AutoAuthStorage {
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
match self.keyring_storage.load() {
Ok(Some(auth)) => Ok(Some(auth)),
Ok(None) => self.file_storage.load(),
Err(err) => {
warn!("failed to load CLI auth from keyring, falling back to file storage: {err}");
self.file_storage.load()
}
}
}
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
match self.keyring_storage.save(auth) {
Ok(()) => Ok(()),
Err(err) => {
warn!("failed to save auth to keyring, falling back to file storage: {err}");
self.file_storage.save(auth)
}
}
}
fn delete(&self) -> std::io::Result<bool> {
self.keyring_storage.delete()
}
}
#[derive(Clone, Debug)]
struct EphemeralAuthStorage {
store_key: String,
}
impl EphemeralAuthStorage {
fn new(codex_home: &Path, session_id: &str) -> std::io::Result<Self> {
Ok(Self {
store_key: store_key(codex_home, session_id)?,
})
}
fn with_store<F, T>(&self, action: F) -> std::io::Result<T>
where
F: FnOnce(&mut HashMap<String, AuthDotJson>, String) -> std::io::Result<T>,
{
let mut store = EPHEMERAL_AUTH_STORE
.lock()
.map_err(|_| std::io::Error::other("failed to lock ephemeral auth storage"))?;
action(&mut store, self.store_key.clone())
}
}
impl AuthStorageBackend for EphemeralAuthStorage {
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
self.with_store(|store, key| Ok(store.get(&key).cloned()))
}
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
self.with_store(|store, key| {
store.insert(key, auth.clone());
Ok(())
})
}
fn delete(&self) -> std::io::Result<bool> {
self.with_store(|store, key| Ok(store.remove(&key).is_some()))
}
}

View File

@@ -217,6 +217,7 @@ pub async fn complete_device_code_login(
tokens.access_token,
tokens.refresh_token,
opts.cli_auth_credentials_store_mode,
opts.revoke_previous_auth,
)
.await
}

View File

@@ -36,7 +36,9 @@ pub use auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR;
pub use auth::RefreshTokenError;
pub use auth::UnauthorizedRecovery;
pub use auth::default_client;
pub use auth::delete_account_session_auth;
pub use auth::enforce_login_restrictions;
pub use auth::load_account_session_auth;
pub use auth::load_auth_dot_json;
pub use auth::login_with_access_token;
pub use auth::login_with_api_key;
@@ -44,6 +46,8 @@ pub use auth::logout;
pub use auth::logout_with_revoke;
pub use auth::read_codex_access_token_from_env;
pub use auth::read_openai_api_key_from_env;
pub use auth::revoke_account_session_auth;
pub use auth::save_account_session_auth;
pub use auth::save_auth;
pub use auth_env_telemetry::AuthEnvTelemetry;
pub use auth_env_telemetry::collect_auth_env_telemetry;

View File

@@ -71,6 +71,7 @@ pub struct ServerOptions {
pub force_state: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
pub codex_streamlined_login: bool,
pub revoke_previous_auth: bool,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
}
@@ -91,6 +92,7 @@ impl ServerOptions {
force_state: None,
forced_chatgpt_workspace_id,
codex_streamlined_login: false,
revoke_previous_auth: true,
cli_auth_credentials_store_mode,
}
}
@@ -362,6 +364,7 @@ async fn process_request(
tokens.access_token.clone(),
tokens.refresh_token.clone(),
opts.cli_auth_credentials_store_mode,
opts.revoke_previous_auth,
)
.await
{
@@ -793,6 +796,7 @@ pub(crate) async fn persist_tokens_async(
access_token: String,
refresh_token: String,
auth_credentials_store_mode: AuthCredentialsStoreMode,
revoke_previous_auth: bool,
) -> io::Result<()> {
// Reuse existing synchronous logic but run it off the async runtime.
let codex_home = codex_home.to_path_buf();
@@ -829,7 +833,8 @@ pub(crate) async fn persist_tokens_async(
.await
.map_err(|e| io::Error::other(format!("persist task failed: {e}")))??;
if should_revoke_auth_tokens(previous_auth.as_ref(), &auth)
if revoke_previous_auth
&& should_revoke_auth_tokens(previous_auth.as_ref(), &auth)
&& let Err(err) = revoke_auth_tokens(previous_auth.as_ref()).await
{
warn!("failed to revoke superseded auth tokens after login: {err}");
@@ -1227,6 +1232,7 @@ mod tests {
"new-access".to_string(),
"new-refresh".to_string(),
AuthCredentialsStoreMode::File,
/*revoke_previous_auth*/ true,
)
.await?;
@@ -1287,6 +1293,7 @@ mod tests {
"new-access".to_string(),
"shared-refresh".to_string(),
AuthCredentialsStoreMode::File,
/*revoke_previous_auth*/ true,
)
.await?;

View File

@@ -128,6 +128,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
force_state: Some(state),
forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]),
codex_streamlined_login: false,
revoke_previous_auth: true,
};
let server = run_login_server(opts)?;
assert!(
@@ -190,6 +191,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> {
force_state: Some(state),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
revoke_previous_auth: true,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -233,6 +235,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result<
WORKSPACE_ID_SECOND_ALLOWED.to_string(),
]),
codex_streamlined_login: false,
revoke_previous_auth: true,
};
let server = run_login_server(opts)?;
let auth_url = Url::parse(&server.auth_url)?;
@@ -271,6 +274,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
force_state: Some(state.clone()),
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
codex_streamlined_login: false,
revoke_previous_auth: true,
};
let server = run_login_server(opts)?;
assert!(
@@ -331,6 +335,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error()
force_state: Some(state.clone()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
revoke_previous_auth: true,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -399,6 +404,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<
force_state: Some(state.clone()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
revoke_previous_auth: true,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -544,6 +550,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
force_state: Some("cancel_state".to_string()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
revoke_previous_auth: true,
};
let first_server = run_login_server(first_opts)?;
@@ -565,6 +572,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
force_state: Some("cancel_state_2".to_string()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
revoke_previous_auth: true,
};
let second_server = run_login_server(second_opts)?;