Merge remote-tracking branch 'origin/main' into dev/abhinav/permission-request-hook

# Conflicts:
#	codex-rs/core/src/guardian/review.rs
#	codex-rs/core/src/tools/orchestrator.rs
#	codex-rs/core/src/tools/runtimes/shell.rs
#	codex-rs/core/src/tools/runtimes/unified_exec.rs
This commit is contained in:
Abhinav Vedmala
2026-04-10 18:08:08 -07:00
127 changed files with 1607 additions and 3536 deletions
+1 -11
View File
@@ -1366,16 +1366,6 @@ dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "codex-account"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-backend-client",
"codex-login",
"thiserror 2.0.18",
]
[[package]]
name = "codex-analytics"
version = "0.0.0"
@@ -1908,7 +1898,6 @@ dependencies = [
"bm25",
"chrono",
"clap",
"codex-account",
"codex-analytics",
"codex-api",
"codex-app-server-protocol",
@@ -2942,6 +2931,7 @@ name = "codex-utils-absolute-path"
version = "0.0.0"
dependencies = [
"dirs",
"dunce",
"pretty_assertions",
"schemars 0.8.22",
"serde",
-2
View File
@@ -1,6 +1,5 @@
[workspace]
members = [
"account",
"analytics",
"backend-client",
"ansi-escape",
@@ -105,7 +104,6 @@ license = "Apache-2.0"
# Internal
app_test_support = { path = "app-server/tests/common" }
codex-analytics = { path = "analytics" }
codex-account = { path = "account" }
codex-ansi-escape = { path = "ansi-escape" }
codex-api = { path = "codex-api" }
codex-app-server = { path = "app-server" }
-6
View File
@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "account",
crate_name = "codex_account",
)
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "codex-account"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lib]
name = "codex_account"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-backend-client = { workspace = true }
codex-login = { workspace = true }
thiserror = { workspace = true }
-121
View File
@@ -1,121 +0,0 @@
use codex_backend_client::Client as BackendClient;
use codex_backend_client::RequestError;
use codex_backend_client::WorkspaceRole as BackendWorkspaceRole;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceRole {
AccountOwner,
AccountAdmin,
StandardUser,
}
impl WorkspaceRole {
fn is_workspace_owner(self) -> bool {
matches!(self, Self::AccountOwner | Self::AccountAdmin)
}
}
impl From<BackendWorkspaceRole> for WorkspaceRole {
fn from(value: BackendWorkspaceRole) -> Self {
match value {
BackendWorkspaceRole::AccountOwner => Self::AccountOwner,
BackendWorkspaceRole::AccountAdmin => Self::AccountAdmin,
BackendWorkspaceRole::StandardUser => Self::StandardUser,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WorkspaceOwnership {
pub workspace_role: Option<WorkspaceRole>,
pub is_workspace_owner: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddCreditsNudgeEmailStatus {
Sent,
CooldownActive,
}
#[derive(Debug, Error)]
pub enum SendAddCreditsNudgeEmailError {
#[error("codex account authentication required to notify workspace owner")]
AuthRequired,
#[error("chatgpt authentication required to notify workspace owner")]
ChatGptAuthRequired,
#[error("failed to construct backend client: {0}")]
CreateClient(#[from] anyhow::Error),
#[error("failed to notify workspace owner: {0}")]
Request(#[from] RequestError),
}
pub async fn send_add_credits_nudge_email(
chatgpt_base_url: impl Into<String>,
auth_manager: &AuthManager,
) -> Result<AddCreditsNudgeEmailStatus, SendAddCreditsNudgeEmailError> {
let auth = auth_manager
.auth()
.await
.ok_or(SendAddCreditsNudgeEmailError::AuthRequired)?;
send_add_credits_nudge_email_for_auth(chatgpt_base_url, &auth).await
}
pub async fn send_add_credits_nudge_email_for_auth(
chatgpt_base_url: impl Into<String>,
auth: &CodexAuth,
) -> Result<AddCreditsNudgeEmailStatus, SendAddCreditsNudgeEmailError> {
if !auth.is_chatgpt_auth() {
return Err(SendAddCreditsNudgeEmailError::ChatGptAuthRequired);
}
let client = BackendClient::from_auth(chatgpt_base_url, auth)?;
match client.send_add_credits_nudge_email().await {
Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent),
Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => {
Ok(AddCreditsNudgeEmailStatus::CooldownActive)
}
Err(err) => Err(err.into()),
}
}
pub async fn resolve_workspace_role_and_owner_for_auth(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> WorkspaceOwnership {
let token_is_workspace_owner = auth.and_then(CodexAuth::is_workspace_owner);
let Some(auth) = auth else {
return WorkspaceOwnership::default();
};
let workspace_role = fetch_current_workspace_role_for_auth(chatgpt_base_url, auth).await;
let is_workspace_owner = workspace_role
.map(WorkspaceRole::is_workspace_owner)
.or(token_is_workspace_owner);
WorkspaceOwnership {
workspace_role,
is_workspace_owner,
}
}
async fn fetch_current_workspace_role_for_auth(
chatgpt_base_url: &str,
auth: &CodexAuth,
) -> Option<WorkspaceRole> {
if !auth.is_chatgpt_auth() {
return None;
}
let client = BackendClient::from_auth(chatgpt_base_url.to_string(), auth).ok()?;
client
.get_current_workspace_role()
.await
.ok()
.flatten()
.map(WorkspaceRole::from)
}
+1 -1
View File
@@ -20,6 +20,7 @@ codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
os_info = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha1 = { workspace = true }
tokio = { workspace = true, features = [
"macros",
@@ -29,4 +30,3 @@ tracing = { workspace = true, features = ["log"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
+7
View File
@@ -1,4 +1,5 @@
use crate::events::AppServerRpcTransport;
use crate::events::GuardianReviewEventParams;
use crate::events::TrackEventRequest;
use crate::events::TrackEventsRequest;
use crate::events::current_runtime_metadata;
@@ -151,6 +152,12 @@ impl AnalyticsEventsClient {
));
}
pub fn track_guardian_review(&self, input: GuardianReviewEventParams) {
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(
Box::new(input),
)));
}
pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec<AppInvocation>) {
if mentions.is_empty() {
return;
+177
View File
@@ -6,6 +6,9 @@ use crate::facts::SubAgentThreadStartedInput;
use crate::facts::TrackEventsContext;
use codex_login::default_client::originator;
use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxPermissions;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use serde::Serialize;
@@ -36,6 +39,7 @@ pub(crate) struct TrackEventsRequest {
pub(crate) enum TrackEventRequest {
SkillInvocation(SkillInvocationEventRequest),
ThreadInitialized(ThreadInitializedEvent),
GuardianReview(Box<GuardianReviewEventRequest>),
AppMentioned(CodexAppMentionedEventRequest),
AppUsed(CodexAppUsedEventRequest),
Compaction(Box<CodexCompactionEventRequest>),
@@ -101,6 +105,179 @@ pub(crate) struct ThreadInitializedEvent {
pub(crate) event_params: ThreadInitializedEventParams,
}
#[derive(Serialize)]
pub(crate) struct GuardianReviewEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: GuardianReviewEventPayload,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GuardianReviewDecision {
Approved,
Denied,
Aborted,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GuardianReviewTerminalStatus {
Approved,
Denied,
Aborted,
TimedOut,
FailedClosed,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GuardianReviewFailureReason {
Timeout,
Cancelled,
PromptBuildError,
SessionError,
ParseError,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GuardianReviewSessionKind {
TrunkNew,
TrunkReused,
EphemeralForked,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GuardianReviewRiskLevel {
Low,
Medium,
High,
Critical,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GuardianReviewUserAuthorization {
Unknown,
Low,
Medium,
High,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GuardianReviewOutcome {
Allow,
Deny,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GuardianApprovalRequestSource {
/// Approval requested directly by the main Codex turn.
MainTurn,
/// Approval requested by a delegated subagent and routed through the parent
/// session for guardian review.
DelegatedSubagent,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GuardianReviewedAction {
Shell {
command: Vec<String>,
command_display: String,
cwd: String,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<PermissionProfile>,
justification: Option<String>,
},
UnifiedExec {
command: Vec<String>,
command_display: String,
cwd: String,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<PermissionProfile>,
justification: Option<String>,
tty: bool,
},
Execve {
source: GuardianCommandSource,
program: String,
argv: Vec<String>,
cwd: String,
additional_permissions: Option<PermissionProfile>,
},
ApplyPatch {
cwd: String,
files: Vec<String>,
},
NetworkAccess {
target: String,
host: String,
protocol: NetworkApprovalProtocol,
port: u16,
},
McpToolCall {
server: String,
tool_name: String,
connector_id: Option<String>,
connector_name: Option<String>,
tool_title: Option<String>,
},
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GuardianCommandSource {
Shell,
UnifiedExec,
}
#[derive(Clone, Serialize)]
pub struct GuardianReviewEventParams {
pub thread_id: String,
pub turn_id: String,
pub review_id: String,
pub target_item_id: String,
pub retry_reason: Option<String>,
pub approval_request_source: GuardianApprovalRequestSource,
pub reviewed_action: GuardianReviewedAction,
pub reviewed_action_truncated: bool,
pub decision: GuardianReviewDecision,
pub terminal_status: GuardianReviewTerminalStatus,
pub failure_reason: Option<GuardianReviewFailureReason>,
pub risk_level: Option<GuardianReviewRiskLevel>,
pub user_authorization: Option<GuardianReviewUserAuthorization>,
pub outcome: Option<GuardianReviewOutcome>,
pub rationale: Option<String>,
pub guardian_thread_id: Option<String>,
pub guardian_session_kind: Option<GuardianReviewSessionKind>,
pub guardian_model: Option<String>,
pub guardian_reasoning_effort: Option<String>,
pub had_prior_review_context: Option<bool>,
pub review_timeout_ms: u64,
pub tool_call_count: u64,
pub time_to_first_token_ms: Option<u64>,
pub completion_latency_ms: Option<u64>,
pub started_at: u64,
pub completed_at: Option<u64>,
pub input_tokens: Option<i64>,
pub cached_input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub reasoning_output_tokens: Option<i64>,
pub total_tokens: Option<i64>,
}
#[derive(Serialize)]
pub(crate) struct GuardianReviewEventPayload {
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
#[serde(flatten)]
pub(crate) guardian_review: GuardianReviewEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexAppMetadata {
pub(crate) connector_id: Option<String>,
+2
View File
@@ -1,5 +1,6 @@
use crate::events::AppServerRpcTransport;
use crate::events::CodexRuntimeMetadata;
use crate::events::GuardianReviewEventParams;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponse;
use codex_app_server_protocol::InitializeParams;
@@ -154,6 +155,7 @@ pub(crate) enum AnalyticsFact {
pub(crate) enum CustomAnalyticsFact {
SubAgentThreadStarted(SubAgentThreadStartedInput),
Compaction(Box<CodexCompactionEvent>),
GuardianReview(Box<GuardianReviewEventParams>),
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
AppUsed(AppUsedInput),
+11
View File
@@ -5,6 +5,17 @@ mod reducer;
pub use client::AnalyticsEventsClient;
pub use events::AppServerRpcTransport;
pub use events::GuardianApprovalRequestSource;
pub use events::GuardianCommandSource;
pub use events::GuardianReviewDecision;
pub use events::GuardianReviewEventParams;
pub use events::GuardianReviewFailureReason;
pub use events::GuardianReviewOutcome;
pub use events::GuardianReviewRiskLevel;
pub use events::GuardianReviewSessionKind;
pub use events::GuardianReviewTerminalStatus;
pub use events::GuardianReviewUserAuthorization;
pub use events::GuardianReviewedAction;
pub use facts::AppInvocation;
pub use facts::CodexCompactionEvent;
pub use facts::CompactionImplementation;
+42
View File
@@ -6,6 +6,9 @@ use crate::events::CodexCompactionEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexRuntimeMetadata;
use crate::events::GuardianReviewEventParams;
use crate::events::GuardianReviewEventPayload;
use crate::events::GuardianReviewEventRequest;
use crate::events::SkillInvocationEventParams;
use crate::events::SkillInvocationEventRequest;
use crate::events::ThreadInitializationMode;
@@ -120,6 +123,9 @@ impl AnalyticsReducer {
CustomAnalyticsFact::Compaction(input) => {
self.ingest_compaction(*input, out);
}
CustomAnalyticsFact::GuardianReview(input) => {
self.ingest_guardian_review(*input, out);
}
CustomAnalyticsFact::SkillInvoked(input) => {
self.ingest_skill_invoked(input, out).await;
}
@@ -174,6 +180,42 @@ impl AnalyticsReducer {
));
}
fn ingest_guardian_review(
&mut self,
input: GuardianReviewEventParams,
out: &mut Vec<TrackEventRequest>,
) {
let Some(connection_id) = self.thread_connections.get(&input.thread_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
review_id = %input.review_id,
"dropping guardian analytics event: missing thread connection metadata"
);
return;
};
let Some(connection_state) = self.connections.get(connection_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
review_id = %input.review_id,
connection_id,
"dropping guardian analytics event: missing connection metadata"
);
return;
};
out.push(TrackEventRequest::GuardianReview(Box::new(
GuardianReviewEventRequest {
event_type: "codex_guardian_review",
event_params: GuardianReviewEventPayload {
app_server_client: connection_state.app_server_client.clone(),
runtime: connection_state.runtime.clone(),
guardian_review: input,
},
},
)));
}
async fn ingest_skill_invoked(
&mut self,
input: SkillInvokedInput,
-8
View File
@@ -1287,8 +1287,6 @@ mod tests {
id: request.id,
result: serde_json::to_value(GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: false,
})
.expect("response should serialize"),
@@ -1397,8 +1395,6 @@ mod tests {
id: request.id,
result: serde_json::to_value(GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: false,
})
.expect("response should serialize"),
@@ -1452,8 +1448,6 @@ mod tests {
first_response,
GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: false,
}
);
@@ -1473,8 +1467,6 @@ mod tests {
AccountUpdatedNotification {
auth_mode: None,
plan_type: None,
workspace_role: None,
is_workspace_owner: None,
},
))
.expect("notification should serialize"),
@@ -2562,17 +2562,6 @@
],
"type": "object"
},
"ThreadAddCreditsNudgeEmailParams": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadArchiveParams": {
"properties": {
"threadId": {
@@ -3221,10 +3210,27 @@
"type": "null"
}
]
},
"sessionStartSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadStartSource"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"ThreadStartSource": {
"enum": [
"startup",
"clear"
],
"type": "string"
},
"ThreadUnarchiveParams": {
"properties": {
"threadId": {
@@ -3820,30 +3826,6 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/addCreditsNudgeEmail"
],
"title": "Thread/addCreditsNudgeEmailRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAddCreditsNudgeEmailParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/addCreditsNudgeEmailRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -51,12 +51,6 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -66,89 +60,10 @@
"type": "null"
}
]
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AddCreditsNudgeEmailNotification": {
"properties": {
"result": {
"$ref": "#/definitions/AddCreditsNudgeEmailResult"
},
"threadId": {
"type": "string"
}
},
"required": [
"result",
"threadId"
],
"type": "object"
},
"AddCreditsNudgeEmailResult": {
"oneOf": [
{
"properties": {
"status": {
"enum": [
"sent"
],
"title": "SentAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "SentAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"status": {
"enum": [
"cooldownActive"
],
"title": "CooldownActiveAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "CooldownActiveAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"message": {
"type": "string"
},
"status": {
"enum": [
"failed"
],
"type": "string"
}
},
"required": [
"message",
"status"
],
"type": "object"
}
]
},
"AgentMessageDeltaNotification": {
"properties": {
"delta": {
@@ -473,6 +388,13 @@
}
]
},
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
"agent"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -1674,17 +1596,28 @@
"type": "object"
},
"ItemGuardianApprovalReviewCompletedNotification": {
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",
"properties": {
"action": {
"$ref": "#/definitions/GuardianApprovalReviewAction"
},
"decisionSource": {
"$ref": "#/definitions/AutoReviewDecisionSource"
},
"review": {
"$ref": "#/definitions/GuardianApprovalReview"
},
"targetItemId": {
"reviewId": {
"description": "Stable identifier for this review.",
"type": "string"
},
"targetItemId": {
"description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.",
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -1694,15 +1627,16 @@
},
"required": [
"action",
"decisionSource",
"review",
"targetItemId",
"reviewId",
"threadId",
"turnId"
],
"type": "object"
},
"ItemGuardianApprovalReviewStartedNotification": {
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",
"properties": {
"action": {
"$ref": "#/definitions/GuardianApprovalReviewAction"
@@ -1710,9 +1644,17 @@
"review": {
"$ref": "#/definitions/GuardianApprovalReview"
},
"targetItemId": {
"reviewId": {
"description": "Stable identifier for this review.",
"type": "string"
},
"targetItemId": {
"description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.",
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -1723,7 +1665,7 @@
"required": [
"action",
"review",
"targetItemId",
"reviewId",
"threadId",
"turnId"
],
@@ -2131,16 +2073,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -2340,17 +2272,6 @@
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -4135,14 +4056,6 @@
"samplePaths"
],
"type": "object"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
}
},
"description": "Notification sent from the server to the client.",
@@ -4770,26 +4683,6 @@
"title": "Account/rateLimits/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"account/addCreditsNudgeEmail/completed"
],
"title": "Account/addCreditsNudgeEmail/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AddCreditsNudgeEmailNotification"
}
},
"required": [
"method",
"params"
],
"title": "Account/addCreditsNudgeEmail/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -482,30 +482,6 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/addCreditsNudgeEmail"
],
"title": "Thread/addCreditsNudgeEmailRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadAddCreditsNudgeEmailParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/addCreditsNudgeEmailRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4058,26 +4034,6 @@
"title": "Account/rateLimits/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"account/addCreditsNudgeEmail/completed"
],
"title": "Account/addCreditsNudgeEmail/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AddCreditsNudgeEmailNotification"
}
},
"required": [
"method",
"params"
],
"title": "Account/addCreditsNudgeEmail/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -4965,12 +4921,6 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -4980,92 +4930,11 @@
"type": "null"
}
]
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/v2/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",
"type": "object"
},
"AddCreditsNudgeEmailNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"result": {
"$ref": "#/definitions/v2/AddCreditsNudgeEmailResult"
},
"threadId": {
"type": "string"
}
},
"required": [
"result",
"threadId"
],
"title": "AddCreditsNudgeEmailNotification",
"type": "object"
},
"AddCreditsNudgeEmailResult": {
"oneOf": [
{
"properties": {
"status": {
"enum": [
"sent"
],
"title": "SentAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "SentAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"status": {
"enum": [
"cooldownActive"
],
"title": "CooldownActiveAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "CooldownActiveAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"message": {
"type": "string"
},
"status": {
"enum": [
"failed"
],
"type": "string"
}
},
"required": [
"message",
"status"
],
"type": "object"
}
]
},
"AgentMessageDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -5667,6 +5536,13 @@
}
]
},
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
"agent"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -8142,24 +8018,8 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"requiresOpenaiAuth": {
"type": "boolean"
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/v2/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -8720,17 +8580,28 @@
},
"ItemGuardianApprovalReviewCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",
"properties": {
"action": {
"$ref": "#/definitions/v2/GuardianApprovalReviewAction"
},
"decisionSource": {
"$ref": "#/definitions/v2/AutoReviewDecisionSource"
},
"review": {
"$ref": "#/definitions/v2/GuardianApprovalReview"
},
"targetItemId": {
"reviewId": {
"description": "Stable identifier for this review.",
"type": "string"
},
"targetItemId": {
"description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.",
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -8740,8 +8611,9 @@
},
"required": [
"action",
"decisionSource",
"review",
"targetItemId",
"reviewId",
"threadId",
"turnId"
],
@@ -8750,7 +8622,7 @@
},
"ItemGuardianApprovalReviewStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",
"properties": {
"action": {
"$ref": "#/definitions/v2/GuardianApprovalReviewAction"
@@ -8758,9 +8630,17 @@
"review": {
"$ref": "#/definitions/v2/GuardianApprovalReview"
},
"targetItemId": {
"reviewId": {
"description": "Stable identifier for this review.",
"type": "string"
},
"targetItemId": {
"description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.",
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -8771,7 +8651,7 @@
"required": [
"action",
"review",
"targetItemId",
"reviewId",
"threadId",
"turnId"
],
@@ -10544,16 +10424,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -12377,17 +12247,6 @@
"title": "SkillsListResponse",
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -12679,24 +12538,6 @@
],
"type": "string"
},
"ThreadAddCreditsNudgeEmailParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAddCreditsNudgeEmailParams",
"type": "object"
},
"ThreadAddCreditsNudgeEmailResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadAddCreditsNudgeEmailResponse",
"type": "object"
},
"ThreadArchiveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -14427,6 +14268,16 @@
"type": "null"
}
]
},
"sessionStartSource": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadStartSource"
},
{
"type": "null"
}
]
}
},
"title": "ThreadStartParams",
@@ -14494,6 +14345,13 @@
"title": "ThreadStartResponse",
"type": "object"
},
"ThreadStartSource": {
"enum": [
"startup",
"clear"
],
"type": "string"
},
"ThreadStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -15605,14 +15463,6 @@
"title": "WindowsWorldWritableWarningNotification",
"type": "object"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
},
"WriteStatus": {
"enum": [
"ok",
@@ -100,12 +100,6 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -115,92 +109,11 @@
"type": "null"
}
]
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",
"type": "object"
},
"AddCreditsNudgeEmailNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"result": {
"$ref": "#/definitions/AddCreditsNudgeEmailResult"
},
"threadId": {
"type": "string"
}
},
"required": [
"result",
"threadId"
],
"title": "AddCreditsNudgeEmailNotification",
"type": "object"
},
"AddCreditsNudgeEmailResult": {
"oneOf": [
{
"properties": {
"status": {
"enum": [
"sent"
],
"title": "SentAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "SentAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"status": {
"enum": [
"cooldownActive"
],
"title": "CooldownActiveAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "CooldownActiveAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"message": {
"type": "string"
},
"status": {
"enum": [
"failed"
],
"type": "string"
}
},
"required": [
"message",
"status"
],
"type": "object"
}
]
},
"AgentMessageDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -802,6 +715,13 @@
}
]
},
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
"agent"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -1144,30 +1064,6 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/addCreditsNudgeEmail"
],
"title": "Thread/addCreditsNudgeEmailRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAddCreditsNudgeEmailParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/addCreditsNudgeEmailRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4881,24 +4777,8 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"requiresOpenaiAuth": {
"type": "boolean"
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -5503,17 +5383,28 @@
},
"ItemGuardianApprovalReviewCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",
"properties": {
"action": {
"$ref": "#/definitions/GuardianApprovalReviewAction"
},
"decisionSource": {
"$ref": "#/definitions/AutoReviewDecisionSource"
},
"review": {
"$ref": "#/definitions/GuardianApprovalReview"
},
"targetItemId": {
"reviewId": {
"description": "Stable identifier for this review.",
"type": "string"
},
"targetItemId": {
"description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.",
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -5523,8 +5414,9 @@
},
"required": [
"action",
"decisionSource",
"review",
"targetItemId",
"reviewId",
"threadId",
"turnId"
],
@@ -5533,7 +5425,7 @@
},
"ItemGuardianApprovalReviewStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",
"properties": {
"action": {
"$ref": "#/definitions/GuardianApprovalReviewAction"
@@ -5541,9 +5433,17 @@
"review": {
"$ref": "#/definitions/GuardianApprovalReview"
},
"targetItemId": {
"reviewId": {
"description": "Stable identifier for this review.",
"type": "string"
},
"targetItemId": {
"description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.",
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -5554,7 +5454,7 @@
"required": [
"action",
"review",
"targetItemId",
"reviewId",
"threadId",
"turnId"
],
@@ -7327,16 +7227,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -9346,26 +9236,6 @@
"title": "Account/rateLimits/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"account/addCreditsNudgeEmail/completed"
],
"title": "Account/addCreditsNudgeEmail/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AddCreditsNudgeEmailNotification"
}
},
"required": [
"method",
"params"
],
"title": "Account/addCreditsNudgeEmail/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -10232,17 +10102,6 @@
"title": "SkillsListResponse",
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -10534,24 +10393,6 @@
],
"type": "string"
},
"ThreadAddCreditsNudgeEmailParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAddCreditsNudgeEmailParams",
"type": "object"
},
"ThreadAddCreditsNudgeEmailResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadAddCreditsNudgeEmailResponse",
"type": "object"
},
"ThreadArchiveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12282,6 +12123,16 @@
"type": "null"
}
]
},
"sessionStartSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadStartSource"
},
{
"type": "null"
}
]
}
},
"title": "ThreadStartParams",
@@ -12349,6 +12200,13 @@
"title": "ThreadStartResponse",
"type": "object"
},
"ThreadStartSource": {
"enum": [
"startup",
"clear"
],
"type": "string"
},
"ThreadStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -13460,14 +13318,6 @@
"title": "WindowsWorldWritableWarningNotification",
"type": "object"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
},
"WriteStatus": {
"enum": [
"ok",
@@ -91,16 +91,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -130,17 +120,6 @@
"usedPercent"
],
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
}
},
"properties": {
@@ -42,14 +42,6 @@
"unknown"
],
"type": "string"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
}
},
"properties": {
@@ -63,12 +55,6 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -78,16 +64,6 @@
"type": "null"
}
]
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",
@@ -1,73 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AddCreditsNudgeEmailResult": {
"oneOf": [
{
"properties": {
"status": {
"enum": [
"sent"
],
"title": "SentAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "SentAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"status": {
"enum": [
"cooldownActive"
],
"title": "CooldownActiveAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "CooldownActiveAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"message": {
"type": "string"
},
"status": {
"enum": [
"failed"
],
"type": "string"
}
},
"required": [
"message",
"status"
],
"type": "object"
}
]
}
},
"properties": {
"result": {
"$ref": "#/definitions/AddCreditsNudgeEmailResult"
},
"threadId": {
"type": "string"
}
},
"required": [
"result",
"threadId"
],
"title": "AddCreditsNudgeEmailNotification",
"type": "object"
}
@@ -91,16 +91,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -130,17 +120,6 @@
"usedPercent"
],
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
}
},
"properties": {
@@ -60,14 +60,6 @@
"unknown"
],
"type": "string"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
}
},
"properties": {
@@ -81,24 +73,8 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"requiresOpenaiAuth": {
"type": "boolean"
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -1,6 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.",
"enum": [
"agent"
],
"type": "string"
},
"GuardianApprovalReview": {
"description": "[UNSTABLE] Temporary guardian approval review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.",
"properties": {
@@ -256,17 +263,28 @@
"type": "string"
}
},
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",
"properties": {
"action": {
"$ref": "#/definitions/GuardianApprovalReviewAction"
},
"decisionSource": {
"$ref": "#/definitions/AutoReviewDecisionSource"
},
"review": {
"$ref": "#/definitions/GuardianApprovalReview"
},
"targetItemId": {
"reviewId": {
"description": "Stable identifier for this review.",
"type": "string"
},
"targetItemId": {
"description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.",
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -276,8 +294,9 @@
},
"required": [
"action",
"decisionSource",
"review",
"targetItemId",
"reviewId",
"threadId",
"turnId"
],
@@ -256,7 +256,7 @@
"type": "string"
}
},
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.",
"description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.",
"properties": {
"action": {
"$ref": "#/definitions/GuardianApprovalReviewAction"
@@ -264,9 +264,17 @@
"review": {
"$ref": "#/definitions/GuardianApprovalReview"
},
"targetItemId": {
"reviewId": {
"description": "Stable identifier for this review.",
"type": "string"
},
"targetItemId": {
"description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.",
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -277,7 +285,7 @@
"required": [
"action",
"review",
"targetItemId",
"reviewId",
"threadId",
"turnId"
],
@@ -1,13 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAddCreditsNudgeEmailParams",
"type": "object"
}
@@ -1,5 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadAddCreditsNudgeEmailResponse",
"type": "object"
}
@@ -101,6 +101,13 @@
"flex"
],
"type": "string"
},
"ThreadStartSource": {
"enum": [
"startup",
"clear"
],
"type": "string"
}
},
"properties": {
@@ -210,6 +217,16 @@
"type": "null"
}
]
},
"sessionStartSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadStartSource"
},
{
"type": "null"
}
]
}
},
"title": "ThreadStartParams",
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearc
import type { AccountLoginCompletedNotification } from "./v2/AccountLoginCompletedNotification";
import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimitsUpdatedNotification";
import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification";
import type { AddCreditsNudgeEmailNotification } from "./v2/AddCreditsNudgeEmailNotification";
import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification";
import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification";
import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification";
@@ -59,4 +58,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "account/addCreditsNudgeEmail/completed", "params": AddCreditsNudgeEmailNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
@@ -3,6 +3,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuthMode } from "../AuthMode";
import type { PlanType } from "../PlanType";
import type { WorkspaceRole } from "./WorkspaceRole";
export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, workspaceRole: WorkspaceRole | null, isWorkspaceOwner: boolean | null, };
export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, };
@@ -1,6 +0,0 @@
// 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 { AddCreditsNudgeEmailResult } from "./AddCreditsNudgeEmailResult";
export type AddCreditsNudgeEmailNotification = { threadId: string, result: AddCreditsNudgeEmailResult, };
@@ -1,5 +0,0 @@
// 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 AddCreditsNudgeEmailResult = { "status": "sent" } | { "status": "cooldownActive" } | { "status": "failed", message: string, };
@@ -2,4 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadAddCreditsNudgeEmailResponse = Record<string, never>;
/**
* [UNSTABLE] Source that produced a terminal guardian approval review decision.
*/
export type AutoReviewDecisionSource = "agent";
@@ -2,6 +2,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Account } from "./Account";
import type { WorkspaceRole } from "./WorkspaceRole";
export type GetAccountResponse = { account: Account | null, workspaceRole: WorkspaceRole | null, isWorkspaceOwner: boolean | null, requiresOpenaiAuth: boolean, };
export type GetAccountResponse = { account: Account | null, requiresOpenaiAuth: boolean, };
@@ -1,15 +1,30 @@
// 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 { AutoReviewDecisionSource } from "./AutoReviewDecisionSource";
import type { GuardianApprovalReview } from "./GuardianApprovalReview";
import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction";
/**
* [UNSTABLE] Temporary notification payload for guardian automatic approval
* review. This shape is expected to change soon.
*
* TODO(ccunningham): Attach guardian review state to the reviewed tool item's
* lifecycle instead of sending separate standalone review notifications so the
* app-server API can persist and replay review state via `thread/read`.
*/
export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string, targetItemId: string, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, };
export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string,
/**
* Stable identifier for this review.
*/
reviewId: string,
/**
* Identifier for the reviewed item or tool call when one exists.
*
* In most cases, one review maps to one target item. The exceptions are
* - execve reviews, where a single command may contain multiple execve
* calls to review (only possible when using the shell_zsh_fork feature)
* - network policy reviews, where there is no target item
*
* A network call is triggered by a CommandExecution item, so having a
* target_item_id set to the CommandExecution item would be misleading
* because the review is about the network call, not the command execution.
* Therefore, target_item_id is set to None for network policy reviews.
*/
targetItemId: string | null, decisionSource: AutoReviewDecisionSource, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, };
@@ -7,9 +7,23 @@ import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewActio
/**
* [UNSTABLE] Temporary notification payload for guardian automatic approval
* review. This shape is expected to change soon.
*
* TODO(ccunningham): Attach guardian review state to the reviewed tool item's
* lifecycle instead of sending separate standalone review notifications so the
* app-server API can persist and replay review state via `thread/read`.
*/
export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string, targetItemId: string, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, };
export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string,
/**
* Stable identifier for this review.
*/
reviewId: string,
/**
* Identifier for the reviewed item or tool call when one exists.
*
* In most cases, one review maps to one target item. The exceptions are
* - execve reviews, where a single command may contain multiple execve
* calls to review (only possible when using the shell_zsh_fork feature)
* - network policy reviews, where there is no target item
*
* A network call is triggered by a CommandExecution item, so having a
* target_item_id set to the CommandExecution item would be misleading
* because the review is about the network call, not the command execution.
* Therefore, target_item_id is set to None for network policy reviews.
*/
targetItemId: string | null, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, };
@@ -4,6 +4,5 @@
import type { PlanType } from "../PlanType";
import type { CreditsSnapshot } from "./CreditsSnapshot";
import type { RateLimitWindow } from "./RateLimitWindow";
import type { SpendControlSnapshot } from "./SpendControlSnapshot";
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, spendControl: SpendControlSnapshot | null, planType: PlanType | null, };
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, };
@@ -1,5 +0,0 @@
// 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 ThreadAddCreditsNudgeEmailParams = { threadId: string, };
@@ -7,12 +7,13 @@ import type { JsonValue } from "../serde_json/JsonValue";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
import type { ThreadStartSource } from "./ThreadStartSource";
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /**
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /**
* If true, opt into emitting raw Responses API items on the event stream.
* This is for internal use only (e.g. Codex Cloud).
*/
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SpendControlSnapshot = { reached: boolean, };
export type ThreadStartSource = "startup" | "clear";
@@ -1,5 +0,0 @@
// 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 WorkspaceRole = "account-owner" | "account-admin" | "standard-user";
@@ -4,8 +4,6 @@ export type { Account } from "./Account";
export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification";
export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification";
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { AddCreditsNudgeEmailNotification } from "./AddCreditsNudgeEmailNotification";
export type { AddCreditsNudgeEmailResult } from "./AddCreditsNudgeEmailResult";
export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions";
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
@@ -26,6 +24,7 @@ export type { AppsDefaultConfig } from "./AppsDefaultConfig";
export type { AppsListParams } from "./AppsListParams";
export type { AppsListResponse } from "./AppsListResponse";
export type { AskForApproval } from "./AskForApproval";
export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource";
export type { ByteRange } from "./ByteRange";
export type { CancelLoginAccountParams } from "./CancelLoginAccountParams";
export type { CancelLoginAccountResponse } from "./CancelLoginAccountResponse";
@@ -267,15 +266,12 @@ export type { SkillsListEntry } from "./SkillsListEntry";
export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd";
export type { SkillsListParams } from "./SkillsListParams";
export type { SkillsListResponse } from "./SkillsListResponse";
export type { SpendControlSnapshot } from "./SpendControlSnapshot";
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
export type { TextElement } from "./TextElement";
export type { TextPosition } from "./TextPosition";
export type { TextRange } from "./TextRange";
export type { Thread } from "./Thread";
export type { ThreadActiveFlag } from "./ThreadActiveFlag";
export type { ThreadAddCreditsNudgeEmailParams } from "./ThreadAddCreditsNudgeEmailParams";
export type { ThreadAddCreditsNudgeEmailResponse } from "./ThreadAddCreditsNudgeEmailResponse";
export type { ThreadArchiveParams } from "./ThreadArchiveParams";
export type { ThreadArchiveResponse } from "./ThreadArchiveResponse";
export type { ThreadArchivedNotification } from "./ThreadArchivedNotification";
@@ -316,6 +312,7 @@ export type { ThreadSortKey } from "./ThreadSortKey";
export type { ThreadSourceKind } from "./ThreadSourceKind";
export type { ThreadStartParams } from "./ThreadStartParams";
export type { ThreadStartResponse } from "./ThreadStartResponse";
export type { ThreadStartSource } from "./ThreadStartSource";
export type { ThreadStartedNotification } from "./ThreadStartedNotification";
export type { ThreadStatus } from "./ThreadStatus";
export type { ThreadStatusChangedNotification } from "./ThreadStatusChangedNotification";
@@ -356,5 +353,4 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams";
export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse";
export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification";
export type { WorkspaceRole } from "./WorkspaceRole";
export type { WriteStatus } from "./WriteStatus";
@@ -296,10 +296,6 @@ client_request_definitions! {
params: v2::ThreadShellCommandParams,
response: v2::ThreadShellCommandResponse,
},
ThreadAddCreditsNudgeEmail => "thread/addCreditsNudgeEmail" {
params: v2::ThreadAddCreditsNudgeEmailParams,
response: v2::ThreadAddCreditsNudgeEmailResponse,
},
#[experimental("thread/backgroundTerminals/clean")]
ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" {
params: v2::ThreadBackgroundTerminalsCleanParams,
@@ -995,7 +991,6 @@ server_notification_definitions! {
McpServerStatusUpdated => "mcpServer/startupStatus/updated" (v2::McpServerStatusUpdatedNotification),
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
AddCreditsNudgeEmailCompleted => "account/addCreditsNudgeEmail/completed" (v2::AddCreditsNudgeEmailNotification),
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
FsChanged => "fs/changed" (v2::FsChangedNotification),
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
@@ -11,6 +11,7 @@
//! - The projection is presentation-specific. Core protocol events stay generic, while the
//! app-server protocol decides how to surface those events as `ThreadItem`s for clients.
use crate::protocol::common::ServerNotification;
use crate::protocol::v2::AutoReviewDecisionSource;
use crate::protocol::v2::CommandAction;
use crate::protocol::v2::CommandExecutionSource;
use crate::protocol::v2::CommandExecutionStatus;
@@ -142,12 +143,13 @@ pub fn build_item_from_guardian_event(
) -> Option<ThreadItem> {
match &assessment.action {
GuardianAssessmentAction::Command { command, cwd, .. } => {
let id = assessment.target_item_id.as_ref()?;
let command = command.clone();
let command_actions = vec![CommandAction::Unknown {
command: command.clone(),
}];
Some(ThreadItem::CommandExecution {
id: assessment.id.clone(),
id: id.clone(),
command,
cwd: cwd.clone(),
process_id: None,
@@ -162,6 +164,7 @@ pub fn build_item_from_guardian_event(
GuardianAssessmentAction::Execve {
program, argv, cwd, ..
} => {
let id = assessment.target_item_id.as_ref()?;
let argv = if argv.is_empty() {
vec![program.clone()]
} else {
@@ -179,7 +182,7 @@ pub fn build_item_from_guardian_event(
parsed_cmd.into_iter().map(CommandAction::from).collect()
};
Some(ThreadItem::CommandExecution {
id: assessment.id.clone(),
id: id.clone(),
command,
cwd: cwd.clone(),
process_id: None,
@@ -202,9 +205,6 @@ pub fn guardian_auto_approval_review_notification(
event_turn_id: &str,
assessment: &GuardianAssessmentEvent,
) -> ServerNotification {
// TODO(ccunningham): Attach guardian review state to the reviewed tool
// item's lifecycle instead of sending standalone review notifications so
// the app-server API can persist and replay review state via `thread/read`.
let turn_id = if assessment.turn_id.is_empty() {
event_turn_id.to_string()
} else {
@@ -236,7 +236,8 @@ pub fn guardian_auto_approval_review_notification(
ItemGuardianApprovalReviewStartedNotification {
thread_id: conversation_id.to_string(),
turn_id,
target_item_id: assessment.id.clone(),
review_id: assessment.id.clone(),
target_item_id: assessment.target_item_id.clone(),
review,
action,
},
@@ -249,7 +250,12 @@ pub fn guardian_auto_approval_review_notification(
ItemGuardianApprovalReviewCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id,
target_item_id: assessment.id.clone(),
review_id: assessment.id.clone(),
target_item_id: assessment.target_item_id.clone(),
decision_source: assessment
.decision_source
.map(AutoReviewDecisionSource::from)
.unwrap_or(AutoReviewDecisionSource::Agent),
review,
action,
},
@@ -2088,12 +2088,14 @@ mod tests {
local_images: Vec::new(),
}),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-exec".into(),
id: "review-guardian-exec".into(),
target_item_id: Some("guardian-exec".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: serde_json::from_value(serde_json::json!({
"type": "command",
"source": "shell",
@@ -2103,12 +2105,16 @@ mod tests {
.expect("guardian action"),
}),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-exec".into(),
id: "review-guardian-exec".into(),
target_item_id: Some("guardian-exec".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::Denied,
risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High),
user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low),
rationale: Some("Would delete user data.".into()),
decision_source: Some(
codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent,
),
action: serde_json::from_value(serde_json::json!({
"type": "command",
"source": "shell",
@@ -2161,12 +2167,14 @@ mod tests {
local_images: Vec::new(),
}),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-execve".into(),
id: "review-guardian-execve".into(),
target_item_id: Some("guardian-execve".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: serde_json::from_value(serde_json::json!({
"type": "execve",
"source": "shell",
+56 -76
View File
@@ -9,6 +9,7 @@ use codex_protocol::account::PlanType;
use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction;
use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource;
use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource;
use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol;
@@ -82,7 +83,6 @@ use codex_protocol::protocol::SkillInterface as CoreSkillInterface;
use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata;
use codex_protocol::protocol::SkillScope as CoreSkillScope;
use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency;
use codex_protocol::protocol::SpendControlSnapshot as CoreSpendControlSnapshot;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
@@ -413,6 +413,14 @@ v2_enum_from_core!(
}
);
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadStartSource {
Startup,
Clear,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1739,27 +1747,11 @@ pub struct GetAccountParams {
pub refresh_token: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum WorkspaceRole {
#[serde(rename = "account-owner")]
#[ts(rename = "account-owner")]
AccountOwner,
#[serde(rename = "account-admin")]
#[ts(rename = "account-admin")]
AccountAdmin,
#[serde(rename = "standard-user")]
#[ts(rename = "standard-user")]
StandardUser,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountResponse {
pub account: Option<Account>,
pub workspace_role: Option<WorkspaceRole>,
pub is_workspace_owner: Option<bool>,
pub requires_openai_auth: bool,
}
@@ -2633,6 +2625,8 @@ pub struct ThreadStartParams {
pub personality: Option<Personality>,
#[ts(optional = nullable)]
pub ephemeral: Option<bool>,
#[ts(optional = nullable)]
pub session_start_source: Option<ThreadStartSource>,
#[experimental("thread/start.dynamicTools")]
#[ts(optional = nullable)]
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
@@ -3036,18 +3030,6 @@ pub struct ThreadShellCommandParams {
#[ts(export_to = "v2/")]
pub struct ThreadShellCommandResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAddCreditsNudgeEmailParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAddCreditsNudgeEmailResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3685,8 +3667,6 @@ pub struct Thread {
pub struct AccountUpdatedNotification {
pub auth_mode: Option<AuthMode>,
pub plan_type: Option<PlanType>,
pub workspace_role: Option<WorkspaceRole>,
pub is_workspace_owner: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -4555,6 +4535,22 @@ pub enum GuardianApprovalReviewStatus {
Aborted,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// [UNSTABLE] Source that produced a terminal guardian approval review decision.
pub enum AutoReviewDecisionSource {
Agent,
}
impl From<CoreGuardianAssessmentDecisionSource> for AutoReviewDecisionSource {
fn from(value: CoreGuardianAssessmentDecisionSource) -> Self {
match value {
CoreGuardianAssessmentDecisionSource::Agent => Self::Agent,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
@@ -5311,14 +5307,23 @@ pub struct ItemStartedNotification {
#[ts(export_to = "v2/")]
/// [UNSTABLE] Temporary notification payload for guardian automatic approval
/// review. This shape is expected to change soon.
///
/// TODO(ccunningham): Attach guardian review state to the reviewed tool item's
/// lifecycle instead of sending separate standalone review notifications so the
/// app-server API can persist and replay review state via `thread/read`.
pub struct ItemGuardianApprovalReviewStartedNotification {
pub thread_id: String,
pub turn_id: String,
pub target_item_id: String,
/// Stable identifier for this review.
pub review_id: String,
/// Identifier for the reviewed item or tool call when one exists.
///
/// In most cases, one review maps to one target item. The exceptions are
/// - execve reviews, where a single command may contain multiple execve
/// calls to review (only possible when using the shell_zsh_fork feature)
/// - network policy reviews, where there is no target item
///
/// A network call is triggered by a CommandExecution item, so having a
/// target_item_id set to the CommandExecution item would be misleading
/// because the review is about the network call, not the command execution.
/// Therefore, target_item_id is set to None for network policy reviews.
pub target_item_id: Option<String>,
pub review: GuardianApprovalReview,
pub action: GuardianApprovalReviewAction,
}
@@ -5328,14 +5333,24 @@ pub struct ItemGuardianApprovalReviewStartedNotification {
#[ts(export_to = "v2/")]
/// [UNSTABLE] Temporary notification payload for guardian automatic approval
/// review. This shape is expected to change soon.
///
/// TODO(ccunningham): Attach guardian review state to the reviewed tool item's
/// lifecycle instead of sending separate standalone review notifications so the
/// app-server API can persist and replay review state via `thread/read`.
pub struct ItemGuardianApprovalReviewCompletedNotification {
pub thread_id: String,
pub turn_id: String,
pub target_item_id: String,
/// Stable identifier for this review.
pub review_id: String,
/// Identifier for the reviewed item or tool call when one exists.
///
/// In most cases, one review maps to one target item. The exceptions are
/// - execve reviews, where a single command may contain multiple execve
/// calls to review (only possible when using the shell_zsh_fork feature)
/// - network policy reviews, where there is no target item
///
/// A network call is triggered by a CommandExecution item, so having a
/// target_item_id set to the CommandExecution item would be misleading
/// because the review is about the network call, not the command execution.
/// Therefore, target_item_id is set to None for network policy reviews.
pub target_item_id: Option<String>,
pub decision_source: AutoReviewDecisionSource,
pub review: GuardianApprovalReview,
pub action: GuardianApprovalReviewAction,
}
@@ -6258,24 +6273,6 @@ pub struct AccountRateLimitsUpdatedNotification {
pub rate_limits: RateLimitSnapshot,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AddCreditsNudgeEmailNotification {
pub thread_id: String,
pub result: AddCreditsNudgeEmailResult,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "status", rename_all = "camelCase")]
#[ts(tag = "status", rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum AddCreditsNudgeEmailResult {
Sent,
CooldownActive,
Failed { message: String },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6285,7 +6282,6 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub spend_control: Option<SpendControlSnapshot>,
pub plan_type: Option<PlanType>,
}
@@ -6297,7 +6293,6 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
primary: value.primary.map(RateLimitWindow::from),
secondary: value.secondary.map(RateLimitWindow::from),
credits: value.credits.map(CreditsSnapshot::from),
spend_control: value.spend_control.map(SpendControlSnapshot::from),
plan_type: value.plan_type,
}
}
@@ -6343,21 +6338,6 @@ impl From<CoreCreditsSnapshot> for CreditsSnapshot {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SpendControlSnapshot {
pub reached: bool,
}
impl From<CoreSpendControlSnapshot> for SpendControlSnapshot {
fn from(value: CoreSpendControlSnapshot) -> Self {
Self {
reached: value.reached,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+13 -25
View File
@@ -133,7 +133,7 @@ Example with notification opt-out:
## API Overview
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`.
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
@@ -147,7 +147,6 @@ Example with notification opt-out:
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
- `thread/addCreditsNudgeEmail` — ask the backend to notify the workspace owner that the thread hit a usage limit; returns `{}` immediately and emits `account/addCreditsNudgeEmail/completed` with the result.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
@@ -213,6 +212,7 @@ Start a fresh thread when you need a new Codex conversation.
"sandbox": "workspaceWrite",
"personality": "friendly",
"serviceName": "my_app_server_client", // optional metrics tag (`service_name`)
"sessionStartSource": "startup", // optional: "startup" (default) or "clear"
// Experimental: requires opt-in
"dynamicTools": [
{
@@ -457,15 +457,6 @@ If the thread does not already have an active turn, the server starts a standalo
{ "id": 26, "result": {} }
```
### Example: Notify Workspace Owner About Usage Limit
Use `thread/addCreditsNudgeEmail` when a usage-limit prompt needs to notify the thread's workspace owner. The request returns immediately with `{}`. The final delivery result is emitted separately as `account/addCreditsNudgeEmail/completed` for the same `threadId`.
```json
{ "method": "thread/addCreditsNudgeEmail", "id": 27, "params": { "threadId": "thr_b" } }
{ "id": 27, "result": {} }
```
### Example: Start a turn (send user input)
Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:
@@ -1366,19 +1357,19 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
### Authentication modes
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, the current `workspaceRole` when live account metadata can be fetched, and the derived `isWorkspaceOwner` flag. The same cached account state can be read from `account/read`.
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`.
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically.
### API Overview
- `account/read` — fetch cached current account info; optionally refresh tokens. ChatGPT workspace role is refreshed in the background and delivered by `account/updated` so this request does not wait for live account metadata.
- `account/read` — fetch current account info; optionally refresh tokens.
- `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`.
- `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`, `workspaceRole`, and `isWorkspaceOwner`.
- `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).
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
@@ -1395,19 +1386,16 @@ Request:
Response examples:
```json
{ "id": 1, "result": { "account": null, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
{ "id": 1, "result": { "account": null, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
{ "id": 1, "result": { "account": { "type": "apiKey" }, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": true } }
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "workspaceRole": null, "isWorkspaceOwner": true, "requiresOpenaiAuth": true } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "pro", "workspaceRole": "account-admin", "isWorkspaceOwner": true } } // emitted when live workspace metadata arrives
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } }
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } }
```
Field notes:
- `refreshToken` (bool): set `true` to force a token refresh.
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
- `workspaceRole` is a nullable live account role from ChatGPT account metadata: `account-owner`, `account-admin`, or `standard-user`. It is normally delivered asynchronously in `account/updated`; clients should treat `null` from `account/read` as "not known yet".
- `isWorkspaceOwner` is a nullable convenience flag. When `workspaceRole` is available, owners and admins map to `true` and standard users map to `false`; otherwise the server may fall back to the managed-account token's workspace-owner claim.
### 2) Log in with an API key
@@ -1426,7 +1414,7 @@ Field notes:
3. Notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "apikey", "planType": null, "workspaceRole": null, "isWorkspaceOwner": null } }
{ "method": "account/updated", "params": { "authMode": "apikey", "planType": null } }
```
### 3) Log in with ChatGPT (browser flow)
@@ -1440,7 +1428,7 @@ Field notes:
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus", "workspaceRole": "account-owner", "isWorkspaceOwner": true } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
```
### 4) Log in with ChatGPT (device code flow)
@@ -1454,7 +1442,7 @@ Field notes:
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus", "workspaceRole": "account-owner", "isWorkspaceOwner": true } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
```
### 5) Cancel a ChatGPT login
@@ -1469,7 +1457,7 @@ Field notes:
```json
{ "method": "account/logout", "id": 6 }
{ "id": 6, "result": {} }
{ "method": "account/updated", "params": { "authMode": null, "planType": null, "workspaceRole": null, "isWorkspaceOwner": null } }
{ "method": "account/updated", "params": { "authMode": null, "planType": null } }
```
### 7) Rate limits (ChatGPT)
@@ -13,8 +13,6 @@ use crate::thread_state::resolve_server_request_on_thread_listener;
use crate::thread_status::ThreadWatchActiveGuard;
use crate::thread_status::ThreadWatchManager;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailResult;
use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::ApplyPatchApprovalParams;
@@ -119,7 +117,6 @@ use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynam
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::AddCreditsNudgeEmailStatus as CoreAddCreditsNudgeEmailStatus;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
@@ -226,26 +223,6 @@ pub(crate) async fn apply_bespoke_event_handling(
)
.await;
}
EventMsg::AddCreditsNudgeEmailResponse(event) => {
if let ApiVersion::V2 = api_version {
let result = match event.result {
Ok(CoreAddCreditsNudgeEmailStatus::Sent) => AddCreditsNudgeEmailResult::Sent,
Ok(CoreAddCreditsNudgeEmailStatus::CooldownActive) => {
AddCreditsNudgeEmailResult::CooldownActive
}
Err(message) => AddCreditsNudgeEmailResult::Failed { message },
};
let notification = AddCreditsNudgeEmailNotification {
thread_id: conversation_id.to_string(),
result,
};
outgoing
.send_server_notification(ServerNotification::AddCreditsNudgeEmailCompleted(
notification,
))
.await;
}
}
EventMsg::SkillsUpdateAvailable => {
if let ApiVersion::V2 = api_version {
outgoing
@@ -291,15 +268,19 @@ pub(crate) async fn apply_bespoke_event_handling(
CommandExecutionStatus::InProgress,
) {
Some(ThreadItem::CommandExecution {
id,
command,
cwd,
command_actions,
..
}) => Some(CommandExecutionCompletionItem {
command,
cwd,
command_actions,
}),
}) => Some((
id,
CommandExecutionCompletionItem {
command,
cwd,
command_actions,
},
)),
Some(_) | None => None,
};
let assessment_turn_id = if assessment.turn_id.is_empty() {
@@ -309,12 +290,13 @@ pub(crate) async fn apply_bespoke_event_handling(
};
if assessment.status
== codex_protocol::protocol::GuardianAssessmentStatus::InProgress
&& let Some(completion_item) = pending_command_execution.as_ref()
&& let Some((target_item_id, completion_item)) =
pending_command_execution.as_ref()
{
start_command_execution_item(
&conversation_id,
assessment_turn_id.clone(),
assessment.id.clone(),
target_item_id.clone(),
completion_item.command.clone(),
completion_item.cwd.clone(),
completion_item.command_actions.clone(),
@@ -334,12 +316,12 @@ pub(crate) async fn apply_bespoke_event_handling(
assessment.status,
codex_protocol::protocol::GuardianAssessmentStatus::Denied
| codex_protocol::protocol::GuardianAssessmentStatus::Aborted
) && let Some(completion_item) = pending_command_execution
) && let Some((target_item_id, completion_item)) = pending_command_execution
{
complete_command_execution_item(
&conversation_id,
assessment_turn_id,
assessment.id.clone(),
target_item_id,
completion_item.command,
completion_item.cwd,
/*process_id*/ None,
@@ -2919,6 +2901,7 @@ mod tests {
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use codex_app_server_protocol::AutoReviewDecisionSource;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::TurnPlanStepStatus;
@@ -3020,12 +3003,18 @@ mod tests {
GuardianAssessmentStatus::Aborted => (None, None, None),
};
GuardianAssessmentEvent {
id: id.to_string(),
id: format!("review-{id}"),
target_item_id: Some(id.to_string()),
turn_id: turn_id.to_string(),
status,
risk_level,
user_authorization,
rationale,
decision_source: if matches!(status, GuardianAssessmentStatus::InProgress) {
None
} else {
Some(codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent)
},
action: serde_json::from_value(json!({
"type": "command",
"source": "shell",
@@ -3080,12 +3069,14 @@ mod tests {
&conversation_id,
"turn-from-event",
&GuardianAssessmentEvent {
id: "item-1".to_string(),
id: "review-1".to_string(),
target_item_id: Some("item-1".to_string()),
turn_id: String::new(),
status: codex_protocol::protocol::GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: action.clone(),
},
);
@@ -3094,7 +3085,8 @@ mod tests {
ServerNotification::ItemGuardianApprovalReviewStarted(payload) => {
assert_eq!(payload.thread_id, conversation_id.to_string());
assert_eq!(payload.turn_id, "turn-from-event");
assert_eq!(payload.target_item_id, "item-1");
assert_eq!(payload.review_id, "review-1");
assert_eq!(payload.target_item_id.as_deref(), Some("item-1"));
assert_eq!(
payload.review.status,
GuardianApprovalReviewStatus::InProgress
@@ -3120,12 +3112,16 @@ mod tests {
&conversation_id,
"turn-from-event",
&GuardianAssessmentEvent {
id: "item-2".to_string(),
id: "review-2".to_string(),
target_item_id: Some("item-2".to_string()),
turn_id: "turn-from-assessment".to_string(),
status: codex_protocol::protocol::GuardianAssessmentStatus::Denied,
risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High),
user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low),
rationale: Some("too risky".to_string()),
decision_source: Some(
codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent,
),
action: action.clone(),
},
);
@@ -3134,7 +3130,9 @@ mod tests {
ServerNotification::ItemGuardianApprovalReviewCompleted(payload) => {
assert_eq!(payload.thread_id, conversation_id.to_string());
assert_eq!(payload.turn_id, "turn-from-assessment");
assert_eq!(payload.target_item_id, "item-2");
assert_eq!(payload.review_id, "review-2");
assert_eq!(payload.target_item_id.as_deref(), Some("item-2"));
assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent);
assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Denied);
assert_eq!(
payload.review.risk_level,
@@ -3164,12 +3162,16 @@ mod tests {
&conversation_id,
"turn-from-event",
&GuardianAssessmentEvent {
id: "item-3".to_string(),
id: "review-3".to_string(),
target_item_id: None,
turn_id: "turn-from-assessment".to_string(),
status: codex_protocol::protocol::GuardianAssessmentStatus::Aborted,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: Some(
codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent,
),
action: action.clone(),
},
);
@@ -3178,7 +3180,9 @@ mod tests {
ServerNotification::ItemGuardianApprovalReviewCompleted(payload) => {
assert_eq!(payload.thread_id, conversation_id.to_string());
assert_eq!(payload.turn_id, "turn-from-assessment");
assert_eq!(payload.target_item_id, "item-3");
assert_eq!(payload.review_id, "review-3");
assert_eq!(payload.target_item_id, None);
assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent);
assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Aborted);
assert_eq!(payload.review.risk_level, None);
assert_eq!(payload.review.user_authorization, None);
@@ -3395,7 +3399,11 @@ mod tests {
OutgoingMessage::AppServerNotification(
ServerNotification::ItemGuardianApprovalReviewStarted(payload),
) => {
assert_eq!(payload.target_item_id, "cmd-guardian-approved");
assert_eq!(payload.review_id, "review-cmd-guardian-approved");
assert_eq!(
payload.target_item_id.as_deref(),
Some("cmd-guardian-approved")
);
assert_eq!(
payload.review.status,
GuardianApprovalReviewStatus::InProgress
@@ -3416,7 +3424,12 @@ mod tests {
OutgoingMessage::AppServerNotification(
ServerNotification::ItemGuardianApprovalReviewCompleted(payload),
) => {
assert_eq!(payload.target_item_id, "cmd-guardian-approved");
assert_eq!(payload.review_id, "review-cmd-guardian-approved");
assert_eq!(
payload.target_item_id.as_deref(),
Some("cmd-guardian-approved")
);
assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent);
assert_eq!(
payload.review.status,
GuardianApprovalReviewStatus::Approved
@@ -3453,7 +3466,11 @@ mod tests {
OutgoingMessage::AppServerNotification(
ServerNotification::ItemGuardianApprovalReviewStarted(payload),
) => {
assert_eq!(payload.target_item_id, "cmd-guardian-denied");
assert_eq!(payload.review_id, "review-cmd-guardian-denied");
assert_eq!(
payload.target_item_id.as_deref(),
Some("cmd-guardian-denied")
);
assert_eq!(
payload.review.status,
GuardianApprovalReviewStatus::InProgress
@@ -3474,7 +3491,12 @@ mod tests {
OutgoingMessage::AppServerNotification(
ServerNotification::ItemGuardianApprovalReviewCompleted(payload),
) => {
assert_eq!(payload.target_item_id, "cmd-guardian-denied");
assert_eq!(payload.review_id, "review-cmd-guardian-denied");
assert_eq!(
payload.target_item_id.as_deref(),
Some("cmd-guardian-denied")
);
assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent);
assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Denied);
}
other => bail!("unexpected message: {other:?}"),
@@ -3491,6 +3513,30 @@ mod tests {
other => bail!("unexpected message: {other:?}"),
}
let mut missing_target = guardian_command_assessment(
"cmd-guardian-missing-target",
"turn-guardian-missing-target",
GuardianAssessmentStatus::InProgress,
);
missing_target.target_item_id = None;
guardian_context
.apply_guardian_assessment_event(missing_target)
.await;
let eighth = recv_broadcast_message(&mut rx).await?;
match eighth {
OutgoingMessage::AppServerNotification(
ServerNotification::ItemGuardianApprovalReviewStarted(payload),
) => {
assert_eq!(payload.review_id, "review-cmd-guardian-missing-target");
assert_eq!(payload.target_item_id, None);
assert_eq!(
payload.review.status,
GuardianApprovalReviewStatus::InProgress
);
}
other => bail!("unexpected message: {other:?}"),
}
assert!(rx.try_recv().is_err(), "no extra messages expected");
conversation.shutdown_and_wait().await?;
Ok(())
@@ -3993,7 +4039,6 @@ mod tests {
unlimited: false,
balance: Some("5".to_string()),
}),
spend_control: None,
plan_type: None,
};
@@ -116,8 +116,6 @@ use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadArchivedNotification;
@@ -186,7 +184,6 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_app_server_protocol::WorkspaceRole;
use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::Client as BackendClient;
@@ -203,7 +200,6 @@ use codex_core::SteerInputError;
use codex_core::ThreadConfigSnapshot;
use codex_core::ThreadManager;
use codex_core::ThreadSortKey as CoreThreadSortKey;
use codex_core::WorkspaceRole as CoreWorkspaceRole;
use codex_core::append_thread_name;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
@@ -490,96 +486,6 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) log_db: Option<LogDbLayer>,
}
async fn resolve_workspace_role_and_owner_for_auth(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> (Option<WorkspaceRole>, Option<bool>) {
let ownership =
codex_core::resolve_workspace_role_and_owner_for_auth(chatgpt_base_url, auth).await;
(
ownership.workspace_role.map(workspace_role_to_v2),
ownership.is_workspace_owner,
)
}
fn workspace_role_to_v2(role: CoreWorkspaceRole) -> WorkspaceRole {
match role {
CoreWorkspaceRole::AccountOwner => WorkspaceRole::AccountOwner,
CoreWorkspaceRole::AccountAdmin => WorkspaceRole::AccountAdmin,
CoreWorkspaceRole::StandardUser => WorkspaceRole::StandardUser,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct AuthIdentity {
auth_mode: AuthMode,
account_id: Option<String>,
account_email: Option<String>,
chatgpt_user_id: Option<String>,
}
fn auth_identity(auth: &CodexAuth) -> AuthIdentity {
AuthIdentity {
auth_mode: auth.api_auth_mode(),
account_id: auth.get_account_id(),
account_email: auth.get_account_email(),
chatgpt_user_id: auth.get_chatgpt_user_id(),
}
}
fn cached_workspace_role_and_owner_for_auth(
auth: Option<&CodexAuth>,
) -> (Option<WorkspaceRole>, Option<bool>) {
(None, auth.and_then(CodexAuth::is_workspace_owner))
}
fn account_updated_notification_for_auth(
auth: Option<&CodexAuth>,
workspace_role: Option<WorkspaceRole>,
is_workspace_owner: Option<bool>,
) -> AccountUpdatedNotification {
AccountUpdatedNotification {
auth_mode: auth.map(CodexAuth::api_auth_mode),
plan_type: auth.and_then(CodexAuth::account_plan_type),
workspace_role,
is_workspace_owner,
}
}
fn spawn_live_workspace_role_update_for_auth(
outgoing: Arc<OutgoingMessageSender>,
auth_manager: Arc<AuthManager>,
chatgpt_base_url: String,
auth: Option<CodexAuth>,
) {
let Some(auth) = auth.filter(CodexAuth::is_chatgpt_auth) else {
return;
};
let expected_identity = auth_identity(&auth);
tokio::spawn(async move {
let (workspace_role, is_workspace_owner) =
resolve_workspace_role_and_owner_for_auth(&chatgpt_base_url, Some(&auth)).await;
let Some(workspace_role) = workspace_role else {
return;
};
let current_auth = auth_manager.auth_cached();
if current_auth.as_ref().map(auth_identity) != Some(expected_identity) {
return;
}
let payload = account_updated_notification_for_auth(
current_auth.as_ref(),
Some(workspace_role),
is_workspace_owner,
);
outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload))
.await;
});
}
impl CodexMessageProcessor {
pub(crate) fn handle_config_mutation(&self) {
self.clear_plugin_related_caches();
@@ -592,18 +498,10 @@ impl CodexMessageProcessor {
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
let auth = self.auth_manager.auth_cached();
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
account_updated_notification_for_auth(auth.as_ref(), workspace_role, is_workspace_owner)
}
fn spawn_live_workspace_role_update(&self, auth: Option<CodexAuth>) {
spawn_live_workspace_role_update_for_auth(
self.outgoing.clone(),
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
auth,
);
AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
}
}
async fn load_thread(
@@ -887,10 +785,6 @@ impl CodexMessageProcessor {
self.thread_shell_command(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadAddCreditsNudgeEmail { request_id, params } => {
self.thread_add_credits_nudge_email(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::SkillsList { request_id, params } => {
self.skills_list(to_connection_request_id(request_id), params)
.await;
@@ -1210,7 +1104,6 @@ impl CodexMessageProcessor {
self.current_account_updated_notification(),
))
.await;
self.spawn_live_workspace_role_update(self.auth_manager.auth_cached());
}
Err(error) => {
self.outgoing.send_error(request_id, error).await;
@@ -1335,7 +1228,7 @@ impl CodexMessageProcessor {
replace_cloud_requirements_loader(
cloud_requirements.as_ref(),
auth_manager.clone(),
chatgpt_base_url.clone(),
chatgpt_base_url,
codex_home,
);
sync_default_client_residency_requirement(
@@ -1344,27 +1237,17 @@ impl CodexMessageProcessor {
)
.await;
// Notify clients with the actual current auth mode immediately; the
// live workspace role is fetched below without delaying this update.
// Notify clients with the actual current auth mode.
let auth = auth_manager.auth_cached();
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let payload_v2 = account_updated_notification_for_auth(
auth.as_ref(),
workspace_role,
is_workspace_owner,
);
let payload_v2 = AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
};
outgoing_clone
.send_server_notification(ServerNotification::AccountUpdated(
payload_v2,
))
.await;
spawn_live_workspace_role_update_for_auth(
outgoing_clone.clone(),
auth_manager.clone(),
chatgpt_base_url.clone(),
auth,
);
}
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
@@ -1459,7 +1342,7 @@ impl CodexMessageProcessor {
replace_cloud_requirements_loader(
cloud_requirements.as_ref(),
auth_manager.clone(),
chatgpt_base_url.clone(),
chatgpt_base_url,
codex_home,
);
sync_default_client_residency_requirement(
@@ -1469,24 +1352,15 @@ impl CodexMessageProcessor {
.await;
let auth = auth_manager.auth_cached();
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let payload_v2 = account_updated_notification_for_auth(
auth.as_ref(),
workspace_role,
is_workspace_owner,
);
let payload_v2 = AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
};
outgoing_clone
.send_server_notification(ServerNotification::AccountUpdated(
payload_v2,
))
.await;
spawn_live_workspace_role_update_for_auth(
outgoing_clone.clone(),
auth_manager.clone(),
chatgpt_base_url.clone(),
auth,
);
}
let mut guard = active_login.lock().await;
@@ -1635,7 +1509,6 @@ impl CodexMessageProcessor {
self.current_account_updated_notification(),
))
.await;
self.spawn_live_workspace_role_update(self.auth_manager.auth_cached());
}
async fn logout_common(&self) -> std::result::Result<Option<AuthMode>, JSONRPCErrorError> {
@@ -1673,8 +1546,6 @@ impl CodexMessageProcessor {
let payload_v2 = AccountUpdatedNotification {
auth_mode: current_auth_method,
plan_type: None,
workspace_role: None,
is_workspace_owner: None,
};
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
@@ -1773,16 +1644,13 @@ impl CodexMessageProcessor {
if !requires_openai_auth {
let response = GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth,
};
self.outgoing.send_response(request_id, response).await;
return;
}
let auth = self.auth_manager.auth_cached();
let account = match auth.as_ref() {
let account = match self.auth_manager.auth_cached() {
Some(auth) => match auth.auth_mode() {
CoreAuthMode::ApiKey => Some(Account::ApiKey {}),
CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => {
@@ -1809,17 +1677,12 @@ impl CodexMessageProcessor {
},
None => None,
};
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let response = GetAccountResponse {
account,
workspace_role,
is_workspace_owner,
requires_openai_auth,
};
self.outgoing.send_response(request_id, response).await;
self.spawn_live_workspace_role_update(auth);
}
async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) {
@@ -1837,11 +1700,6 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
Err(error) => {
tracing::warn!(
?request_id,
error = %error.message,
"account/rateLimits/read request failed"
);
self.outgoing.send_error(request_id, error).await;
}
}
@@ -2236,6 +2094,7 @@ impl CodexMessageProcessor {
experimental_raw_events,
personality,
ephemeral,
session_start_source,
persist_extended_history,
} = params;
let mut typesafe_overrides = self.build_thread_config_overrides(
@@ -2277,6 +2136,7 @@ impl CodexMessageProcessor {
config,
typesafe_overrides,
dynamic_tools,
session_start_source,
persist_extended_history,
service_name,
experimental_raw_events,
@@ -2352,6 +2212,7 @@ impl CodexMessageProcessor {
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
persist_extended_history: bool,
service_name: Option<String>,
experimental_raw_events: bool,
@@ -2475,6 +2336,12 @@ impl CodexMessageProcessor {
.thread_manager
.start_thread_with_tools_and_service_name(
config,
match session_start_source
.unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup)
{
codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New,
codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared,
},
core_dynamic_tools,
persist_extended_history,
service_name,
@@ -3542,40 +3409,6 @@ impl CodexMessageProcessor {
}
}
async fn thread_add_credits_nudge_email(
&self,
request_id: ConnectionRequestId,
params: ThreadAddCreditsNudgeEmailParams,
) {
let ThreadAddCreditsNudgeEmailParams { thread_id } = params;
let (_, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self
.submit_core_op(&request_id, thread.as_ref(), Op::SendAddCreditsNudgeEmail)
.await
{
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadAddCreditsNudgeEmailResponse {})
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to request add-credits nudge email: {err}"),
)
.await;
}
}
}
async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) {
let ThreadListParams {
cursor,
@@ -4443,7 +4276,7 @@ impl CodexMessageProcessor {
thread.preview = preview_from_rollout_items(items);
Ok(thread)
}
InitialHistory::New => Err(format!(
InitialHistory::New | InitialHistory::Cleared => Err(format!(
"failed to build resume response for thread {thread_id}: initial history missing"
)),
};
@@ -9114,7 +8947,7 @@ pub(crate) async fn read_rollout_items_from_rollout(
path: &Path,
) -> std::io::Result<Vec<RolloutItem>> {
let items = match RolloutRecorder::get_rollout_history(path).await? {
InitialHistory::New => Vec::new(),
InitialHistory::New | InitialHistory::Cleared => Vec::new(),
InitialHistory::Forked(items) => items,
InitialHistory::Resumed(resumed) => resumed.history,
};
+1 -7
View File
@@ -735,7 +735,6 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(PlanType::Plus),
},
});
@@ -755,7 +754,6 @@ mod tests {
},
"secondary": null,
"credits": null,
"spendControl": null,
"planType": "plus"
}
},
@@ -771,8 +769,6 @@ mod tests {
let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification {
auth_mode: Some(AuthMode::ApiKey),
plan_type: None,
workspace_role: None,
is_workspace_owner: None,
});
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
@@ -781,9 +777,7 @@ mod tests {
"method": "account/updated",
"params": {
"authMode": "apikey",
"planType": null,
"workspaceRole": null,
"isWorkspaceOwner": null
"planType": null
},
}),
serde_json::to_value(jsonrpc_notification)
@@ -60,11 +60,6 @@ impl ChatGptAuthFixture {
self
}
pub fn is_org_owner(mut self, is_org_owner: bool) -> Self {
self.claims.is_org_owner = Some(is_org_owner);
self
}
pub fn email(mut self, email: impl Into<String>) -> Self {
self.claims.email = Some(email.into());
self
@@ -87,7 +82,6 @@ pub struct ChatGptIdTokenClaims {
pub plan_type: Option<String>,
pub chatgpt_user_id: Option<String>,
pub chatgpt_account_id: Option<String>,
pub is_org_owner: Option<bool>,
}
impl ChatGptIdTokenClaims {
@@ -114,11 +108,6 @@ impl ChatGptIdTokenClaims {
self.chatgpt_account_id = Some(chatgpt_account_id.into());
self
}
pub fn is_org_owner(mut self, is_org_owner: bool) -> Self {
self.is_org_owner = Some(is_org_owner);
self
}
}
pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result<String> {
@@ -137,9 +126,6 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result<String> {
if let Some(chatgpt_account_id) = &claims.chatgpt_account_id {
auth_payload.insert("chatgpt_account_id".to_string(), json!(chatgpt_account_id));
}
if let Some(is_org_owner) = claims.is_org_owner {
auth_payload.insert("is_org_owner".to_string(), json!(is_org_owner));
}
if !auth_payload.is_empty() {
payload.insert(
"https://api.openai.com/auth".to_string(),
@@ -58,7 +58,6 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadForkParams;
@@ -415,16 +414,6 @@ impl McpProcess {
self.send_request("thread/shellCommand", params).await
}
/// Send a `thread/addCreditsNudgeEmail` JSON-RPC request.
pub async fn send_thread_add_credits_nudge_email_request(
&mut self,
params: ThreadAddCreditsNudgeEmailParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/addCreditsNudgeEmail", params)
.await
}
/// Send a `thread/rollback` JSON-RPC request.
pub async fn send_thread_rollback_request(
&mut self,
+1 -255
View File
@@ -28,7 +28,6 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WorkspaceRole;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::login_with_api_key;
use codex_protocol::account::PlanType as AccountPlanType;
@@ -56,7 +55,6 @@ struct CreateConfigTomlParams {
forced_workspace_id: Option<String>,
requires_openai_auth: Option<bool>,
base_url: Option<String>,
chatgpt_base_url: Option<String>,
}
fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> {
@@ -64,9 +62,6 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std:
let base_url = params
.base_url
.unwrap_or_else(|| "http://127.0.0.1:0/v1".to_string());
let chatgpt_base_url = params
.chatgpt_base_url
.unwrap_or_else(|| "http://127.0.0.1:0/backend-api".to_string());
let forced_line = if let Some(method) = params.forced_method {
format!("forced_login_method = \"{method}\"\n")
} else {
@@ -87,7 +82,6 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std:
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
chatgpt_base_url = "{chatgpt_base_url}"
{forced_line}
{forced_workspace_line}
@@ -128,49 +122,6 @@ async fn mock_device_code_usercode_failure(server: &MockServer, status: u16) {
.await;
}
async fn mock_accounts_check_role(server: &MockServer, account_id: &str, role: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/accounts/check/v4-2023-04-27"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"accounts": {
account_id: {
"account": {
"account_user_role": role,
}
}
},
"account_ordering": [account_id],
})))
.mount(server)
.await;
}
async fn mock_slow_accounts_check_role(
server: &MockServer,
account_id: &str,
role: &str,
delay: Duration,
) {
Mock::given(method("GET"))
.and(path("/backend-api/accounts/check/v4-2023-04-27"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(delay)
.set_body_json(json!({
"accounts": {
account_id: {
"account": {
"account_user_role": role,
}
}
},
"account_ordering": [account_id],
})),
)
.mount(server)
.await;
}
async fn mock_device_code_token_success(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/api/accounts/deviceauth/token"))
@@ -270,12 +221,10 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
CreateConfigTomlParams {
requires_openai_auth: Some(true),
base_url: Some(format!("{}/v1", mock_server.uri())),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
write_models_cache(codex_home.path())?;
mock_accounts_check_role(&mock_server, "org-embedded", "standard-user").await;
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
@@ -313,20 +262,6 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
};
assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens));
assert_eq!(payload.plan_type, Some(AccountPlanType::Pro));
assert_eq!(payload.workspace_role, None);
assert_eq!(payload.is_workspace_owner, None);
let note = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/updated"),
)
.await??;
let parsed: ServerNotification = note.try_into()?;
let ServerNotification::AccountUpdated(payload) = parsed else {
bail!("unexpected notification: {parsed:?}");
};
assert_eq!(payload.workspace_role, Some(WorkspaceRole::StandardUser));
assert_eq!(payload.is_workspace_owner, Some(false));
let get_id = mcp
.send_get_account_request(GetAccountParams {
@@ -346,8 +281,6 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
email: "embedded@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
}
);
@@ -415,8 +348,6 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
email: "embedded@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
}
);
@@ -1574,8 +1505,6 @@ async fn get_account_with_api_key() -> Result<()> {
let expected = GetAccountResponse {
account: Some(Account::ApiKey {}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
};
assert_eq!(received, expected);
@@ -1610,8 +1539,6 @@ async fn get_account_when_auth_not_required() -> Result<()> {
let expected = GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: false,
};
assert_eq!(received, expected);
@@ -1632,8 +1559,7 @@ async fn get_account_with_chatgpt() -> Result<()> {
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.email("user@example.com")
.plan_type("pro")
.is_org_owner(/*is_org_owner*/ true),
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
@@ -1657,190 +1583,12 @@ async fn get_account_with_chatgpt() -> Result<()> {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: Some(true),
requires_openai_auth: true,
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_emits_workspace_role_from_accounts_check() -> Result<()> {
let codex_home = TempDir::new()?;
let mock_server = MockServer::start().await;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
mock_accounts_check_role(&mock_server, "org-embedded", "account-owner").await;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.account_id("org-embedded")
.email("user@example.com")
.plan_type("pro")
.chatgpt_account_id("org-embedded"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
};
assert_eq!(received, expected);
let note = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/updated"),
)
.await??;
let parsed: ServerNotification = note.try_into()?;
let ServerNotification::AccountUpdated(payload) = parsed else {
bail!("unexpected notification: {parsed:?}");
};
assert_eq!(payload.workspace_role, Some(WorkspaceRole::AccountOwner));
assert_eq!(payload.is_workspace_owner, Some(true));
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_does_not_guess_workspace_role_from_other_accounts() -> Result<()>
{
let codex_home = TempDir::new()?;
let mock_server = MockServer::start().await;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
mock_accounts_check_role(&mock_server, "org-other", "account-owner").await;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.account_id("org-current")
.email("user@example.com")
.plan_type("pro")
.chatgpt_account_id("org-current"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_does_not_wait_for_accounts_check() -> Result<()> {
let codex_home = TempDir::new()?;
let mock_server = MockServer::start().await;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
mock_slow_accounts_check_role(
&mock_server,
"org-embedded",
"standard-user",
Duration::from_secs(2),
)
.await;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.account_id("org-embedded")
.email("user@example.com")
.plan_type("pro")
.chatgpt_account_id("org-embedded")
.is_org_owner(/*is_org_owner*/ true),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let resp: JSONRPCResponse = timeout(
Duration::from_millis(500),
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
assert_eq!(
received,
GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: Some(true),
requires_openai_auth: true,
}
);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1877,8 +1625,6 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Unknown,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
};
assert_eq!(received, expected);
@@ -569,6 +569,55 @@ model = "gpt-old"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().canonicalize()?;
write_config(
&temp_dir,
r#"
model = "gpt-old"
"#,
)?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let write_id = mcp
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: json!("gpt-new"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await?;
let read_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let write_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
)
.await??;
let write: ConfigWriteResponse = to_response(write_resp)?;
assert_eq!(write.status, WriteStatus::Ok);
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
)
.await??;
let read: ConfigReadResponse = to_response(read_resp)?;
assert_eq!(read.config.model.as_deref(), Some("gpt-new"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_rejects_version_conflict() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -32,7 +32,6 @@ mod request_user_input;
mod review;
mod safety_check_downgrade;
mod skills_list;
mod thread_add_credits_nudge_email;
mod thread_archive;
mod thread_fork;
mod thread_list;
@@ -133,10 +133,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
}
}
}
],
"spend_control": {
"reached": true
}
]
});
Mock::given(method("GET"))
@@ -175,7 +172,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
spend_control: Some(codex_app_server_protocol::SpendControlSnapshot { reached: true }),
plan_type: Some(AccountPlanType::Pro),
},
rate_limits_by_limit_id: Some(
@@ -196,9 +192,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
spend_control: Some(codex_app_server_protocol::SpendControlSnapshot {
reached: true,
}),
plan_type: Some(AccountPlanType::Pro),
},
),
@@ -214,7 +207,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
},
),
@@ -241,6 +241,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
developer_instructions: None,
personality: None,
ephemeral: None,
session_start_source: None,
dynamic_tools: None,
mock_experimental_field: None,
experimental_raw_events: false,
@@ -1,96 +0,0 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::to_response;
use codex_app_server_protocol::AddCreditsNudgeEmailNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailResult;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_add_credits_nudge_email_submits_core_op_and_emits_completion() -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let server = create_mock_responses_server_sequence(vec![]).await;
create_config_toml(codex_home.as_path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.as_path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let nudge_id = mcp
.send_thread_add_credits_nudge_email_request(ThreadAddCreditsNudgeEmailParams {
thread_id: thread.id.clone(),
})
.await?;
let nudge_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(nudge_id)),
)
.await??;
let _: ThreadAddCreditsNudgeEmailResponse =
to_response::<ThreadAddCreditsNudgeEmailResponse>(nudge_resp)?;
let notification: AddCreditsNudgeEmailNotification = serde_json::from_value(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/addCreditsNudgeEmail/completed"),
)
.await??
.params
.expect("account/addCreditsNudgeEmail/completed params"),
)?;
assert_eq!(notification.thread_id, thread.id);
assert_eq!(
notification.result,
AddCreditsNudgeEmailResult::Failed {
message: "codex account authentication required to notify workspace owner".to_string(),
}
);
Ok(())
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
+13 -130
View File
@@ -1,10 +1,8 @@
use crate::types::AccountsCheckV4Response;
use crate::types::CodeTaskDetailsResponse;
use crate::types::ConfigFileResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitStatusPayload;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use crate::types::WorkspaceRole;
use anyhow::Result;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_login::CodexAuth;
@@ -13,7 +11,6 @@ use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlSnapshot;
use reqwest::StatusCode;
use reqwest::header::AUTHORIZATION;
use reqwest::header::CONTENT_TYPE;
@@ -23,10 +20,6 @@ use reqwest::header::HeaderValue;
use reqwest::header::USER_AGENT;
use serde::de::DeserializeOwned;
use std::fmt;
use std::time::Duration;
const BACKEND_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const ACCOUNTS_CHECK_V4_VERSION: &str = "v4-2023-04-27";
#[derive(Debug)]
pub enum RequestError {
@@ -266,55 +259,12 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url),
};
let req = self
.http
.get(&url)
.headers(self.headers())
.timeout(BACKEND_REQUEST_TIMEOUT);
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?;
Ok(Self::rate_limit_snapshots_from_payload(payload))
}
pub async fn send_add_credits_nudge_email(&self) -> std::result::Result<(), RequestError> {
let url = match self.path_style {
PathStyle::CodexApi => {
format!(
"{}/api/codex/accounts/send_add_credits_nudge_email",
self.base_url
)
}
PathStyle::ChatGptApi => {
format!("{}/accounts/send_add_credits_nudge_email", self.base_url)
}
};
let req = self
.http
.post(&url)
.headers(self.headers())
.timeout(BACKEND_REQUEST_TIMEOUT);
let _ = self.exec_request_detailed(req, "POST", &url).await?;
Ok(())
}
pub async fn get_current_workspace_role(&self) -> Result<Option<WorkspaceRole>> {
if self.path_style != PathStyle::ChatGptApi {
return Ok(None);
}
let url = format!(
"{}/accounts/check/{ACCOUNTS_CHECK_V4_VERSION}",
self.base_url
);
let req = self
.http
.get(&url)
.headers(self.headers())
.timeout(BACKEND_REQUEST_TIMEOUT);
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let payload: AccountsCheckV4Response = self.decode_json(&url, &ct, &body)?;
Ok(payload.current_workspace_role(self.chatgpt_account_id.as_deref()))
}
pub async fn list_tasks(
&self,
limit: Option<i32>,
@@ -447,23 +397,20 @@ impl Client {
payload: RateLimitStatusPayload,
) -> Vec<RateLimitSnapshot> {
let plan_type = Some(Self::map_plan_type(payload.plan_type));
let spend_control = payload.spend_control.map(|details| *details);
let mut snapshots = vec![Self::make_rate_limit_snapshot(
Some("codex".to_string()),
/*limit_name*/ None,
payload.rate_limit.map(|details| *details),
payload.credits.map(|details| *details),
spend_control,
payload.rate_limit.flatten().map(|details| *details),
payload.credits.flatten().map(|details| *details),
plan_type,
)];
if let Some(additional) = payload.additional_rate_limits {
if let Some(additional) = payload.additional_rate_limits.flatten() {
snapshots.extend(additional.into_iter().map(|details| {
Self::make_rate_limit_snapshot(
Some(details.metered_feature),
Some(details.limit_name),
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
/*credits*/ None,
/*spend_control*/ None,
plan_type,
)
}));
@@ -476,7 +423,6 @@ impl Client {
limit_name: Option<String>,
rate_limit: Option<crate::types::RateLimitStatusDetails>,
credits: Option<crate::types::CreditStatusDetails>,
spend_control: Option<crate::types::SpendControlStatusDetails>,
plan_type: Option<AccountPlanType>,
) -> RateLimitSnapshot {
let (primary, secondary) = match rate_limit {
@@ -492,7 +438,6 @@ impl Client {
primary,
secondary,
credits: Self::map_credits(credits),
spend_control: Self::map_spend_control(spend_control),
plan_type,
}
}
@@ -522,15 +467,6 @@ impl Client {
})
}
fn map_spend_control(
spend_control: Option<crate::types::SpendControlStatusDetails>,
) -> Option<SpendControlSnapshot> {
let details = spend_control?;
Some(SpendControlSnapshot {
reached: details.reached,
})
}
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
match plan_type {
crate::types::PlanType::Free => AccountPlanType::Free,
@@ -567,6 +503,7 @@ impl Client {
#[cfg(test)]
mod tests {
use super::*;
use codex_backend_openapi_models::models::AdditionalRateLimitDetails;
use pretty_assertions::assert_eq;
#[test]
@@ -585,7 +522,7 @@ mod tests {
fn usage_payload_maps_primary_and_additional_rate_limits() {
let payload = RateLimitStatusPayload {
plan_type: crate::types::PlanType::Pro,
rate_limit: Some(Box::new(crate::types::RateLimitStatusDetails {
rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails {
primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot {
used_percent: 42,
limit_window_seconds: 300,
@@ -599,8 +536,8 @@ mod tests {
reset_at: 456,
}))),
..Default::default()
})),
additional_rate_limits: Some(vec![crate::types::AdditionalRateLimitDetails {
}))),
additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails {
limit_name: "codex_other".to_string(),
metered_feature: "codex_other".to_string(),
rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails {
@@ -613,16 +550,13 @@ mod tests {
secondary_window: None,
..Default::default()
}))),
}]),
credits: Some(Box::new(crate::types::CreditStatusDetails {
}])),
credits: Some(Some(Box::new(crate::types::CreditStatusDetails {
has_credits: true,
unlimited: false,
balance: Some(Some("9.99".to_string())),
..Default::default()
})),
spend_control: Some(Box::new(crate::types::SpendControlStatusDetails {
reached: true,
})),
}))),
};
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
@@ -646,10 +580,6 @@ mod tests {
balance: Some("9.99".to_string()),
})
);
assert_eq!(
snapshots[0].spend_control,
Some(SpendControlSnapshot { reached: true })
);
assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro));
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
@@ -659,7 +589,6 @@ mod tests {
Some(70.0)
);
assert_eq!(snapshots[1].credits, None);
assert_eq!(snapshots[1].spend_control, None);
assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
}
@@ -668,13 +597,12 @@ mod tests {
let payload = RateLimitStatusPayload {
plan_type: crate::types::PlanType::Plus,
rate_limit: None,
additional_rate_limits: Some(vec![crate::types::AdditionalRateLimitDetails {
additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails {
limit_name: "codex_other".to_string(),
metered_feature: "codex_other".to_string(),
rate_limit: None,
}]),
}])),
credits: None,
spend_control: None,
};
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
@@ -682,7 +610,6 @@ mod tests {
assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex"));
assert_eq!(snapshots[0].limit_name, None);
assert_eq!(snapshots[0].primary, None);
assert_eq!(snapshots[0].spend_control, None);
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
}
@@ -700,7 +627,6 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
},
RateLimitSnapshot {
@@ -713,7 +639,6 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
},
];
@@ -725,46 +650,4 @@ mod tests {
.unwrap_or_else(|| snapshots[0].clone());
assert_eq!(preferred.limit_id.as_deref(), Some("codex"));
}
#[test]
fn add_credits_nudge_email_uses_expected_paths() {
let codex_api = Client::new("https://example.com").expect("codex api client");
assert_eq!(
match codex_api.path_style {
PathStyle::CodexApi => format!(
"{}/api/codex/accounts/send_add_credits_nudge_email",
codex_api.base_url
),
PathStyle::ChatGptApi => unreachable!("plain host should use codex api paths"),
},
"https://example.com/api/codex/accounts/send_add_credits_nudge_email"
);
let chatgpt_api = Client::new("https://chatgpt.com").expect("chatgpt backend api client");
assert_eq!(
match chatgpt_api.path_style {
PathStyle::CodexApi => unreachable!("chatgpt host should use backend-api paths"),
PathStyle::ChatGptApi => format!(
"{}/accounts/send_add_credits_nudge_email",
chatgpt_api.base_url
),
},
"https://chatgpt.com/backend-api/accounts/send_add_credits_nudge_email"
);
}
#[test]
fn current_workspace_role_uses_expected_path() {
let chatgpt_api = Client::new("https://chatgpt.com").expect("chatgpt backend api client");
assert_eq!(
match chatgpt_api.path_style {
PathStyle::CodexApi => unreachable!("chatgpt host should use backend-api paths"),
PathStyle::ChatGptApi => format!(
"{}/accounts/check/{ACCOUNTS_CHECK_V4_VERSION}",
chatgpt_api.base_url
),
},
"https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
);
}
}
-2
View File
@@ -3,11 +3,9 @@ pub(crate) mod types;
pub use client::Client;
pub use client::RequestError;
pub use types::AccountsCheckV4Response;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::ConfigFileResponse;
pub use types::PaginatedListTaskListItem;
pub use types::TaskListItem;
pub use types::TurnAttemptsSiblingTurnsResponse;
pub use types::WorkspaceRole;
+1 -176
View File
@@ -1,115 +1,17 @@
pub use codex_backend_openapi_models::models::AdditionalRateLimitDetails;
pub use codex_backend_openapi_models::models::ConfigFileResponse;
pub use codex_backend_openapi_models::models::CreditStatusDetails;
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
pub use codex_backend_openapi_models::models::PlanType;
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
pub use codex_backend_openapi_models::models::TaskListItem;
use serde::Deserialize;
use serde::Serialize;
use serde::de::Deserializer;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WorkspaceRole {
AccountOwner,
AccountAdmin,
StandardUser,
}
impl WorkspaceRole {
pub fn from_api_str(value: &str) -> Option<Self> {
match value {
"account-owner" | "account_owner" => Some(Self::AccountOwner),
"account-admin" | "account_admin" => Some(Self::AccountAdmin),
"standard-user" | "standard_user" | "member" => Some(Self::StandardUser),
_ => None,
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct AccountsCheckV4Response {
#[serde(default)]
pub accounts: HashMap<String, AccountsCheckV4AccountItem>,
#[serde(default)]
pub account_ordering: Vec<String>,
}
impl AccountsCheckV4Response {
pub fn current_workspace_role(
&self,
current_account_id: Option<&str>,
) -> Option<WorkspaceRole> {
let account = if let Some(account_id) = current_account_id {
self.accounts.get(account_id)?
} else {
self.account_ordering
.iter()
.find_map(|account_id| self.accounts.get(account_id))
.or_else(|| {
if self.accounts.len() == 1 {
self.accounts.values().next()
} else {
None
}
})?
};
account
.account
.as_ref()
.and_then(|account| account.account_user_role.as_deref())
.and_then(WorkspaceRole::from_api_str)
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct AccountsCheckV4AccountItem {
#[serde(default)]
pub account: Option<AccountsCheckV4Account>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct AccountsCheckV4Account {
#[serde(default, rename = "account_user_role")]
pub account_user_role: Option<String>,
}
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct RateLimitStatusPayload {
#[serde(rename = "plan_type")]
pub plan_type: PlanType,
#[serde(
rename = "rate_limit",
default,
skip_serializing_if = "Option::is_none"
)]
pub rate_limit: Option<Box<RateLimitStatusDetails>>,
#[serde(rename = "credits", default, skip_serializing_if = "Option::is_none")]
pub credits: Option<Box<CreditStatusDetails>>,
#[serde(
rename = "additional_rate_limits",
default,
skip_serializing_if = "Option::is_none"
)]
pub additional_rate_limits: Option<Vec<AdditionalRateLimitDetails>>,
#[serde(
rename = "spend_control",
default,
skip_serializing_if = "Option::is_none"
)]
pub spend_control: Option<Box<SpendControlStatusDetails>>,
}
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SpendControlStatusDetails {
#[serde(rename = "reached")]
pub reached: bool,
}
/// 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.
@@ -318,83 +220,6 @@ impl Turn {
}
}
#[cfg(test)]
mod accounts_check_tests {
use super::AccountsCheckV4Account;
use super::AccountsCheckV4AccountItem;
use super::AccountsCheckV4Response;
use super::WorkspaceRole;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
#[test]
fn current_workspace_role_prefers_current_account_id() {
let response = AccountsCheckV4Response {
accounts: HashMap::from([
(
"workspace-a".to_string(),
AccountsCheckV4AccountItem {
account: Some(AccountsCheckV4Account {
account_user_role: Some("standard-user".to_string()),
}),
},
),
(
"workspace-b".to_string(),
AccountsCheckV4AccountItem {
account: Some(AccountsCheckV4Account {
account_user_role: Some("account-owner".to_string()),
}),
},
),
]),
account_ordering: vec!["workspace-a".to_string(), "workspace-b".to_string()],
};
assert_eq!(
response.current_workspace_role(Some("workspace-b")),
Some(WorkspaceRole::AccountOwner)
);
}
#[test]
fn current_workspace_role_falls_back_to_account_ordering() {
let response = AccountsCheckV4Response {
accounts: HashMap::from([(
"workspace-a".to_string(),
AccountsCheckV4AccountItem {
account: Some(AccountsCheckV4Account {
account_user_role: Some("account_admin".to_string()),
}),
},
)]),
account_ordering: vec!["workspace-a".to_string()],
};
assert_eq!(
response.current_workspace_role(/*current_account_id*/ None),
Some(WorkspaceRole::AccountAdmin)
);
}
#[test]
fn current_workspace_role_does_not_fall_back_when_current_account_missing() {
let response = AccountsCheckV4Response {
accounts: HashMap::from([(
"workspace-a".to_string(),
AccountsCheckV4AccountItem {
account: Some(AccountsCheckV4Account {
account_user_role: Some("account-owner".to_string()),
}),
},
)]),
account_ordering: vec!["workspace-a".to_string()],
};
assert_eq!(response.current_workspace_role(Some("workspace-b")), None);
}
}
impl WorklogMessage {
fn is_assistant(&self) -> bool {
self.author
-2
View File
@@ -92,7 +92,6 @@ pub fn parse_rate_limit_for_limit(
primary,
secondary,
credits,
spend_control: None,
plan_type: None,
})
}
@@ -156,7 +155,6 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
primary,
secondary,
credits,
spend_control: None,
plan_type: event.plan_type,
})
}
-1
View File
@@ -26,7 +26,6 @@ bm25 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
codex-analytics = { workspace = true }
codex-account = { workspace = true }
codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
-20
View File
@@ -1,20 +0,0 @@
use crate::config::Config;
use codex_account::AddCreditsNudgeEmailStatus;
use codex_account::SendAddCreditsNudgeEmailError;
use codex_account::WorkspaceOwnership;
use codex_login::AuthManager;
use codex_login::CodexAuth;
pub async fn send_add_credits_nudge_email(
config: &Config,
auth_manager: &AuthManager,
) -> Result<AddCreditsNudgeEmailStatus, SendAddCreditsNudgeEmailError> {
codex_account::send_add_credits_nudge_email(config.chatgpt_base_url.clone(), auth_manager).await
}
pub async fn resolve_workspace_role_and_owner_for_auth(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> WorkspaceOwnership {
codex_account::resolve_workspace_role_and_owner_for_auth(chatgpt_base_url, auth).await
}
+7 -55
View File
@@ -599,7 +599,7 @@ impl Codex {
let thread_id = match &conversation_history {
InitialHistory::Resumed(resumed) => Some(resumed.conversation_id),
InitialHistory::Forked(_) => conversation_history.forked_from_id(),
InitialHistory::New => None,
InitialHistory::New | InitialHistory::Cleared => None,
};
match thread_id {
Some(thread_id) => {
@@ -1619,7 +1619,7 @@ impl Session {
let forked_from_id = initial_history.forked_from_id();
let (conversation_id, rollout_params) = match &initial_history {
InitialHistory::New | InitialHistory::Forked(_) => {
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => {
let conversation_id = ThreadId::default();
(
conversation_id,
@@ -1660,14 +1660,14 @@ impl Session {
.count(),
)
.unwrap_or(u64::MAX),
InitialHistory::New | InitialHistory::Forked(_) => 0,
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => 0,
};
let state_builder = match &initial_history {
InitialHistory::Resumed(resumed) => metadata::builder_from_items(
resumed.history.as_slice(),
resumed.rollout_path.as_path(),
),
InitialHistory::New | InitialHistory::Forked(_) => None,
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
};
// Kick off independent async setup tasks in parallel to reduce startup latency.
@@ -2026,7 +2026,7 @@ impl Session {
session_telemetry,
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
guardian_rejection_rationales: Mutex::new(HashMap::new()),
guardian_rejections: Mutex::new(HashMap::new()),
skills_manager,
plugins_manager: Arc::clone(&plugins_manager),
mcp_manager: Arc::clone(&mcp_manager),
@@ -2199,6 +2199,7 @@ impl Session {
InitialHistory::New | InitialHistory::Forked(_) => {
codex_hooks::SessionStartSource::Startup
}
InitialHistory::Cleared => codex_hooks::SessionStartSource::Clear,
};
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
@@ -2339,7 +2340,7 @@ impl Session {
)
};
match conversation_history {
InitialHistory::New => {
InitialHistory::New | InitialHistory::Cleared => {
// Defer initial context insertion until the first real turn starts so
// turn/start overrides can be merged before we write model-visible context.
self.set_previous_turn_settings(/*previous_turn_settings*/ None)
@@ -4795,10 +4796,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
handlers::set_thread_name(&sess, sub.id.clone(), name).await;
false
}
Op::SendAddCreditsNudgeEmail => {
handlers::send_add_credits_nudge_email(&sess, &config, sub.id.clone()).await;
false
}
Op::RunUserShellCommand { command } => {
handlers::run_user_shell_command(&sess, sub.id.clone(), command).await;
false
@@ -4895,8 +4892,6 @@ mod handlers {
use crate::tasks::execute_user_shell_command;
use codex_mcp::collect_mcp_snapshot_from_manager;
use codex_mcp::compute_auth_statuses;
use codex_protocol::protocol::AddCreditsNudgeEmailResponseEvent;
use codex_protocol::protocol::AddCreditsNudgeEmailStatus;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
@@ -4941,48 +4936,6 @@ mod handlers {
sess.close_unified_exec_processes().await;
}
pub async fn send_add_credits_nudge_email(
sess: &Arc<Session>,
config: &Config,
sub_id: String,
) {
match crate::send_add_credits_nudge_email(config, &sess.services.auth_manager).await {
Ok(status) => {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::AddCreditsNudgeEmailResponse(
AddCreditsNudgeEmailResponseEvent {
result: Ok(add_credits_nudge_email_status_to_event(status)),
},
),
})
.await;
}
Err(err) => {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::AddCreditsNudgeEmailResponse(
AddCreditsNudgeEmailResponseEvent {
result: Err(err.to_string()),
},
),
})
.await;
}
}
}
fn add_credits_nudge_email_status_to_event(
status: crate::AddCreditsNudgeEmailStatus,
) -> AddCreditsNudgeEmailStatus {
match status {
crate::AddCreditsNudgeEmailStatus::Sent => AddCreditsNudgeEmailStatus::Sent,
crate::AddCreditsNudgeEmailStatus::CooldownActive => {
AddCreditsNudgeEmailStatus::CooldownActive
}
}
}
pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) {
sess.send_event_raw(Event {
id: sub_id,
@@ -7289,7 +7242,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::AddCreditsNudgeEmailResponse(_)
| EventMsg::RealtimeConversationListVoicesResponse(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::PlanUpdate(_)
+6
View File
@@ -39,6 +39,7 @@ use crate::codex::TurnContext;
use crate::codex::emit_subagent_session_started;
use crate::config::Config;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request_with_cancel;
use crate::guardian::routes_approval_to_guardian;
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT;
@@ -459,6 +460,7 @@ async fn handle_exec_approval(
let review_rx = spawn_guardian_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
new_guardian_review_id(),
GuardianApprovalRequest::Shell {
id: call_id.clone(),
command,
@@ -566,6 +568,7 @@ async fn handle_patch_approval(
let review_rx = spawn_guardian_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
new_guardian_review_id(),
GuardianApprovalRequest::ApplyPatch {
id: approval_id.clone(),
cwd: parent_ctx.cwd.to_path_buf(),
@@ -686,6 +689,7 @@ async fn maybe_auto_review_mcp_request_user_input(
let review_rx = spawn_guardian_review(
Arc::clone(parent_session),
Arc::clone(parent_ctx),
new_guardian_review_id(),
build_guardian_mcp_tool_review_request(&event.call_id, &invocation, metadata.as_ref()),
/*retry_reason*/ None,
review_cancel.clone(),
@@ -729,6 +733,7 @@ async fn maybe_auto_review_mcp_request_user_input(
fn spawn_guardian_review(
session: Arc<Session>,
turn: Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
cancel_token: CancellationToken,
@@ -745,6 +750,7 @@ fn spawn_guardian_review(
let decision = runtime.block_on(review_approval_request_with_cancel(
&session,
&turn,
review_id,
request,
retry_reason,
cancel_token,
+18 -15
View File
@@ -10,7 +10,6 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::GuardianAssessmentAction;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::GuardianCommandSource;
use codex_protocol::protocol::McpInvocation;
@@ -311,22 +310,26 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f
})
.await
.expect("timed out waiting for guardian assessment");
let expected_action = GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf tmp".to_string(),
cwd: "/tmp".into(),
};
assert!(!assessment_event.id.is_empty());
assert_eq!(
assessment_event,
GuardianAssessmentEvent {
id: "command-item-1".to_string(),
turn_id: parent_ctx.sub_id.clone(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf tmp".to_string(),
cwd: "/tmp".into(),
},
}
assessment_event.target_item_id.as_deref(),
Some("command-item-1")
);
assert_eq!(assessment_event.turn_id, parent_ctx.sub_id);
assert_eq!(
assessment_event.status,
GuardianAssessmentStatus::InProgress
);
assert_eq!(assessment_event.risk_level, None);
assert_eq!(assessment_event.user_authorization, None);
assert_eq!(assessment_event.rationale, None);
assert_eq!(assessment_event.decision_source, None);
assert_eq!(assessment_event.action, expected_action);
cancel_token.cancel();
+2 -8
View File
@@ -2012,7 +2012,6 @@ async fn set_rate_limits_retains_previous_credits() {
unlimited: false,
balance: Some("10.00".to_string()),
}),
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
};
state.set_rate_limits(initial.clone());
@@ -2031,7 +2030,6 @@ async fn set_rate_limits_retains_previous_credits() {
resets_at: Some(1_900),
}),
credits: None,
spend_control: None,
plan_type: None,
};
state.set_rate_limits(update.clone());
@@ -2044,7 +2042,6 @@ async fn set_rate_limits_retains_previous_credits() {
primary: update.primary.clone(),
secondary: update.secondary,
credits: initial.credits,
spend_control: None,
plan_type: initial.plan_type,
})
);
@@ -2121,7 +2118,6 @@ async fn set_rate_limits_updates_plan_type_when_present() {
unlimited: false,
balance: Some("15.00".to_string()),
}),
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
};
state.set_rate_limits(initial.clone());
@@ -2136,7 +2132,6 @@ async fn set_rate_limits_updates_plan_type_when_present() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Pro),
};
state.set_rate_limits(update.clone());
@@ -2149,7 +2144,6 @@ async fn set_rate_limits_updates_plan_type_when_present() {
primary: update.primary,
secondary: update.secondary,
credits: initial.credits,
spend_control: None,
plan_type: update.plan_type,
})
);
@@ -2879,7 +2873,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
session_telemetry: session_telemetry.clone(),
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
guardian_rejection_rationales: Mutex::new(std::collections::HashMap::new()),
guardian_rejections: Mutex::new(std::collections::HashMap::new()),
skills_manager,
plugins_manager,
mcp_manager,
@@ -3724,7 +3718,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
session_telemetry: session_telemetry.clone(),
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
guardian_rejection_rationales: Mutex::new(std::collections::HashMap::new()),
guardian_rejections: Mutex::new(std::collections::HashMap::new()),
skills_manager,
plugins_manager,
mcp_manager,
+2 -8
View File
@@ -650,14 +650,8 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
},
]);
#[cfg(windows)]
let expected_deny_write_paths = vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(cwd.join(".codex"))
.expect("absolute .codex"),
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
];
#[cfg(not(windows))]
// The legacy workspace-write root already protects top-level `.codex`, so
// the restricted-token overlay only needs the extra read-only docs carveout.
let expected_deny_write_paths = vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
@@ -359,15 +359,15 @@ pub(crate) fn guardian_assessment_action(
}
}
pub(crate) fn guardian_request_id(request: &GuardianApprovalRequest) -> &str {
pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) -> Option<&str> {
match request {
GuardianApprovalRequest::Shell { id, .. }
| GuardianApprovalRequest::ExecCommand { id, .. }
| GuardianApprovalRequest::ApplyPatch { id, .. }
| GuardianApprovalRequest::NetworkAccess { id, .. }
| GuardianApprovalRequest::McpToolCall { id, .. } => id,
| GuardianApprovalRequest::McpToolCall { id, .. } => Some(id),
GuardianApprovalRequest::NetworkAccess { .. } => None,
#[cfg(unix)]
GuardianApprovalRequest::Execve { id, .. } => id,
GuardianApprovalRequest::Execve { id, .. } => Some(id),
}
}
+8
View File
@@ -18,6 +18,7 @@ mod review_session;
use std::time::Duration;
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
use serde::Deserialize;
use serde::Serialize;
@@ -29,6 +30,7 @@ pub(crate) use review::GuardianApprovalReviewResult;
pub(crate) use review::GuardianApprovalReviewStatus;
pub(crate) use review::guardian_rejection_message;
pub(crate) use review::is_guardian_reviewer_source;
pub(crate) use review::new_guardian_review_id;
pub(crate) use review::review_approval_request;
pub(crate) use review::review_approval_request_with_cancel;
pub(crate) use review::review_approval_request_with_review;
@@ -63,6 +65,12 @@ pub(crate) struct GuardianAssessment {
pub(crate) rationale: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GuardianRejection {
pub(crate) rationale: String,
pub(crate) source: GuardianAssessmentDecisionSource,
}
#[cfg(test)]
use approval_request::format_guardian_action_pretty;
#[cfg(test)]
+50 -21
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::GuardianRiskLevel;
@@ -19,8 +20,9 @@ use super::GUARDIAN_REVIEWER_NAME;
use super::GuardianApprovalRequest;
use super::GuardianAssessment;
use super::GuardianAssessmentOutcome;
use super::GuardianRejection;
use super::approval_request::guardian_assessment_action;
use super::approval_request::guardian_request_id;
use super::approval_request::guardian_request_target_item_id;
use super::approval_request::guardian_request_turn_id;
use super::prompt::guardian_output_schema;
use super::prompt::parse_guardian_assessment;
@@ -36,20 +38,29 @@ const GUARDIAN_REJECTION_INSTRUCTIONS: &str = concat!(
"Otherwise, stop and request user input.",
);
pub(crate) async fn guardian_rejection_message(session: &Session, assessment_id: &str) -> String {
let rationale = session
pub(crate) fn new_guardian_review_id() -> String {
uuid::Uuid::new_v4().to_string()
}
pub(crate) async fn guardian_rejection_message(session: &Session, review_id: &str) -> String {
let rejection = session
.services
.guardian_rejection_rationales
.guardian_rejections
.lock()
.await
.remove(assessment_id)
.filter(|rationale| !rationale.trim().is_empty())
.unwrap_or_else(|| "Guardian denied the action without a specific rationale.".to_string());
format!(
"This action was rejected due to unacceptable risk.\nReason: {}\n{}",
rationale.trim(),
GUARDIAN_REJECTION_INSTRUCTIONS
)
.remove(review_id)
.filter(|rejection| !rejection.rationale.trim().is_empty())
.unwrap_or_else(|| GuardianRejection {
rationale: "Guardian denied the action without a specific rationale.".to_string(),
source: GuardianAssessmentDecisionSource::Agent,
});
match rejection.source {
GuardianAssessmentDecisionSource::Agent => format!(
"This action was rejected due to unacceptable risk.\nReason: {}\n{}",
rejection.rationale.trim(),
GUARDIAN_REJECTION_INSTRUCTIONS
),
}
}
#[derive(Debug)]
@@ -115,23 +126,26 @@ pub(crate) fn is_guardian_reviewer_source(
async fn run_guardian_review(
session: Arc<Session>,
turn: Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
external_cancel: Option<CancellationToken>,
) -> GuardianApprovalReviewResult {
let assessment_id = guardian_request_id(&request).to_string();
let target_item_id = guardian_request_target_item_id(&request).map(str::to_string);
let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string();
let action_summary = guardian_assessment_action(&request);
session
.send_event(
turn.as_ref(),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: assessment_id.clone(),
id: review_id.clone(),
target_item_id: target_item_id.clone(),
turn_id: assessment_turn_id.clone(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: action_summary.clone(),
}),
)
@@ -145,12 +159,14 @@ async fn run_guardian_review(
.send_event(
turn.as_ref(),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: assessment_id,
id: review_id,
target_item_id,
turn_id: assessment_turn_id,
status: GuardianAssessmentStatus::Aborted,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: action_summary,
}),
)
@@ -206,12 +222,14 @@ async fn run_guardian_review(
.send_event(
turn.as_ref(),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: assessment_id,
id: review_id,
target_item_id,
turn_id: assessment_turn_id,
status: GuardianAssessmentStatus::Aborted,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: action_summary,
}),
)
@@ -274,23 +292,29 @@ async fn run_guardian_review(
rationale: Some(assessment.rationale.clone()),
};
{
let mut rationales = session.services.guardian_rejection_rationales.lock().await;
let mut rationales = session.services.guardian_rejections.lock().await;
if approved {
rationales.remove(&assessment_id);
rationales.remove(&review_id);
} else {
rationales.insert(assessment_id.clone(), assessment.rationale.clone());
let rejection = GuardianRejection {
rationale: assessment.rationale.clone(),
source: GuardianAssessmentDecisionSource::Agent,
};
rationales.insert(review_id.clone(), rejection);
}
}
session
.send_event(
turn.as_ref(),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: assessment_id,
id: review_id,
target_item_id,
turn_id: assessment_turn_id,
status,
risk_level: Some(assessment.risk_level),
user_authorization: Some(assessment.user_authorization),
rationale: Some(assessment.rationale.clone()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: terminal_action,
}),
)
@@ -303,12 +327,14 @@ async fn run_guardian_review(
pub(crate) async fn review_approval_request_with_review(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
) -> GuardianApprovalReviewResult {
run_guardian_review(
Arc::clone(session),
Arc::clone(turn),
review_id,
request,
retry_reason,
/*external_cancel*/ None,
@@ -320,10 +346,11 @@ pub(crate) async fn review_approval_request_with_review(
pub(crate) async fn review_approval_request(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
) -> ReviewDecision {
review_approval_request_with_review(session, turn, request, retry_reason)
review_approval_request_with_review(session, turn, review_id, request, retry_reason)
.await
.decision
}
@@ -331,6 +358,7 @@ pub(crate) async fn review_approval_request(
pub(crate) async fn review_approval_request_with_cancel(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
cancel_token: CancellationToken,
@@ -338,6 +366,7 @@ pub(crate) async fn review_approval_request_with_cancel(
run_guardian_review(
Arc::clone(session),
Arc::clone(turn),
review_id,
request,
retry_reason,
Some(cancel_token),
+1 -1
View File
@@ -414,7 +414,7 @@ impl GuardianReviewSessionManager {
let snapshot = state.last_committed_fork_snapshot.as_ref()?;
match &snapshot.initial_history {
InitialHistory::Forked(items) => Some(items.clone()),
InitialHistory::New | InitialHistory::Resumed(_) => None,
_ => None,
}
}
+18 -2
View File
@@ -683,6 +683,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
let decision = review_approval_request_with_cancel(
&session,
&turn,
"review-cancelled-guardian".to_string(),
GuardianApprovalRequest::ApplyPatch {
id: "patch-1".to_string(),
cwd: PathBuf::from("/tmp"),
@@ -1248,6 +1249,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
let decision = review_approval_request(
&session,
&turn,
"review-shell-guardian-error".to_string(),
GuardianApprovalRequest::Shell {
id: "shell-guardian-error".to_string(),
command: vec!["git".to_string(), "push".to_string()],
@@ -1295,8 +1297,13 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
}),
"denial rationale should not fall back to the generic missing payload error"
);
{
let rationales = session.services.guardian_rejections.lock().await;
assert!(rationales.contains_key("review-shell-guardian-error"));
assert!(!rationales.contains_key("shell-guardian-error"));
}
let rejection_message =
guardian_rejection_message(session.as_ref(), "shell-guardian-error").await;
guardian_rejection_message(session.as_ref(), "review-shell-guardian-error").await;
assert!(
rejection_message.contains("Reason: Automatic approval review failed:")
&& rejection_message.contains(error_message),
@@ -1375,7 +1382,14 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
justification: Some("Inspect repo state before proceeding.".to_string()),
};
assert_eq!(
review_approval_request(&session, &turn, initial_request, /*retry_reason*/ None).await,
review_approval_request(
&session,
&turn,
"review-shell-guardian-1".to_string(),
initial_request,
/*retry_reason*/ None
)
.await,
ReviewDecision::Approved
);
session
@@ -1427,6 +1441,7 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
review_approval_request(
&session_for_second,
&turn_for_second,
"review-shell-guardian-2".to_string(),
second_request,
Some("trunk follow-up".to_string()),
)
@@ -1475,6 +1490,7 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
let third_decision = review_approval_request(
&session,
&turn,
"review-shell-guardian-3".to_string(),
third_request,
Some("parallel follow-up".to_string()),
)
-7
View File
@@ -5,7 +5,6 @@
// the TUI or the tracing stack).
#![deny(clippy::print_stdout, clippy::print_stderr)]
mod account;
mod apply_patch;
mod apps;
mod arc_monitor;
@@ -113,13 +112,7 @@ mod stream_events_utils;
pub mod test_support;
mod unified_exec;
pub mod windows_sandbox;
pub use account::resolve_workspace_role_and_owner_for_auth;
pub use account::send_add_credits_nudge_email;
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
pub use codex_account::AddCreditsNudgeEmailStatus;
pub use codex_account::SendAddCreditsNudgeEmailError;
pub use codex_account::WorkspaceOwnership;
pub use codex_account::WorkspaceRole;
pub use codex_protocol::config_types::ModelProviderAuthInfo;
mod event_mapping;
pub mod review_format;
+6 -3
View File
@@ -24,6 +24,7 @@ use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianMcpAnnotations;
use crate::guardian::guardian_approval_request_to_json;
use crate::guardian::guardian_rejection_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files;
@@ -772,14 +773,16 @@ async fn maybe_request_mcp_tool_approval(
.enabled(Feature::ToolCallMcpElicitation);
if routes_approval_to_guardian(turn_context) {
let review_id = new_guardian_review_id();
let decision = review_approval_request(
sess,
turn_context,
review_id.clone(),
build_guardian_mcp_tool_review_request(call_id, invocation, metadata),
monitor_reason.clone(),
)
.await;
let decision = mcp_tool_approval_decision_from_guardian(sess, call_id, decision).await;
let decision = mcp_tool_approval_decision_from_guardian(sess, &review_id, decision).await;
apply_mcp_tool_approval_decision(
sess,
turn_context,
@@ -969,7 +972,7 @@ pub(crate) fn build_guardian_mcp_tool_review_request(
async fn mcp_tool_approval_decision_from_guardian(
sess: &Session,
call_id: &str,
review_id: &str,
decision: ReviewDecision,
) -> McpToolApprovalDecision {
match decision {
@@ -978,7 +981,7 @@ async fn mcp_tool_approval_decision_from_guardian(
| ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept,
ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession,
ReviewDecision::Denied => McpToolApprovalDecision::Decline {
message: Some(guardian_rejection_message(sess, call_id).await),
message: Some(guardian_rejection_message(sess, review_id).await),
},
ReviewDecision::Abort => McpToolApprovalDecision::Decline { message: None },
}
+10 -9
View File
@@ -816,21 +816,22 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() {
assert_eq!(
mcp_tool_approval_decision_from_guardian(
session.as_ref(),
"approval-id",
"review-id",
ReviewDecision::Approved
)
.await,
McpToolApprovalDecision::Accept
);
session
.services
.guardian_rejection_rationales
.lock()
.await
.insert("approval-id".to_string(), "too risky".to_string());
session.services.guardian_rejections.lock().await.insert(
"review-id".to_string(),
crate::guardian::GuardianRejection {
rationale: "too risky".to_string(),
source: codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent,
},
);
let denial = mcp_tool_approval_decision_from_guardian(
session.as_ref(),
"approval-id",
"review-id",
ReviewDecision::Denied,
)
.await;
@@ -845,7 +846,7 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() {
assert_eq!(
mcp_tool_approval_decision_from_guardian(
session.as_ref(),
"approval-id",
"review-id",
ReviewDecision::Abort
)
.await,
+2 -1
View File
@@ -7,6 +7,7 @@ use crate::agent::AgentControl;
use crate::client::ModelClient;
use crate::config::StartedNetworkProxy;
use crate::exec_policy::ExecPolicyManager;
use crate::guardian::GuardianRejection;
use crate::mcp::McpManager;
use crate::plugins::PluginsManager;
use crate::skills_watcher::SkillsWatcher;
@@ -47,7 +48,7 @@ pub(crate) struct SessionServices {
pub(crate) models_manager: Arc<ModelsManager>,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) tool_approvals: Mutex<ApprovalStore>,
pub(crate) guardian_rejection_rationales: Mutex<HashMap<String, String>>,
pub(crate) guardian_rejections: Mutex<HashMap<String, GuardianRejection>>,
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
-3
View File
@@ -229,9 +229,6 @@ fn merge_rate_limit_fields(
if snapshot.credits.is_none() {
snapshot.credits = previous.and_then(|prior| prior.credits.clone());
}
if snapshot.spend_control.is_none() {
snapshot.spend_control = previous.and_then(|prior| prior.spend_control.clone());
}
if snapshot.plan_type.is_none() {
snapshot.plan_type = previous.and_then(|prior| prior.plan_type);
}
-6
View File
@@ -48,7 +48,6 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
});
@@ -76,7 +75,6 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
});
state.set_rate_limits(RateLimitSnapshot {
@@ -89,7 +87,6 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
});
@@ -121,7 +118,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
unlimited: false,
balance: Some("50".to_string()),
}),
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
});
@@ -135,7 +131,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
});
@@ -155,7 +150,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
unlimited: false,
balance: Some("50".to_string()),
}),
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
})
);
+5 -2
View File
@@ -474,6 +474,7 @@ impl ThreadManager {
) -> CodexResult<NewThread> {
Box::pin(self.start_thread_with_tools_and_service_name(
config,
InitialHistory::New,
dynamic_tools,
persist_extended_history,
/*metrics_service_name*/ None,
@@ -485,6 +486,7 @@ impl ThreadManager {
pub async fn start_thread_with_tools_and_service_name(
&self,
config: Config,
initial_history: InitialHistory,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
@@ -492,7 +494,7 @@ impl ThreadManager {
) -> CodexResult<NewThread> {
Box::pin(self.state.spawn_thread(
config,
InitialHistory::New,
initial_history,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
dynamic_tools,
@@ -668,6 +670,7 @@ impl ThreadManager {
ForkSnapshot::Interrupted => {
let history = match history {
InitialHistory::New => InitialHistory::New,
InitialHistory::Cleared => InitialHistory::Cleared,
InitialHistory::Forked(history) => InitialHistory::Forked(history),
InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history),
};
@@ -1070,7 +1073,7 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option<String>)
}));
match history {
InitialHistory::New => InitialHistory::Forked(vec![
InitialHistory::New | InitialHistory::Cleared => InitialHistory::Forked(vec![
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
aborted_event,
]),
+7 -7
View File
@@ -1,6 +1,7 @@
use crate::codex::Session;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::guardian_rejection_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::network_policy_decision::denied_network_policy_message;
@@ -369,12 +370,13 @@ impl NetworkApprovalService {
};
let owner_call = self.resolve_single_active_call().await;
let guardian_approval_id = Self::approval_id_for_key(&key);
let approval_decision = if routes_approval_to_guardian(&turn_context) {
// TODO(ccunningham): Attach guardian network reviews to the reviewed tool item
// lifecycle instead of this temporary standalone network approval id.
let use_guardian = routes_approval_to_guardian(&turn_context);
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let approval_decision = if let Some(review_id) = guardian_review_id.clone() {
review_approval_request(
&session,
&turn_context,
review_id,
GuardianApprovalRequest::NetworkAccess {
id: guardian_approval_id.clone(),
turn_id: owner_call
@@ -487,11 +489,9 @@ impl NetworkApprovalService {
}
},
ReviewDecision::Denied | ReviewDecision::Abort => {
if routes_approval_to_guardian(&turn_context) {
if let Some(review_id) = guardian_review_id.as_deref() {
if let Some(owner_call) = owner_call.as_ref() {
let message =
guardian_rejection_message(session.as_ref(), &guardian_approval_id)
.await;
let message = guardian_rejection_message(session.as_ref(), review_id).await;
self.record_call_outcome(
&owner_call.registration_id,
NetworkApprovalOutcome::DeniedByPolicy(message),
+15 -10
View File
@@ -11,6 +11,7 @@ use crate::guardian::GuardianApprovalReviewResult;
use crate::guardian::GuardianApprovalReviewStatus;
use crate::guardian::GuardianAssessmentOutcome;
use crate::guardian::guardian_rejection_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request_with_review;
use crate::guardian::routes_approval_to_guardian;
use crate::hook_runtime::run_permission_request_hooks;
@@ -123,6 +124,7 @@ impl ToolOrchestrator {
let otel = turn_ctx.session_telemetry.clone();
let otel_tn = &tool_ctx.tool_name;
let otel_ci = &tool_ctx.call_id;
let use_guardian = routes_approval_to_guardian(turn_ctx);
// 1) Approval
let mut already_approved = false;
@@ -142,10 +144,12 @@ impl ToolOrchestrator {
return Err(ToolError::Rejected(reason));
}
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let approval_ctx = ApprovalCtx {
session: &tool_ctx.session,
turn: &tool_ctx.turn,
call_id: &tool_ctx.call_id,
guardian_review_id: guardian_review_id.clone(),
retry_reason: reason,
network_approval_context: None,
};
@@ -162,9 +166,8 @@ impl ToolOrchestrator {
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if routes_approval_to_guardian(turn_ctx) {
guardian_rejection_message(tool_ctx.session.as_ref(), &tool_ctx.call_id)
.await
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await
} else {
"rejected by user".to_string()
};
@@ -298,14 +301,15 @@ impl ToolOrchestrator {
.should_bypass_approval(approval_policy, already_approved)
&& network_approval_context.is_none();
if !bypass_retry_approval {
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let approval_ctx = ApprovalCtx {
session: &tool_ctx.session,
turn: &tool_ctx.turn,
call_id: &tool_ctx.call_id,
guardian_review_id: guardian_review_id.clone(),
retry_reason: Some(retry_reason),
network_approval_context: network_approval_context.clone(),
};
let decision = Self::request_approval(
tool,
req,
@@ -319,12 +323,9 @@ impl ToolOrchestrator {
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if routes_approval_to_guardian(turn_ctx) {
guardian_rejection_message(
tool_ctx.session.as_ref(),
&tool_ctx.call_id,
)
.await
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id)
.await
} else {
"rejected by user".to_string()
};
@@ -403,6 +404,10 @@ impl ToolOrchestrator {
review_approval_request_with_review(
approval_ctx.session,
approval_ctx.turn,
approval_ctx
.guardian_review_id
.clone()
.expect("guardian review id should be present for guardian approvals"),
request,
approval_ctx.retry_reason.clone(),
)
@@ -8,7 +8,6 @@
use crate::exec::ExecCapturePolicy;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::execute_env;
use crate::tools::sandboxing::Approvable;
@@ -147,13 +146,15 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
let retry_reason = ctx.retry_reason.clone();
let approval_keys = self.approval_keys(req);
let changes = req.changes.clone();
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if req.permissions_preapproved && retry_reason.is_none() {
return ReviewDecision::Approved;
}
if routes_approval_to_guardian(turn) {
if let Some(review_id) = guardian_review_id {
let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id);
return review_approval_request(session, turn, action, retry_reason).await;
return review_approval_request(session, turn, review_id, action, retry_reason)
.await;
}
if let Some(reason) = retry_reason {
let rx_approve = session
+10 -4
View File
@@ -12,7 +12,6 @@ use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
@@ -166,10 +165,17 @@ impl Approvable<ShellRequest> for ShellRuntime {
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if routes_approval_to_guardian(turn) {
return review_approval_request(session, turn, guardian_request, retry_reason)
.await;
if let Some(review_id) = guardian_review_id {
return review_approval_request(
session,
turn,
review_id,
guardian_request,
retry_reason,
)
.await;
}
with_cached_approval(&session.services, "shell", keys, move || async move {
let available_decisions = None;
@@ -4,6 +4,7 @@ use crate::exec::ExecExpiration;
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::guardian_rejection_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::ExecOptions;
@@ -315,6 +316,11 @@ enum DecisionSource {
UnmatchedCommandFallback,
}
struct PromptDecision {
decision: ReviewDecision,
guardian_review_id: Option<String>,
}
fn execve_prompt_is_rejected_by_policy(
approval_policy: AskForApproval,
decision_source: &DecisionSource,
@@ -376,7 +382,7 @@ impl CoreShellActionProvider {
workdir: &AbsolutePathBuf,
stopwatch: &Stopwatch,
additional_permissions: Option<PermissionProfile>,
) -> anyhow::Result<ReviewDecision> {
) -> anyhow::Result<PromptDecision> {
let command = join_program_and_argv(program, argv);
let workdir = workdir.to_path_buf();
let session = self.session.clone();
@@ -384,12 +390,14 @@ impl CoreShellActionProvider {
let call_id = self.call_id.clone();
let approval_id = Some(Uuid::new_v4().to_string());
let source = self.tool_name;
let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id);
Ok(stopwatch
.pause_for(async move {
if routes_approval_to_guardian(&turn) {
return review_approval_request(
if let Some(review_id) = guardian_review_id.clone() {
let decision = review_approval_request(
&session,
&turn,
review_id,
GuardianApprovalRequest::Execve {
id: call_id.clone(),
source,
@@ -401,8 +409,12 @@ impl CoreShellActionProvider {
/*retry_reason*/ None,
)
.await;
return PromptDecision {
decision,
guardian_review_id,
};
}
session
let decision = session
.request_command_approval(
&turn,
call_id,
@@ -415,7 +427,11 @@ impl CoreShellActionProvider {
additional_permissions,
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
)
.await
.await;
PromptDecision {
decision,
guardian_review_id: None,
}
})
.await)
}
@@ -442,10 +458,10 @@ impl CoreShellActionProvider {
{
EscalationDecision::deny(Some("Execution forbidden by policy".to_string()))
} else {
match self
let prompt_decision = self
.prompt(program, argv, workdir, &self.stopwatch, prompt_permissions)
.await?
{
.await?;
match prompt_decision.decision {
ReviewDecision::Approved
| ReviewDecision::ApprovedForSession
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
@@ -470,9 +486,10 @@ impl CoreShellActionProvider {
}
},
ReviewDecision::Denied => {
let message = if routes_approval_to_guardian(&self.turn) {
guardian_rejection_message(self.session.as_ref(), &self.call_id)
.await
let message = if let Some(review_id) =
prompt_decision.guardian_review_id.as_deref()
{
guardian_rejection_message(self.session.as_ref(), review_id).await
} else {
"User denied execution".to_string()
};
@@ -9,7 +9,6 @@ use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
@@ -146,10 +145,17 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
let cwd = req.cwd.to_path_buf();
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if routes_approval_to_guardian(turn) {
return review_approval_request(session, turn, guardian_request, retry_reason)
.await;
if let Some(review_id) = guardian_review_id {
return review_approval_request(
session,
turn,
review_id,
guardian_request,
retry_reason,
)
.await;
}
with_cached_approval(&session.services, "unified_exec", keys, || async move {
let available_decisions = None;
+7
View File
@@ -122,6 +122,13 @@ pub(crate) struct ApprovalCtx<'a> {
pub session: &'a Arc<Session>,
pub turn: &'a Arc<TurnContext>,
pub call_id: &'a str,
/// Guardian review lifecycle ID for this approval, when guardian is reviewing it.
///
/// This is separate from `call_id`: `call_id` identifies the tool item under
/// review, while this ID identifies the review itself. Keeping both lets
/// denial handling, overrides, and app-server notifications refer to the
/// review without overloading the tool call ID as a review ID.
pub guardian_review_id: Option<String>,
pub retry_reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
}
-3
View File
@@ -2375,7 +2375,6 @@ async fn token_count_includes_rate_limits_snapshot() {
"resets_at": 1704074400
},
"credits": null,
"spend_control": null,
"plan_type": null
}
})
@@ -2427,7 +2426,6 @@ async fn token_count_includes_rate_limits_snapshot() {
"resets_at": 1704074400
},
"credits": null,
"spend_control": null,
"plan_type": null
}
})
@@ -2502,7 +2500,6 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
"resets_at": null
},
"credits": null,
"spend_control": null,
"plan_type": null
});
@@ -1033,7 +1033,6 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
"resets_at": null
},
"credits": null,
"spend_control": null,
"plan_type": null
}
})
+4 -1
View File
@@ -84,6 +84,7 @@ use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks;
use codex_utils_oss::ensure_oss_provider_ready;
use codex_utils_oss::get_default_model_for_oss_provider;
use event_processor_with_human_output::EventProcessorWithHumanOutput;
@@ -278,7 +279,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
let resolved_cwd = cwd.clone();
let config_cwd = match resolved_cwd.as_deref() {
Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?,
Some(path) => {
AbsolutePathBuf::from_absolute_path(canonicalize_existing_preserving_symlinks(path)?)?
}
None => AbsolutePathBuf::current_dir()?,
};
@@ -20,6 +20,7 @@ use crate::schema::SessionStartCommandInput;
pub enum SessionStartSource {
Startup,
Resume,
Clear,
}
impl SessionStartSource {
@@ -27,6 +28,7 @@ impl SessionStartSource {
match self {
Self::Startup => "startup",
Self::Resume => "resume",
Self::Clear => "clear",
}
}
}
+310 -23
View File
@@ -11,6 +11,7 @@
//! - bubblewrap used to construct the filesystem view before exec.
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::fs;
use std::fs::File;
use std::os::fd::AsRawFd;
use std::path::Path;
@@ -273,19 +274,35 @@ fn create_filesystem_args(
if !root.exists() {
continue;
}
// Writable roots are rebound by real target below; mirror that
// for their restricted-read bootstrap mount. Plain read-only
// roots must stay logical because callers may execute those
// paths inside bwrap, such as Bazel runfiles helper binaries.
let mount_root = if writable_roots
.iter()
.any(|writable_root| root.starts_with(writable_root.root.as_path()))
{
canonical_target_if_symlinked_path(&root).unwrap_or(root)
} else {
root
};
args.push("--ro-bind".to_string());
args.push(path_to_string(&root));
args.push(path_to_string(&root));
args.push(path_to_string(&mount_root));
args.push(path_to_string(&mount_root));
}
}
args
};
let mut preserved_files = Vec::new();
let allowed_write_paths: Vec<PathBuf> = writable_roots
.iter()
.map(|writable_root| writable_root.root.as_path().to_path_buf())
.collect();
let mut allowed_write_paths = Vec::with_capacity(writable_roots.len());
for writable_root in &writable_roots {
let root = writable_root.root.as_path();
allowed_write_paths.push(root.to_path_buf());
if let Some(target) = canonical_target_if_symlinked_path(root) {
allowed_write_paths.push(target);
}
}
let unreadable_paths: HashSet<PathBuf> = unreadable_roots
.iter()
.map(|path| path.as_path().to_path_buf())
@@ -321,6 +338,7 @@ fn create_filesystem_args(
for writable_root in &sorted_writable_roots {
let root = writable_root.root.as_path();
let symlink_target = canonical_target_if_symlinked_path(root);
// If a denied ancestor was already masked, recreate any missing mount
// target parents before binding the narrower writable descendant.
if let Some(masking_root) = unreadable_roots
@@ -332,9 +350,10 @@ fn create_filesystem_args(
append_mount_target_parent_dir_args(&mut args, root, masking_root);
}
let mount_root = symlink_target.as_deref().unwrap_or(root);
args.push("--bind".to_string());
args.push(path_to_string(root));
args.push(path_to_string(root));
args.push(path_to_string(mount_root));
args.push(path_to_string(mount_root));
let mut read_only_subpaths: Vec<PathBuf> = writable_root
.read_only_subpaths
@@ -342,6 +361,9 @@ fn create_filesystem_args(
.map(|path| path.as_path().to_path_buf())
.filter(|path| !unreadable_paths.contains(path))
.collect();
if let Some(target) = &symlink_target {
read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target);
}
read_only_subpaths.sort_by_key(|path| path_depth(path));
for subpath in read_only_subpaths {
append_read_only_subpath_args(&mut args, &subpath, &allowed_write_paths);
@@ -351,6 +373,10 @@ fn create_filesystem_args(
.filter(|path| path.as_path().starts_with(root))
.map(|path| path.as_path().to_path_buf())
.collect();
if let Some(target) = &symlink_target {
nested_unreadable_roots =
remap_paths_for_symlink_target(nested_unreadable_roots, root, target);
}
nested_unreadable_roots.sort_by_key(|path| path_depth(path));
for unreadable_root in nested_unreadable_roots {
append_unreadable_root_args(
@@ -396,6 +422,52 @@ fn path_depth(path: &Path) -> usize {
path.components().count()
}
fn canonical_target_if_symlinked_path(path: &Path) -> Option<PathBuf> {
let mut current = PathBuf::new();
for component in path.components() {
use std::path::Component;
match component {
Component::RootDir => {
current.push(Path::new("/"));
continue;
}
Component::CurDir => continue,
Component::ParentDir => {
current.pop();
continue;
}
Component::Normal(part) => current.push(part),
Component::Prefix(_) => continue,
}
let metadata = match fs::symlink_metadata(&current) {
Ok(metadata) => metadata,
Err(_) => return None,
};
if metadata.file_type().is_symlink() {
let target = fs::canonicalize(path).ok()?;
if target.as_path() == path {
return None;
}
return Some(target);
}
}
None
}
fn remap_paths_for_symlink_target(paths: Vec<PathBuf>, root: &Path, target: &Path) -> Vec<PathBuf> {
paths
.into_iter()
.map(|path| {
if let Ok(relative) = path.strip_prefix(root) {
target.join(relative)
} else {
path
}
})
.collect()
}
fn normalize_command_cwd_for_bwrap(command_cwd: &Path) -> PathBuf {
command_cwd
.canonicalize()
@@ -427,10 +499,15 @@ fn append_read_only_subpath_args(
subpath: &Path,
allowed_write_paths: &[PathBuf],
) {
if let Some(symlink_path) = find_symlink_in_path(subpath, allowed_write_paths) {
if let Some(target) = canonical_target_for_symlink_in_path(subpath, allowed_write_paths) {
// bwrap takes `--ro-bind <source> <destination>`. Use the resolved target
// for both operands so a protected symlinked directory is remounted
// read-only in place instead of binding onto the symlink path itself.
let mount_source = path_to_string(&target);
let mount_destination = path_to_string(&target);
args.push("--ro-bind".to_string());
args.push("/dev/null".to_string());
args.push(path_to_string(&symlink_path));
args.push(mount_source);
args.push(mount_destination);
return;
}
@@ -458,11 +535,16 @@ fn append_unreadable_root_args(
unreadable_root: &Path,
allowed_write_paths: &[PathBuf],
) -> Result<()> {
if let Some(symlink_path) = find_symlink_in_path(unreadable_root, allowed_write_paths) {
args.push("--ro-bind".to_string());
args.push("/dev/null".to_string());
args.push(path_to_string(&symlink_path));
return Ok(());
if let Some(target) = canonical_target_for_symlink_in_path(unreadable_root, allowed_write_paths)
{
// Apply unreadable handling to the resolved symlink target, not the
// logical symlink path, to avoid file-vs-directory bind mismatches.
return append_existing_unreadable_path_args(
args,
preserved_files,
&target,
allowed_write_paths,
);
}
if !unreadable_root.exists() {
@@ -476,6 +558,20 @@ fn append_unreadable_root_args(
return Ok(());
}
append_existing_unreadable_path_args(
args,
preserved_files,
unreadable_root,
allowed_write_paths,
)
}
fn append_existing_unreadable_path_args(
args: &mut Vec<String>,
preserved_files: &mut Vec<File>,
unreadable_root: &Path,
allowed_write_paths: &[PathBuf],
) -> Result<()> {
if unreadable_root.is_dir() {
let mut writable_descendants: Vec<&Path> = allowed_write_paths
.iter()
@@ -525,12 +621,10 @@ fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) -
.any(|root| path.starts_with(root))
}
/// Find the first symlink along `target_path` that is also under a writable root.
///
/// This blocks symlink replacement attacks where a protected path is a symlink
/// inside a writable root (e.g., `.codex -> ./decoy`). In that case we mount
/// `/dev/null` on the symlink itself to prevent rewiring it.
fn find_symlink_in_path(target_path: &Path, allowed_write_paths: &[PathBuf]) -> Option<PathBuf> {
fn canonical_target_for_symlink_in_path(
target_path: &Path,
allowed_write_paths: &[PathBuf],
) -> Option<PathBuf> {
let mut current = PathBuf::new();
for component in target_path.components() {
@@ -557,7 +651,7 @@ fn find_symlink_in_path(target_path: &Path, allowed_write_paths: &[PathBuf]) ->
if metadata.file_type().is_symlink()
&& is_within_allowed_write_paths(&current, allowed_write_paths)
{
return Some(current);
return fs::canonicalize(&current).ok();
}
}
@@ -701,7 +795,13 @@ mod tests {
BwrapOptions::default(),
)
.expect("create bwrap args");
let canonical_sandbox_cwd = path_to_string(
&real_root
.canonicalize()
.expect("canonicalize sandbox policy cwd"),
);
let canonical_command_cwd = path_to_string(&canonical_command_cwd);
let link_sandbox_cwd = path_to_string(&link_root);
let link_command_cwd = path_to_string(&command_cwd);
assert!(
@@ -709,12 +809,199 @@ mod tests {
.windows(2)
.any(|window| { window == ["--chdir", canonical_command_cwd.as_str()] })
);
assert!(args.args.windows(3).any(|window| {
window
== [
"--ro-bind",
canonical_sandbox_cwd.as_str(),
canonical_sandbox_cwd.as_str(),
]
}));
assert!(
!args
.args
.windows(2)
.any(|window| { window == ["--chdir", link_command_cwd.as_str()] })
);
assert!(!args.args.windows(3).any(|window| {
window
== [
"--ro-bind",
link_sandbox_cwd.as_str(),
link_sandbox_cwd.as_str(),
]
}));
}
#[cfg(unix)]
#[test]
fn symlinked_writable_roots_bind_real_target_and_remap_carveouts() {
let temp_dir = TempDir::new().expect("temp dir");
let real_root = temp_dir.path().join("real");
let link_root = temp_dir.path().join("link");
let blocked = real_root.join("blocked");
std::fs::create_dir_all(&blocked).expect("create blocked dir");
std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root");
let link_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_blocked = link_root.join("blocked");
let real_root_str = path_to_string(&real_root);
let real_blocked_str = path_to_string(&blocked);
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_root },
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_blocked },
access: FileSystemAccessMode::None,
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window == ["--bind", real_root_str.as_str(), real_root_str.as_str()]
}));
assert!(args.args.windows(6).any(|window| {
window
== [
"--perms",
"000",
"--tmpfs",
real_blocked_str.as_str(),
"--remount-ro",
real_blocked_str.as_str(),
]
}));
}
#[cfg(unix)]
#[test]
fn writable_roots_under_symlinked_ancestors_bind_real_target() {
let temp_dir = TempDir::new().expect("temp dir");
let logical_home = temp_dir.path().join("home");
let real_codex = temp_dir.path().join("real-codex");
let logical_codex = logical_home.join(".codex");
let real_memories = real_codex.join("memories");
let logical_memories = logical_codex.join("memories");
std::fs::create_dir_all(&logical_home).expect("create logical home");
std::fs::create_dir_all(&real_memories).expect("create memories dir");
std::os::unix::fs::symlink(&real_codex, &logical_codex)
.expect("create symlinked codex home");
let logical_memories_root =
AbsolutePathBuf::from_absolute_path(&logical_memories).expect("absolute memories");
let real_memories_str = path_to_string(&real_memories);
let logical_memories_str = path_to_string(&logical_memories);
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: logical_memories_root,
},
access: FileSystemAccessMode::Write,
}]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window
== [
"--bind",
real_memories_str.as_str(),
real_memories_str.as_str(),
]
}));
assert!(!args.args.windows(3).any(|window| {
window
== [
"--bind",
logical_memories_str.as_str(),
logical_memories_str.as_str(),
]
}));
}
#[cfg(unix)]
#[test]
fn protected_symlinked_directory_subpaths_bind_target_read_only() {
let temp_dir = TempDir::new().expect("temp dir");
let root = temp_dir.path().join("root");
let agents_target = root.join("agents-target");
let agents_link = root.join(".agents");
std::fs::create_dir_all(&agents_target).expect("create agents target");
std::os::unix::fs::symlink(&agents_target, &agents_link).expect("create symlinked .agents");
let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root");
let agents_link_str = path_to_string(&agents_link);
let agents_target_str = path_to_string(&agents_target);
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path { path: root },
access: FileSystemAccessMode::Write,
}]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
assert!(args.args.windows(3).any(|window| {
window
== [
"--ro-bind",
agents_target_str.as_str(),
agents_target_str.as_str(),
]
}));
assert!(!args.args.iter().any(|arg| arg == agents_link_str.as_str()));
}
#[cfg(unix)]
#[test]
fn symlinked_writable_roots_mask_nested_symlink_escape_paths_without_binding_targets() {
let temp_dir = TempDir::new().expect("temp dir");
let real_root = temp_dir.path().join("real");
let link_root = temp_dir.path().join("link");
let outside = temp_dir.path().join("outside-private");
let linked_private = real_root.join("linked-private");
std::fs::create_dir_all(&real_root).expect("create real root");
std::fs::create_dir_all(&outside).expect("create outside dir");
std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root");
std::os::unix::fs::symlink(&outside, &linked_private)
.expect("create nested escape symlink");
let link_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_private = link_root.join("linked-private");
let real_linked_private_str = path_to_string(&linked_private);
let outside_str = path_to_string(&outside);
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_root },
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: link_private },
access: FileSystemAccessMode::None,
},
]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
assert!(args.args.windows(6).any(|window| {
window
== [
"--perms",
"000",
"--tmpfs",
outside_str.as_str(),
"--remount-ro",
outside_str.as_str(),
]
}));
assert!(
!args
.args
.iter()
.any(|arg| arg == real_linked_private_str.as_str())
);
}
#[test]
-1
View File
@@ -128,7 +128,6 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)),
chatgpt_user_id: Some("user-12345".to_string()),
chatgpt_account_id: None,
is_org_owner: None,
raw_jwt: fake_jwt,
},
access_token: "test-access-token".to_string(),
-8
View File
@@ -302,14 +302,6 @@ impl CodexAuth {
.and_then(|t| t.id_token.chatgpt_user_id)
}
/// Returns `Some(true)` when the current ChatGPT token marks the user as a
/// workspace owner, `Some(false)` for non-owners, and `None` when auth is
/// unavailable or the token omits the claim.
pub fn is_workspace_owner(&self) -> Option<bool> {
self.get_current_token_data()
.and_then(|t| t.id_token.is_org_owner)
}
/// Account-facing plan classification derived from the current token.
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
/// mapped from the ID token's internal plan value. Prefer this when you
-6
View File
@@ -36,8 +36,6 @@ pub struct IdTokenInfo {
pub chatgpt_user_id: Option<String>,
/// Organization/workspace identifier associated with the token, if present.
pub chatgpt_account_id: Option<String>,
/// Whether the current user owns the workspace, when present in the token.
pub is_org_owner: Option<bool>,
pub raw_jwt: String,
}
@@ -90,8 +88,6 @@ struct AuthClaims {
user_id: Option<String>,
#[serde(default)]
chatgpt_account_id: Option<String>,
#[serde(default)]
is_org_owner: Option<bool>,
}
#[derive(Deserialize)]
@@ -143,7 +139,6 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoErr
chatgpt_plan_type: auth.chatgpt_plan_type,
chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id),
chatgpt_account_id: auth.chatgpt_account_id,
is_org_owner: auth.is_org_owner,
}),
None => Ok(IdTokenInfo {
email,
@@ -151,7 +146,6 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoErr
chatgpt_plan_type: None,
chatgpt_user_id: None,
chatgpt_account_id: None,
is_org_owner: None,
}),
}
}
-15
View File
@@ -107,21 +107,6 @@ fn id_token_info_parses_usage_based_business_plans() {
assert_eq!(enterprise_cbp.is_workspace_account(), true);
}
#[test]
fn id_token_info_parses_workspace_owner_flag() {
let fake_jwt = fake_jwt(serde_json::json!({
"email": "owner@example.com",
"https://api.openai.com/auth": {
"chatgpt_plan_type": "self_serve_business_usage_based",
"is_org_owner": true
}
}));
let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse");
assert_eq!(info.email.as_deref(), Some("owner@example.com"));
assert_eq!(info.is_org_owner, Some(true));
}
#[test]
fn id_token_info_handles_missing_fields() {
let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" }));
@@ -339,7 +339,6 @@ async fn run_codex_tool_session_inner(
| EventMsg::McpToolCallEnd(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::AddCreditsNudgeEmailResponse(_)
| EventMsg::RealtimeConversationListVoicesResponse(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::TerminalInteraction(_)
+14
View File
@@ -108,6 +108,12 @@ pub enum GuardianAssessmentStatus {
Aborted,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum GuardianAssessmentDecisionSource {
Agent,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum GuardianCommandSource {
@@ -159,6 +165,10 @@ pub struct NetworkPolicyAmendment {
pub struct GuardianAssessmentEvent {
/// Stable identifier for this guardian review lifecycle.
pub id: String,
/// Thread item being reviewed, when the review maps to a concrete item.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub target_item_id: Option<String>,
/// Turn ID that this assessment belongs to.
/// Uses `#[serde(default)]` for backwards compatibility.
#[serde(default)]
@@ -176,6 +186,10 @@ pub struct GuardianAssessmentEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub rationale: Option<String>,
/// Source that produced the terminal assessment decision.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub decision_source: Option<GuardianAssessmentDecisionSource>,
/// Canonical action payload that was reviewed.
pub action: GuardianAssessmentAction,
}
-1
View File
@@ -35,7 +35,6 @@ fn rate_limit_snapshot() -> RateLimitSnapshot {
resets_at: Some(secondary_reset_at),
}),
credits: None,
spend_control: None,
plan_type: None,
}
}
+30 -60
View File
@@ -5,6 +5,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -471,6 +472,9 @@ impl FileSystemSandboxPolicy {
// so root-wide aliases do not create duplicate top-level masks.
// Example: keep `/var/...` normalized under `/` instead of
// materializing both `/var/...` and `/private/var/...`.
// Nested symlink paths under a writable root stay logical so
// downstream sandboxes can still bind the real target while
// masking the user-visible symlink inode when needed.
let preserve_raw_carveout_paths = root.as_path().parent().is_some();
let raw_writable_roots: Vec<&AbsolutePathBuf> = writable_entries
.iter()
@@ -1081,14 +1085,17 @@ fn dedup_absolute_paths(
fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
let raw_path = path.to_path_buf();
for ancestor in raw_path.ancestors() {
let Ok(canonical_ancestor) = ancestor.canonicalize() else {
if std::fs::symlink_metadata(ancestor).is_err() {
continue;
}
let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else {
continue;
};
let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
continue;
};
if let Ok(normalized_path) =
AbsolutePathBuf::from_absolute_path(canonical_ancestor.join(suffix))
AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix))
{
return normalized_path;
}
@@ -1422,7 +1429,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn effective_runtime_roots_canonicalize_symlinked_paths() {
fn effective_runtime_roots_preserve_symlinked_paths() {
let cwd = TempDir::new().expect("tempdir");
let real_root = cwd.path().join("real");
let link_root = cwd.path().join("link");
@@ -1436,18 +1443,9 @@ mod tests {
let link_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_blocked = link_root.join("blocked");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_root.canonicalize().expect("canonicalize real root"),
)
.expect("absolute canonical root");
let expected_blocked = AbsolutePathBuf::from_absolute_path(
blocked.canonicalize().expect("canonicalize blocked"),
)
.expect("absolute canonical blocked");
let expected_codex = AbsolutePathBuf::from_absolute_path(
codex_dir.canonicalize().expect("canonicalize .codex"),
)
.expect("absolute canonical .codex");
let expected_root = link_root.clone();
let expected_blocked = link_blocked.clone();
let expected_codex = link_root.join(".codex");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
@@ -1482,7 +1480,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn current_working_directory_special_path_canonicalizes_symlinked_cwd() {
fn current_working_directory_special_path_preserves_symlinked_cwd() {
let cwd = TempDir::new().expect("tempdir");
let real_root = cwd.path().join("real");
let link_root = cwd.path().join("link");
@@ -1497,22 +1495,11 @@ mod tests {
let link_blocked =
AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_root.canonicalize().expect("canonicalize real root"),
)
.expect("absolute canonical root");
let expected_blocked = AbsolutePathBuf::from_absolute_path(
blocked.canonicalize().expect("canonicalize blocked"),
)
.expect("absolute canonical blocked");
let expected_agents = AbsolutePathBuf::from_absolute_path(
agents_dir.canonicalize().expect("canonicalize .agents"),
)
.expect("absolute canonical .agents");
let expected_codex = AbsolutePathBuf::from_absolute_path(
codex_dir.canonicalize().expect("canonicalize .codex"),
)
.expect("absolute canonical .codex");
let expected_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let expected_blocked = link_blocked.clone();
let expected_agents = expected_root.join(".agents");
let expected_codex = expected_root.join(".codex");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
@@ -1617,11 +1604,8 @@ mod tests {
let link_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_private = link_root.join("linked-private");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_root.canonicalize().expect("canonicalize real root"),
)
.expect("absolute canonical root");
let expected_linked_private = expected_root.join("linked-private");
let expected_root = link_root.clone();
let expected_linked_private = link_private.clone();
let unexpected_decoy =
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
.expect("absolute canonical decoy");
@@ -1667,11 +1651,8 @@ mod tests {
let link_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_private = link_root.join("linked-private");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_root.canonicalize().expect("canonicalize real root"),
)
.expect("absolute canonical root");
let expected_linked_private = expected_root.join("linked-private");
let expected_root = link_root.clone();
let expected_linked_private = link_private.clone();
let unexpected_decoy =
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
.expect("absolute canonical decoy");
@@ -1737,12 +1718,12 @@ mod tests {
#[cfg(unix)]
#[test]
fn tmpdir_special_path_canonicalizes_symlinked_tmpdir() {
fn tmpdir_special_path_preserves_symlinked_tmpdir() {
if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() {
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
.env(SYMLINKED_TMPDIR_TEST_ENV, "1")
.arg("--exact")
.arg("permissions::tests::tmpdir_special_path_canonicalizes_symlinked_tmpdir")
.arg("permissions::tests::tmpdir_special_path_preserves_symlinked_tmpdir")
.output()
.expect("run tmpdir subprocess test");
@@ -1767,20 +1748,10 @@ mod tests {
let link_blocked =
AbsolutePathBuf::from_absolute_path(link_tmpdir.join("blocked")).expect("link blocked");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_tmpdir
.canonicalize()
.expect("canonicalize real tmpdir"),
)
.expect("absolute canonical tmpdir");
let expected_blocked = AbsolutePathBuf::from_absolute_path(
blocked.canonicalize().expect("canonicalize blocked"),
)
.expect("absolute canonical blocked");
let expected_codex = AbsolutePathBuf::from_absolute_path(
codex_dir.canonicalize().expect("canonicalize .codex"),
)
.expect("absolute canonical .codex");
let expected_root =
AbsolutePathBuf::from_absolute_path(&link_tmpdir).expect("absolute symlinked tmpdir");
let expected_blocked = link_blocked.clone();
let expected_codex = expected_root.join(".codex");
unsafe {
std::env::set_var("TMPDIR", &link_tmpdir);
@@ -1932,8 +1903,7 @@ mod tests {
let cwd = TempDir::new().expect("tempdir");
let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
let expected_docs = AbsolutePathBuf::from_absolute_path(
cwd.path()
.canonicalize()
canonicalize_preserving_symlinks(cwd.path())
.expect("canonicalize cwd")
.join("docs"),
)

Some files were not shown because too many files have changed in this diff Show More