diff --git a/.github/workflows/Dockerfile.bazel b/.github/workflows/Dockerfile.bazel index 51c199dcc3..4f85409f94 100644 --- a/.github/workflows/Dockerfile.bazel +++ b/.github/workflows/Dockerfile.bazel @@ -8,9 +8,25 @@ FROM ubuntu:24.04 RUN apt-get update && \ apt-get install -y --no-install-recommends \ - curl git python3 ca-certificates && \ + curl git python3 ca-certificates xz-utils && \ rm -rf /var/lib/apt/lists/* +COPY codex-rs/node-version.txt /tmp/node-version.txt + +RUN set -eux; \ + node_arch="$(dpkg --print-architecture)"; \ + case "${node_arch}" in \ + amd64) node_dist_arch="x64" ;; \ + arm64) node_dist_arch="arm64" ;; \ + *) echo "unsupported architecture: ${node_arch}"; exit 1 ;; \ + esac; \ + node_version="$(tr -d '[:space:]' , +/** + * Optional nicknames/roles for receivers. + */ +receiver_agents?: Array, /** * ID of the waiting call. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts index 57f914c134..929d59c611 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AgentStatus } from "./AgentStatus"; +import type { CollabAgentStatusEntry } from "./CollabAgentStatusEntry"; import type { ThreadId } from "./ThreadId"; export type CollabWaitingEndEvent = { @@ -13,6 +14,10 @@ sender_thread_id: ThreadId, * ID of the waiting call. */ call_id: string, +/** + * Optional receiver metadata paired with final statuses. + */ +agent_statuses?: Array, /** * Last known status of the receiver agents reported to the sender agent. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts b/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts index 269a411be9..df261bf3ea 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadId } from "./ThreadId"; -export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, } } | "memory_consolidation" | { "other": string }; +export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 79d8bdd98e..cda04def82 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -33,8 +33,10 @@ export type { ClientRequest } from "./ClientRequest"; export type { CodexErrorInfo } from "./CodexErrorInfo"; export type { CollabAgentInteractionBeginEvent } from "./CollabAgentInteractionBeginEvent"; export type { CollabAgentInteractionEndEvent } from "./CollabAgentInteractionEndEvent"; +export type { CollabAgentRef } from "./CollabAgentRef"; export type { CollabAgentSpawnBeginEvent } from "./CollabAgentSpawnBeginEvent"; export type { CollabAgentSpawnEndEvent } from "./CollabAgentSpawnEndEvent"; +export type { CollabAgentStatusEntry } from "./CollabAgentStatusEntry"; export type { CollabCloseBeginEvent } from "./CollabCloseBeginEvent"; export type { CollabCloseEndEvent } from "./CollabCloseEndEvent"; export type { CollabResumeBeginEvent } from "./CollabResumeBeginEvent"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts similarity index 71% rename from codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts index bc44835a76..e92cd8e28b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts @@ -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 AppDisabledReason = "unknown" | "user"; +export type AppToolApproval = "auto" | "prompt" | "approve"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts new file mode 100644 index 0000000000..16a1c22c6d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppToolApproval } from "./AppToolApproval"; + +export type AppToolsConfig = { [key in string]?: { enabled: boolean | null, approval_mode: AppToolApproval | null, } }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts index ae3e455201..b22997de97 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -1,6 +1,8 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AppDisabledReason } from "./AppDisabledReason"; +import type { AppToolApproval } from "./AppToolApproval"; +import type { AppToolsConfig } from "./AppToolsConfig"; +import type { AppsDefaultConfig } from "./AppsDefaultConfig"; -export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } }; +export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts new file mode 100644 index 0000000000..e73386027e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppsDefaultConfig = { enabled: boolean, destructive_enabled: boolean, open_world_enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 290ce5669c..4021e50a6a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandAction } from "./CommandAction"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; +import type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type CommandExecutionRequestApprovalParams = { threadId: string, turnId: string, itemId: string, /** @@ -19,6 +20,10 @@ approvalId?: string | null, * Optional explanatory reason (e.g. request for network access). */ reason?: string | null, +/** + * Optional context for managed-network approval prompts. + */ +networkApprovalContext?: NetworkApprovalContext | null, /** * The command to be executed. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index 3066e65406..ebbe3b32d4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -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 FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalContext.ts new file mode 100644 index 0000000000..b4b78e473c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalContext.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; + +export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalProtocol.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalProtocol.ts new file mode 100644 index 0000000000..9dd4066fd1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalProtocol.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkApprovalProtocol = "http" | "https" | "socks5Tcp" | "socks5Udp"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index b7ac9d2f7a..6205de1f4b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -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 NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 523a028583..5e7a6b7af4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -43,6 +43,14 @@ cliVersion: string, * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ source: SessionSource, +/** + * Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + */ +agentNickname: string | null, +/** + * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + */ +agentRole: string | null, /** * Optional Git metadata captured when the thread was created. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index e595d0ce0b..c2f8494785 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -7,13 +7,15 @@ export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AnalyticsConfig } from "./AnalyticsConfig"; export type { AppBranding } from "./AppBranding"; -export type { AppDisabledReason } from "./AppDisabledReason"; export type { AppInfo } from "./AppInfo"; export type { AppListUpdatedNotification } from "./AppListUpdatedNotification"; export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; +export type { AppToolApproval } from "./AppToolApproval"; +export type { AppToolsConfig } from "./AppToolsConfig"; export type { AppsConfig } from "./AppsConfig"; +export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; @@ -100,6 +102,8 @@ export type { ModelListResponse } from "./ModelListResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; export type { NetworkAccess } from "./NetworkAccess"; +export type { NetworkApprovalContext } from "./NetworkApprovalContext"; +export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NetworkRequirements } from "./NetworkRequirements"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 2cb3f9d1f1..1a4edfdf57 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -32,10 +32,93 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::sync::LazyLock; use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; +static VARIANT_TITLE_COLLISION_OVERRIDES: LazyLock> = + LazyLock::new(|| { + HashMap::from([ + ( + "base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=ready|only_property=state|required_only=state", + "StateMcpStartupStatus2", + ), + ( + "base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=cancelled|only_property=state|required_only=state", + "StateMcpStartupStatus3", + ), + ]) + }); +static NUMBERED_DEFINITION_COLLISION_OVERRIDES: LazyLock> = + LazyLock::new(|| { + HashMap::from([ + ( + "schema=ClientRequest|container=definitions|generated=AskForApproval2|base=AskForApproval", + "AskForApproval2", + ), + ( + "schema=ClientRequest|container=definitions|generated=NetworkAccess2|base=NetworkAccess", + "NetworkAccess2", + ), + ( + "schema=ClientRequest|container=definitions|generated=ReadOnlyAccess2|base=ReadOnlyAccess", + "ReadOnlyAccess2", + ), + ( + "schema=ClientRequest|container=definitions|generated=SandboxMode2|base=SandboxMode", + "SandboxMode2", + ), + ( + "schema=ClientRequest|container=definitions|generated=SandboxPolicy2|base=SandboxPolicy", + "SandboxPolicy2", + ), + ( + "schema=ServerNotification|container=definitions|generated=ByteRange2|base=ByteRange", + "ByteRange2", + ), + ( + "schema=ServerNotification|container=definitions|generated=CodexErrorInfo2|base=CodexErrorInfo", + "CodexErrorInfo2", + ), + ( + "schema=ServerNotification|container=definitions|generated=CreditsSnapshot2|base=CreditsSnapshot", + "CreditsSnapshot2", + ), + ( + "schema=ServerNotification|container=definitions|generated=MessagePhase2|base=MessagePhase", + "MessagePhase2", + ), + ( + "schema=ServerNotification|container=definitions|generated=ModelRerouteReason2|base=ModelRerouteReason", + "ModelRerouteReason2", + ), + ( + "schema=ServerNotification|container=definitions|generated=PatchApplyStatus2|base=PatchApplyStatus", + "PatchApplyStatus2", + ), + ( + "schema=ServerNotification|container=definitions|generated=RateLimitSnapshot2|base=RateLimitSnapshot", + "RateLimitSnapshot2", + ), + ( + "schema=ServerNotification|container=definitions|generated=RateLimitWindow2|base=RateLimitWindow", + "RateLimitWindow2", + ), + ( + "schema=ServerNotification|container=definitions|generated=TextElement2|base=TextElement", + "TextElement2", + ), + ( + "schema=ServerNotification|container=definitions|generated=UserInput2|base=UserInput", + "UserInput2", + ), + ( + "schema=ServerNotification|container=definitions|generated=WebSearchAction2|base=WebSearchAction", + "WebSearchAction2", + ), + ]) + }); #[derive(Clone)] pub struct GeneratedSchema { @@ -950,6 +1033,7 @@ where let file_stem = name.trim(); let schema = schema_for!(T); let mut schema_value = serde_json::to_value(schema)?; + enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value); annotate_schema(&mut schema_value, Some(file_stem)); // If the name looks like a namespaced path (e.g., "v2::Type"), mirror // the TypeScript layout and write to out_dir/v2/Type.json. Otherwise @@ -980,6 +1064,101 @@ where }) } +fn enforce_numbered_definition_collision_overrides(schema_name: &str, schema: &mut Value) { + for defs_key in ["definitions", "$defs"] { + let renames = { + let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else { + continue; + }; + collect_numbered_definition_collision_renames(schema_name, defs_key, defs) + }; + + if renames.is_empty() { + continue; + } + + #[expect(clippy::expect_used)] + let defs = schema + .get_mut(defs_key) + .and_then(Value::as_object_mut) + .expect("definition map should still be present"); + for (old_name, new_name, collision_key) in &renames { + if old_name == new_name { + continue; + } + assert!( + !defs.contains_key(new_name), + "Numbered definition collision override produced duplicate definition {new_name} for {collision_key}", + ); + let schema_value = defs.remove(old_name).unwrap_or_else(|| { + panic!("Missing generated numbered definition {old_name} for {collision_key}") + }); + defs.insert(new_name.clone(), schema_value); + } + + for (old_name, new_name, _) in renames { + if old_name != new_name { + rewrite_named_local_ref(schema, defs_key, &old_name, &new_name); + } + } + } +} + +fn collect_numbered_definition_collision_renames( + schema_name: &str, + defs_key: &str, + defs: &Map, +) -> Vec<(String, String, String)> { + let mut renames = Vec::new(); + + for generated_name in defs.keys() { + let base_name = generated_name.trim_end_matches(|c: char| c.is_ascii_digit()); + if base_name == generated_name || !defs.contains_key(base_name) { + continue; + } + + let collision_key = format!( + "schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}" + ); + let replacement = NUMBERED_DEFINITION_COLLISION_OVERRIDES + .get(collision_key.as_str()) + .map(|value| (*value).to_string()) + .unwrap_or_else(|| { + panic!("Missing numbered definition collision override for {collision_key}") + }); + renames.push((generated_name.clone(), replacement, collision_key)); + } + + renames +} + +fn rewrite_named_local_ref(value: &mut Value, defs_key: &str, old_name: &str, new_name: &str) { + let direct = format!("#/{defs_key}/{old_name}"); + let prefixed = format!("{direct}/"); + let replacement = format!("#/{defs_key}/{new_name}"); + let replacement_prefixed = format!("{replacement}/"); + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") { + if reference == &direct { + *reference = replacement; + } else if let Some(rest) = reference.strip_prefix(&prefixed) { + *reference = format!("{replacement_prefixed}{rest}"); + } + } + for child in obj.values_mut() { + rewrite_named_local_ref(child, defs_key, old_name, new_name); + } + } + Value::Array(items) => { + for child in items { + rewrite_named_local_ref(child, defs_key, old_name, new_name); + } + } + _ => {} + } +} + pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result where T: JsonSchema, @@ -1198,10 +1377,20 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { && let Some(name) = variant_definition_name(base_name, variant) { let mut candidate = name.clone(); - let mut index = 2; - while seen.contains(&candidate) { - candidate = format!("{name}{index}"); - index += 1; + if seen.contains(&candidate) { + let collision_key = variant_title_collision_key(base_name, &name, variant); + candidate = VARIANT_TITLE_COLLISION_OVERRIDES + .get(collision_key.as_str()) + .map(|value| (*value).to_string()) + .unwrap_or_else(|| { + panic!( + "Missing variant title collision override for {collision_key} (generated name: {name})" + ) + }); + assert!( + !seen.contains(&candidate), + "Variant title collision override produced duplicate title {candidate} for {collision_key}", + ); } if let Some(obj) = variant.as_object_mut() { obj.insert("title".into(), Value::String(candidate.clone())); @@ -1221,6 +1410,48 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { } } +fn variant_title_collision_key(base: &str, generated_name: &str, variant: &Value) -> String { + let mut parts = vec![ + format!("base={base}"), + format!("generated={generated_name}"), + ]; + + if let Some(props) = variant.get("properties").and_then(Value::as_object) { + for key in DISCRIMINATOR_KEYS { + if let Some(value) = literal_from_property(props, key) { + parts.push(format!("{key}={value}")); + } + } + for (key, value) in props { + if DISCRIMINATOR_KEYS.contains(&key.as_str()) { + continue; + } + if let Some(literal) = string_literal(value) { + parts.push(format!("literal:{key}={literal}")); + } + } + + if props.len() == 1 + && let Some(key) = props.keys().next() + { + parts.push(format!("only_property={key}")); + } + } + + if let Some(required) = variant.get("required").and_then(Value::as_array) + && required.len() == 1 + && let Some(key) = required[0].as_str() + { + parts.push(format!("required_only={key}")); + } + + if parts.len() == 2 { + parts.push(format!("variant={variant}")); + } + + parts.join("|") +} + const DISCRIMINATOR_KEYS: &[&str] = &["type", "method", "mode", "status", "role", "reason"]; fn set_discriminator_titles(props: &mut Map, owner: &str) { @@ -1475,7 +1706,18 @@ mod tests { .file_stem() .and_then(|stem| stem.to_str()) .is_some_and(|stem| { - stem.ends_with("Params") || stem == "InitializeCapabilities" + stem.ends_with("Params") + || stem == "InitializeCapabilities" + || matches!( + stem, + "CollabAgentRef" + | "CollabAgentStatusEntry" + | "CollabAgentSpawnEndEvent" + | "CollabAgentInteractionEndEvent" + | "CollabCloseEndEvent" + | "CollabResumeBeginEvent" + | "CollabResumeEndEvent" + ) }); let contents = fs::read_to_string(&path)?; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index e5005077e5..fce6f4f263 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -24,9 +24,13 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ContextCompactedEvent; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::RolloutItem; @@ -36,8 +40,10 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::ViewImageToolCallEvent; +use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; use std::collections::HashMap; +use tracing::warn; use uuid::Uuid; #[cfg(test)] @@ -57,14 +63,20 @@ pub fn build_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { builder.finish() } -struct ThreadHistoryBuilder { +pub struct ThreadHistoryBuilder { turns: Vec, current_turn: Option, next_item_index: i64, } +impl Default for ThreadHistoryBuilder { + fn default() -> Self { + Self::new() + } +} + impl ThreadHistoryBuilder { - fn new() -> Self { + pub fn new() -> Self { Self { turns: Vec::new(), current_turn: None, @@ -72,14 +84,32 @@ impl ThreadHistoryBuilder { } } - fn finish(mut self) -> Vec { + pub fn reset(&mut self) { + *self = Self::new(); + } + + pub fn finish(mut self) -> Vec { self.finish_current_turn(); self.turns } + pub fn active_turn_snapshot(&self) -> Option { + self.current_turn + .as_ref() + .map(Turn::from) + .or_else(|| self.turns.last().cloned()) + } + + pub fn has_active_turn(&self) -> bool { + self.current_turn.is_some() + } + + /// Shared reducer for persisted rollout replay and in-memory current-turn + /// tracking used by running thread resume/rejoin. + /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), EventMsg::AgentMessage(payload) => { @@ -89,21 +119,35 @@ impl ThreadHistoryBuilder { EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) } + EventMsg::WebSearchBegin(payload) => self.handle_web_search_begin(payload), EventMsg::WebSearchEnd(payload) => self.handle_web_search_end(payload), + EventMsg::ExecCommandBegin(payload) => self.handle_exec_command_begin(payload), EventMsg::ExecCommandEnd(payload) => self.handle_exec_command_end(payload), + EventMsg::PatchApplyBegin(payload) => self.handle_patch_apply_begin(payload), EventMsg::PatchApplyEnd(payload) => self.handle_patch_apply_end(payload), + EventMsg::McpToolCallBegin(payload) => self.handle_mcp_tool_call_begin(payload), EventMsg::McpToolCallEnd(payload) => self.handle_mcp_tool_call_end(payload), EventMsg::ViewImageToolCall(payload) => self.handle_view_image_tool_call(payload), + EventMsg::CollabAgentSpawnBegin(payload) => { + self.handle_collab_agent_spawn_begin(payload) + } EventMsg::CollabAgentSpawnEnd(payload) => self.handle_collab_agent_spawn_end(payload), + EventMsg::CollabAgentInteractionBegin(payload) => { + self.handle_collab_agent_interaction_begin(payload) + } EventMsg::CollabAgentInteractionEnd(payload) => { self.handle_collab_agent_interaction_end(payload) } + EventMsg::CollabWaitingBegin(payload) => self.handle_collab_waiting_begin(payload), EventMsg::CollabWaitingEnd(payload) => self.handle_collab_waiting_end(payload), + EventMsg::CollabCloseBegin(payload) => self.handle_collab_close_begin(payload), EventMsg::CollabCloseEnd(payload) => self.handle_collab_close_end(payload), + EventMsg::CollabResumeBegin(payload) => self.handle_collab_resume_begin(payload), EventMsg::CollabResumeEnd(payload) => self.handle_collab_resume_end(payload), EventMsg::ContextCompacted(payload) => self.handle_context_compacted(payload), EventMsg::EnteredReviewMode(payload) => self.handle_entered_review_mode(payload), EventMsg::ExitedReviewMode(payload) => self.handle_exited_review_mode(payload), + EventMsg::ItemStarted(payload) => self.handle_item_started(payload), EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), EventMsg::Error(payload) => self.handle_error(payload), EventMsg::TokenCount(_) => {} @@ -116,7 +160,7 @@ impl ThreadHistoryBuilder { } } - fn handle_rollout_item(&mut self, item: &RolloutItem) { + pub fn handle_rollout_item(&mut self, item: &RolloutItem) { match item { RolloutItem::EventMsg(event) => self.handle_event(event), RolloutItem::Compacted(payload) => self.handle_compacted(payload), @@ -199,15 +243,51 @@ impl ThreadHistoryBuilder { }); } - fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) { - if let codex_protocol::items::TurnItem::Plan(plan) = &payload.item - && plan.text.is_empty() - { - return; + fn handle_item_started(&mut self, payload: &ItemStartedEvent) { + match &payload.item { + codex_protocol::items::TurnItem::Plan(plan) => { + if plan.text.is_empty() { + return; + } + self.upsert_item_in_turn_id( + &payload.turn_id, + ThreadItem::from(payload.item.clone()), + ); + } + codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::AgentMessage(_) + | codex_protocol::items::TurnItem::Reasoning(_) + | codex_protocol::items::TurnItem::WebSearch(_) + | codex_protocol::items::TurnItem::ContextCompaction(_) => {} } + } - let item = ThreadItem::from(payload.item.clone()); - self.ensure_turn().items.push(item); + fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) { + match &payload.item { + codex_protocol::items::TurnItem::Plan(plan) => { + if plan.text.is_empty() { + return; + } + self.upsert_item_in_turn_id( + &payload.turn_id, + ThreadItem::from(payload.item.clone()), + ); + } + codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::AgentMessage(_) + | codex_protocol::items::TurnItem::Reasoning(_) + | codex_protocol::items::TurnItem::WebSearch(_) + | codex_protocol::items::TurnItem::ContextCompaction(_) => {} + } + } + + fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) { + let item = ThreadItem::WebSearch { + id: payload.call_id.clone(), + query: String::new(), + action: None, + }; + self.upsert_item_in_current_turn(item); } fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) { @@ -216,7 +296,30 @@ impl ThreadHistoryBuilder { query: payload.query.clone(), action: Some(WebSearchAction::from(payload.action.clone())), }; - self.ensure_turn().items.push(item); + self.upsert_item_in_current_turn(item); + } + + fn handle_exec_command_begin(&mut self, payload: &ExecCommandBeginEvent) { + let command = shlex::try_join(payload.command.iter().map(String::as_str)) + .unwrap_or_else(|_| payload.command.join(" ")); + let command_actions = payload + .parsed_cmd + .iter() + .cloned() + .map(CommandAction::from) + .collect(); + let item = ThreadItem::CommandExecution { + id: payload.call_id.clone(), + command, + cwd: payload.cwd.clone(), + process_id: payload.process_id.clone(), + status: CommandExecutionStatus::InProgress, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + self.upsert_item_in_turn_id(&payload.turn_id, item); } fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) { @@ -246,33 +349,25 @@ impl ThreadHistoryBuilder { exit_code: Some(payload.exit_code), duration_ms: Some(duration_ms), }; - // Command completions can arrive out of order. Unified exec may return // while a PTY is still running, then emit ExecCommandEnd later from a // background exit watcher when that process finally exits. By then, a // newer user turn may already have started. Route by event turn_id so // replay preserves the original turn association. - if let Some(turn) = self.current_turn.as_mut() - && turn.id == payload.turn_id - { - turn.items.push(item); - return; - } + self.upsert_item_in_turn_id(&payload.turn_id, item); + } - // If the originating turn is already finalized, append there instead - // of attaching to whichever turn is currently active during replay. - if let Some(turn) = self - .turns - .iter_mut() - .find(|turn| turn.id == payload.turn_id) - { - turn.items.push(item); - return; + fn handle_patch_apply_begin(&mut self, payload: &PatchApplyBeginEvent) { + let item = ThreadItem::FileChange { + id: payload.call_id.clone(), + changes: convert_patch_changes(&payload.changes), + status: PatchApplyStatus::InProgress, + }; + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); } - - // Backward-compatibility fallback for partial/legacy streams where the - // event turn_id does not match any known replay turn. - self.ensure_turn().items.push(item); } fn handle_patch_apply_end(&mut self, payload: &PatchApplyEndEvent) { @@ -282,7 +377,29 @@ impl ThreadHistoryBuilder { changes: convert_patch_changes(&payload.changes), status, }; - self.ensure_turn().items.push(item); + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } + + fn handle_mcp_tool_call_begin(&mut self, payload: &McpToolCallBeginEvent) { + let item = ThreadItem::McpToolCall { + id: payload.call_id.clone(), + server: payload.invocation.server.clone(), + tool: payload.invocation.tool.clone(), + status: McpToolCallStatus::InProgress, + arguments: payload + .invocation + .arguments + .clone() + .unwrap_or(serde_json::Value::Null), + result: None, + error: None, + duration_ms: None, + }; + self.upsert_item_in_current_turn(item); } fn handle_mcp_tool_call_end(&mut self, payload: &McpToolCallEndEvent) { @@ -321,7 +438,7 @@ impl ThreadHistoryBuilder { error, duration_ms, }; - self.ensure_turn().items.push(item); + self.upsert_item_in_current_turn(item); } fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) { @@ -329,7 +446,23 @@ impl ThreadHistoryBuilder { id: payload.call_id.clone(), path: payload.path.to_string_lossy().into_owned(), }; - self.ensure_turn().items.push(item); + self.upsert_item_in_current_turn(item); + } + + fn handle_collab_agent_spawn_begin( + &mut self, + payload: &codex_protocol::protocol::CollabAgentSpawnBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some(payload.prompt.clone()), + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_agent_spawn_end( @@ -353,17 +486,31 @@ impl ThreadHistoryBuilder { } None => (Vec::new(), HashMap::new()), }; - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::SpawnAgent, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids, - prompt: Some(payload.prompt.clone()), - agents_states, - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SpawnAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: Some(payload.prompt.clone()), + agents_states, + }); + } + + fn handle_collab_agent_interaction_begin( + &mut self, + payload: &codex_protocol::protocol::CollabAgentInteractionBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: Some(payload.prompt.clone()), + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_agent_interaction_end( @@ -376,17 +523,35 @@ impl ThreadHistoryBuilder { }; let receiver_id = payload.receiver_thread_id.to_string(); let received_status = CollabAgentState::from(payload.status.clone()); - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::SendInput, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id.clone()], - prompt: Some(payload.prompt.clone()), - agents_states: [(receiver_id, received_status)].into_iter().collect(), - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SendInput, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: Some(payload.prompt.clone()), + agents_states: [(receiver_id, received_status)].into_iter().collect(), + }); + } + + fn handle_collab_waiting_begin( + &mut self, + payload: &codex_protocol::protocol::CollabWaitingBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: payload + .receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(), + prompt: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_waiting_end( @@ -410,17 +575,31 @@ impl ThreadHistoryBuilder { .iter() .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) .collect(); - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::Wait, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids, - prompt: None, - agents_states, - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::Wait, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + agents_states, + }); + } + + fn handle_collab_close_begin( + &mut self, + payload: &codex_protocol::protocol::CollabCloseBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_close_end(&mut self, payload: &codex_protocol::protocol::CollabCloseEndEvent) { @@ -435,17 +614,31 @@ impl ThreadHistoryBuilder { )] .into_iter() .collect(); - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::CloseAgent, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id], - prompt: None, - agents_states, - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + agents_states, + }); + } + + fn handle_collab_resume_begin( + &mut self, + payload: &codex_protocol::protocol::CollabResumeBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_resume_end( @@ -463,17 +656,15 @@ impl ThreadHistoryBuilder { )] .into_iter() .collect(); - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::ResumeAgent, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id], - prompt: None, - agents_states, - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::ResumeAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + agents_states, + }); } fn handle_context_compacted(&mut self, _payload: &ContextCompactedEvent) { @@ -548,6 +739,7 @@ impl ThreadHistoryBuilder { self.finish_current_turn(); self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) + .with_status(TurnStatus::InProgress) .opened_explicitly(), ); } @@ -642,6 +834,30 @@ impl ThreadHistoryBuilder { unreachable!("current turn must exist after initialization"); } + fn upsert_item_in_turn_id(&mut self, turn_id: &str, item: ThreadItem) { + if let Some(turn) = self.current_turn.as_mut() + && turn.id == turn_id + { + upsert_turn_item(&mut turn.items, item); + return; + } + + if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { + upsert_turn_item(&mut turn.items, item); + return; + } + + warn!( + item_id = item.id(), + "dropping turn-scoped item for unknown turn id `{turn_id}`" + ); + } + + fn upsert_item_in_current_turn(&mut self, item: ThreadItem) { + let turn = self.ensure_turn(); + upsert_turn_item(&mut turn.items, item); + } + fn next_item_id(&mut self) -> String { let id = format!("item-{}", self.next_item_index); self.next_item_index += 1; @@ -684,7 +900,7 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String { } } -fn convert_patch_changes( +pub fn convert_patch_changes( changes: &HashMap, ) -> Vec { let mut converted: Vec = changes @@ -726,6 +942,17 @@ fn format_file_change_diff(change: &codex_protocol::protocol::FileChange) -> Str } } +fn upsert_turn_item(items: &mut Vec, item: ThreadItem) { + if let Some(existing_item) = items + .iter_mut() + .find(|existing_item| existing_item.id() == item.id()) + { + *existing_item = item; + return; + } + items.push(item); +} + struct PendingTurn { id: String, items: Vec, @@ -744,6 +971,11 @@ impl PendingTurn { self.opened_explicitly = true; self } + + fn with_status(mut self, status: TurnStatus) -> Self { + self.status = status; + self + } } impl From for Turn { @@ -757,10 +989,23 @@ impl From for Turn { } } +impl From<&PendingTurn> for Turn { + fn from(value: &PendingTurn) -> Self { + Self { + id: value.id.clone(), + items: value.items.clone(), + error: value.error.clone(), + status: value.status.clone(), + } + } +} + #[cfg(test)] mod tests { use super::*; use codex_protocol::ThreadId; + use codex_protocol::items::TurnItem as CoreTurnItem; + use codex_protocol::items::UserMessageItem as CoreUserMessageItem; use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; @@ -771,6 +1016,7 @@ mod tests { use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::ThreadRolledBackEvent; @@ -816,11 +1062,11 @@ mod tests { }), ]; - let items = events - .into_iter() - .map(RolloutItem::EventMsg) - .collect::>(); - let turns = build_turns_from_rollout_items(&items); + let mut builder = ThreadHistoryBuilder::new(); + for event in &events { + builder.handle_event(event); + } + let turns = builder.finish(); assert_eq!(turns.len(), 2); let first = &turns[0]; @@ -883,6 +1129,55 @@ mod tests { ); } + #[test] + fn ignores_non_plan_item_lifecycle_events() { + let turn_id = "turn-1"; + let thread_id = ThreadId::new(); + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::ItemStarted(ItemStartedEvent { + thread_id, + turn_id: turn_id.to_string(), + item: CoreTurnItem::UserMessage(CoreUserMessageItem { + id: "user-item-id".to_string(), + content: Vec::new(), + }), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + } + ); + } + #[test] fn preserves_agent_message_phase_in_history() { let events = vec![EventMsg::AgentMessage(AgentMessageEvent { @@ -1212,6 +1507,11 @@ mod tests { #[test] fn reconstructs_tool_items_from_persisted_completion_events() { let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), EventMsg::UserMessage(UserMessageEvent { message: "run tools".into(), images: None, @@ -1311,6 +1611,11 @@ mod tests { #[test] fn reconstructs_declined_exec_and_patch_items() { let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), EventMsg::UserMessage(UserMessageEvent { message: "run tools".into(), images: None, @@ -1471,6 +1776,82 @@ mod tests { ); } + #[test] + fn drops_late_turn_scoped_item_for_unknown_turn_id() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "first".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + }), + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-b".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "second".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "exec-unknown-turn".into(), + process_id: Some("pid-42".into()), + turn_id: "turn-missing".into(), + command: vec!["echo".into(), "done".into()], + cwd: PathBuf::from("/tmp"), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo done".into(), + }], + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done\n".into(), + stderr: String::new(), + aggregated_output: "done\n".into(), + exit_code: 0, + duration: Duration::from_millis(5), + formatted_output: "done\n".into(), + status: CoreExecCommandStatus::Completed, + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-b".into(), + last_agent_message: None, + }), + ]; + + let mut builder = ThreadHistoryBuilder::new(); + for event in &events { + builder.handle_event(event); + } + let turns = builder.finish(); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].id, "turn-a"); + assert_eq!(turns[1].id, "turn-b"); + assert_eq!(turns[0].items.len(), 1); + assert_eq!(turns[1].items.len(), 1); + assert_eq!( + turns[1].items[0], + ThreadItem::UserMessage { + id: "item-2".into(), + content: vec![UserInput::Text { + text: "second".into(), + text_elements: Vec::new(), + }], + } + ); + } + #[test] fn late_turn_complete_does_not_close_active_turn() { let events = vec![ @@ -1572,7 +1953,7 @@ mod tests { assert_eq!(turns.len(), 2); assert_eq!(turns[0].id, "turn-a"); assert_eq!(turns[1].id, "turn-b"); - assert_eq!(turns[1].status, TurnStatus::Completed); + assert_eq!(turns[1].status, TurnStatus::InProgress); assert_eq!(turns[1].items.len(), 2); } @@ -1621,6 +2002,8 @@ mod tests { .expect("valid sender thread id"), receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") .expect("valid receiver thread id"), + receiver_agent_nickname: None, + receiver_agent_role: None, status: AgentStatus::Completed(None), }), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 328fdc0912..833e628214 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5,6 +5,8 @@ use crate::protocol::common::AuthMode; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ForcedLoginMethod; @@ -396,12 +398,41 @@ pub struct AnalyticsConfig { pub additional: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppToolApproval { + Auto, + Prompt, + Approve, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] -pub enum AppDisabledReason { - Unknown, - User, +pub struct AppsDefaultConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_enabled")] + pub destructive_enabled: bool, + #[serde(default = "default_enabled")] + pub open_world_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolConfig { + pub enabled: Option, + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolsConfig { + #[serde(default, flatten)] + pub tools: HashMap, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -410,15 +441,20 @@ pub enum AppDisabledReason { pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, - pub disabled_reason: Option, + pub destructive_enabled: Option, + pub open_world_enabled: Option, + pub default_tools_approval_mode: Option, + pub default_tools_enabled: Option, + pub tools: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] pub struct AppsConfig { + #[serde(default, rename = "_default")] + pub default: Option, #[serde(default, flatten)] - #[schemars(with = "HashMap")] pub apps: HashMap, } @@ -575,6 +611,7 @@ pub struct NetworkRequirements { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -650,6 +687,32 @@ pub enum CommandExecutionApprovalDecision { Cancel, } +v2_enum_from_core! { + pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { + Http, + Https, + Socks5Tcp, + Socks5Udp, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkApprovalContext { + pub host: String, + pub protocol: NetworkApprovalProtocol, +} + +impl From for NetworkApprovalContext { + fn from(value: CoreNetworkApprovalContext) -> Self { + Self { + host: value.host, + protocol: value.protocol.into(), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1443,6 +1506,8 @@ pub struct FeedbackUploadParams { #[ts(optional = nullable)] pub thread_id: Option, pub include_logs: bool, + #[ts(optional = nullable)] + pub extra_log_files: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -2206,6 +2271,10 @@ pub struct Thread { pub cli_version: String, /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). pub source: SessionSource, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, /// Optional Git metadata captured when the thread was created. pub git_info: Option, /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` @@ -2727,6 +2796,26 @@ pub enum ThreadItem { ContextCompaction { id: String }, } +impl ThreadItem { + pub fn id(&self) -> &str { + match self { + ThreadItem::UserMessage { id, .. } + | ThreadItem::AgentMessage { id, .. } + | ThreadItem::Plan { id, .. } + | ThreadItem::Reasoning { id, .. } + | ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageView { id, .. } + | ThreadItem::EnteredReviewMode { id, .. } + | ThreadItem::ExitedReviewMode { id, .. } + | ThreadItem::ContextCompaction { id, .. } => id, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type", rename_all = "camelCase")] @@ -3307,6 +3396,10 @@ pub struct CommandExecutionRequestApprovalParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub reason: Option, + /// Optional context for managed-network approval prompts. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub network_approval_context: Option, /// The command to be executed. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 7b846c80f1..a1d10be334 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1491,6 +1491,7 @@ impl CodexClient { item_id, approval_id, reason, + network_approval_context, command, cwd, command_actions, @@ -1506,6 +1507,9 @@ impl CodexClient { if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } + if let Some(network_approval_context) = network_approval_context.as_ref() { + println!("< network approval context: {network_approval_context:?}"); + } if let Some(command) = command.as_deref() { println!("< command: {command}"); } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 84ace4e38d..7b90affe69 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -48,9 +48,10 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } +tokio-util = { workspace = true } tokio-tungstenite = { workspace = true } tracing = { workspace = true, features = ["log"] } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index fcae5b3069..8ee15688c6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -28,6 +28,11 @@ Supported transports: Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. +Tracing/log output: + +- `RUST_LOG` controls log filtering/verbosity. +- Set `LOG_FORMAT=json` to emit app-server tracing logs to `stderr` as JSON (one event per line). + Backpressure behavior: - The server uses bounded queues between transport ingress, request processing, and outbound writes. @@ -145,7 +150,7 @@ Example with notification opt-out: - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); returns `{ started: true }` immediately and later emits `windowsSandbox/setupCompleted`. -- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. +- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `config/read` — fetch the effective config on disk after resolving config layering. - `config/value/write` — write a single config key/value to the user's config.toml on disk. @@ -224,6 +229,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). - `cwd` — restrict results to threads whose session cwd exactly matches this path. +- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. Example: @@ -235,7 +241,7 @@ Example: } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" } }, + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } ], "nextCursor": "opaque-token-or-null" @@ -271,7 +277,7 @@ When `nextCursor` is `null`, you’ve reached the final page. ### Example: Read a thread -Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. +Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. ```json { "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } @@ -661,7 +667,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap Order of messages: 1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. -2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), `reason`, plus `command`, `cwd`, and `commandActions` for friendly display. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. 3. Client response — `{ "decision": "accept", "acceptSettings": { "forSession": false } }` or `{ "decision": "decline" }`. 4. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b206eac355..fca2bcc1c1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -44,8 +44,8 @@ use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::PatchApplyStatus; -use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::PlanDeltaNotification; use codex_app_server_protocol::RawResponseItemCompletedNotification; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; @@ -72,6 +72,7 @@ use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::build_turns_from_rollout_items; +use codex_app_server_protocol::convert_patch_changes; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::parse_command::shlex_join; @@ -81,7 +82,6 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::FileChange as CoreFileChange; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; @@ -107,6 +107,17 @@ use tracing::error; type JsonValue = serde_json::Value; +enum CommandExecutionApprovalPresentation { + Network(V2NetworkApprovalContext), + Command(CommandExecutionCompletionItem), +} + +struct CommandExecutionCompletionItem { + command: String, + cwd: PathBuf, + command_actions: Vec, +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, @@ -246,6 +257,7 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, + network_approval_context, proposed_execpolicy_amendment, parsed_cmd, .. @@ -281,7 +293,32 @@ pub(crate) async fn apply_bespoke_event_handling( .cloned() .map(V2ParsedCommand::from) .collect::>(); - let command_string = shlex_join(&command); + let presentation = if let Some(network_approval_context) = + network_approval_context.map(V2NetworkApprovalContext::from) + { + CommandExecutionApprovalPresentation::Network(network_approval_context) + } else { + let command_string = shlex_join(&command); + let completion_item = CommandExecutionCompletionItem { + command: command_string, + cwd: cwd.clone(), + command_actions: command_actions.clone(), + }; + CommandExecutionApprovalPresentation::Command(completion_item) + }; + let (network_approval_context, command, cwd, command_actions, completion_item) = + match presentation { + CommandExecutionApprovalPresentation::Network( + network_approval_context, + ) => (Some(network_approval_context), None, None, None, None), + CommandExecutionApprovalPresentation::Command(completion_item) => ( + None, + Some(completion_item.command.clone()), + Some(completion_item.cwd.clone()), + Some(completion_item.command_actions.clone()), + Some(completion_item), + ), + }; let proposed_execpolicy_amendment_v2 = proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); @@ -291,9 +328,10 @@ pub(crate) async fn apply_bespoke_event_handling( item_id: call_id.clone(), approval_id: approval_id.clone(), reason, - command: Some(command_string.clone()), - cwd: Some(cwd.clone()), - command_actions: Some(command_actions.clone()), + network_approval_context, + command, + cwd, + command_actions, proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, }; let rx = outgoing @@ -307,9 +345,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, approval_id, call_id, - command_string, - cwd, - command_actions, + completion_item, rx, conversation, outgoing, @@ -1706,46 +1742,6 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String { } } -fn convert_patch_changes(changes: &HashMap) -> Vec { - let mut converted: Vec = changes - .iter() - .map(|(path, change)| FileUpdateChange { - path: path.to_string_lossy().into_owned(), - kind: map_patch_change_kind(change), - diff: format_file_change_diff(change), - }) - .collect(); - converted.sort_by(|a, b| a.path.cmp(&b.path)); - converted -} - -fn map_patch_change_kind(change: &CoreFileChange) -> V2PatchChangeKind { - match change { - CoreFileChange::Add { .. } => V2PatchChangeKind::Add, - CoreFileChange::Delete { .. } => V2PatchChangeKind::Delete, - CoreFileChange::Update { move_path, .. } => V2PatchChangeKind::Update { - move_path: move_path.clone(), - }, - } -} - -fn format_file_change_diff(change: &CoreFileChange) -> String { - match change { - CoreFileChange::Add { content } => content.clone(), - CoreFileChange::Delete { content } => content.clone(), - CoreFileChange::Update { - unified_diff, - move_path, - } => { - if let Some(path) = move_path { - format!("{unified_diff}\n\nMoved to: {}", path.display()) - } else { - unified_diff.clone() - } - } - } -} - fn map_file_change_approval_decision( decision: FileChangeApprovalDecision, ) -> (ReviewDecision, Option) { @@ -1831,9 +1827,7 @@ async fn on_command_execution_request_approval_response( conversation_id: ThreadId, approval_id: Option, item_id: String, - command: String, - cwd: PathBuf, - command_actions: Vec, + completion_item: Option, receiver: oneshot::Receiver, conversation: Arc, outgoing: ThreadScopedOutgoingMessageSender, @@ -1905,15 +1899,16 @@ async fn on_command_execution_request_approval_response( if let Some(status) = completion_status && !suppress_subcommand_completion_item + && let Some(completion_item) = completion_item { complete_command_execution_item( conversation_id, event_turn_id.clone(), item_id.clone(), - command.clone(), - cwd.clone(), + completion_item.command, + completion_item.cwd, None, - command_actions.clone(), + completion_item.command_actions, status, &outgoing, ) @@ -2100,6 +2095,8 @@ mod tests { call_id: "call-1".to_string(), sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, }; let item = collab_resume_begin_item(event.clone()); @@ -2121,6 +2118,8 @@ mod tests { call_id: "call-2".to_string(), sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, status: codex_protocol::protocol::AgentStatus::NotFound, }; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 58ca63306e..65b7f1ecb7 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -336,6 +336,7 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + single_client_mode: bool, cli_overrides: Vec<(String, TomlValue)>, cloud_requirements: Arc>, active_login: Arc>>, @@ -361,6 +362,7 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) config: Arc, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) cloud_requirements: Arc>, + pub(crate) single_client_mode: bool, pub(crate) feedback: CodexFeedback, } @@ -397,6 +399,7 @@ impl CodexMessageProcessor { config, cli_overrides, cloud_requirements, + single_client_mode, feedback, } = args; Self { @@ -405,6 +408,7 @@ impl CodexMessageProcessor { outgoing: outgoing.clone(), codex_linux_sandbox_exe, config, + single_client_mode, cli_overrides, cloud_requirements, active_login: Arc::new(Mutex::new(None)), @@ -1759,7 +1763,6 @@ impl CodexMessageProcessor { network: started_network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy), - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, justification: None, @@ -3042,21 +3045,14 @@ impl CodexMessageProcessor { return true; } - if let Err(err) = self - .ensure_conversation_listener( - existing_thread_id, - request_id.connection_id, - false, - ApiVersion::V2, - ) - .await - { - tracing::warn!( - "failed to attach listener for thread {}: {}", - existing_thread_id, - err.message - ); - } + let thread_state = self.thread_state_manager.thread_state(existing_thread_id); + self.ensure_listener_task_running( + existing_thread_id, + existing_thread.clone(), + thread_state.clone(), + ApiVersion::V2, + ) + .await; let config_snapshot = existing_thread.config_snapshot().await; let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); @@ -3068,41 +3064,39 @@ impl CodexMessageProcessor { ); } - let Some(mut thread) = self - .load_thread_from_rollout_or_send_internal( - request_id.clone(), - existing_thread_id, - rollout_path.as_path(), - config_snapshot.model_provider_id.as_str(), - ) - .await - else { + let listener_command_tx = { + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let Some(listener_command_tx) = listener_command_tx else { + let err = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running" + ), + data: None, + }; + self.outgoing.send_error(request_id, err).await; return true; }; - let ThreadConfigSnapshot { - model, - model_provider_id, - approval_policy, - sandbox_policy, - cwd, - reasoning_effort, - .. - } = config_snapshot; - thread.status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; - let response = ThreadResumeResponse { - thread, - model, - model_provider: model_provider_id, - cwd, - approval_policy: approval_policy.into(), - sandbox: sandbox_policy.into(), - reasoning_effort, - }; - self.outgoing.send_response(request_id, response).await; + let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( + crate::thread_state::PendingThreadResumeRequest { + request_id: request_id.clone(), + rollout_path, + config_snapshot, + }, + ); + if listener_command_tx.send(command).is_err() { + let err = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed" + ), + data: None, + }; + self.outgoing.send_error(request_id, err).await; + } return true; } false @@ -5817,17 +5811,18 @@ impl CodexMessageProcessor { api_version: ApiVersion, ) { let (cancel_tx, mut cancel_rx) = oneshot::channel(); - { + let (mut listener_command_rx, listener_generation) = { let mut thread_state = thread_state.lock().await; if thread_state.listener_matches(&conversation) { return; } - thread_state.set_listener(cancel_tx, &conversation); - } + thread_state.set_listener(cancel_tx, &conversation) + }; let outgoing_for_task = self.outgoing.clone(); let thread_manager = self.thread_manager.clone(); let thread_watch_manager = self.thread_watch_manager.clone(); let fallback_model_provider = self.config.model_provider_id.clone(); + let single_client_mode = self.single_client_mode; tokio::spawn(async move { loop { tokio::select! { @@ -5869,7 +5864,10 @@ impl CodexMessageProcessor { conversation_id.to_string().into(), ); let (subscribed_connection_ids, raw_events_enabled) = { - let thread_state = thread_state.lock().await; + let mut thread_state = thread_state.lock().await; + if !single_client_mode { + thread_state.track_current_turn_event(&event.msg); + } ( thread_state.subscribed_connection_ids(), thread_state.experimental_raw_events, @@ -5908,8 +5906,32 @@ impl CodexMessageProcessor { ) .await; } + listener_command = listener_command_rx.recv() => { + let Some(listener_command) = listener_command else { + break; + }; + match listener_command { + crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( + resume_request, + ) => { + handle_pending_thread_resume_request( + conversation_id, + &thread_state, + &thread_watch_manager, + &outgoing_for_task, + resume_request, + ) + .await; + } + } + } } } + + let mut thread_state = thread_state.lock().await; + if thread_state.listener_generation == listener_generation { + thread_state.clear_listener(); + } }); } async fn git_diff_to_origin(&self, request_id: ConnectionRequestId, cwd: PathBuf) { @@ -6076,6 +6098,7 @@ impl CodexMessageProcessor { reason, thread_id, include_logs, + extra_log_files, } = params; let conversation_id = match thread_id.as_deref() { @@ -6105,15 +6128,19 @@ impl CodexMessageProcessor { } else { None }; + let mut attachment_paths = validated_rollout_path.into_iter().collect::>(); + if let Some(extra_log_files) = extra_log_files { + attachment_paths.extend(extra_log_files); + } + let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { - let rollout_path_ref = validated_rollout_path.as_deref(); snapshot.upload_feedback( &classification, reason.as_deref(), include_logs, - rollout_path_ref, + &attachment_paths, Some(session_source), ) }) @@ -6206,6 +6233,106 @@ impl CodexMessageProcessor { } } +async fn handle_pending_thread_resume_request( + conversation_id: ThreadId, + thread_state: &Arc>, + thread_watch_manager: &ThreadWatchManager, + outgoing: &Arc, + pending: crate::thread_state::PendingThreadResumeRequest, +) { + let active_turn = { + let state = thread_state.lock().await; + state.active_turn_snapshot() + }; + + let request_id = pending.request_id; + let connection_id = request_id.connection_id; + let mut thread = match load_thread_for_running_resume_response( + conversation_id, + pending.rollout_path.as_path(), + pending.config_snapshot.model_provider_id.as_str(), + active_turn.as_ref(), + ) + .await + { + Ok(thread) => thread, + Err(message) => { + outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message, + data: None, + }, + ) + .await; + return; + } + }; + thread.status = thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await; + + let ThreadConfigSnapshot { + model, + model_provider_id, + approval_policy, + sandbox_policy, + cwd, + reasoning_effort, + .. + } = pending.config_snapshot; + let response = ThreadResumeResponse { + thread, + model, + model_provider: model_provider_id, + cwd, + approval_policy: approval_policy.into(), + sandbox: sandbox_policy.into(), + reasoning_effort, + }; + outgoing.send_response(request_id, response).await; + thread_state.lock().await.add_connection(connection_id); +} + +async fn load_thread_for_running_resume_response( + conversation_id: ThreadId, + rollout_path: &Path, + fallback_provider: &str, + active_turn: Option<&Turn>, +) -> std::result::Result { + let mut thread = read_summary_from_rollout(rollout_path, fallback_provider) + .await + .map(summary_to_thread) + .map_err(|err| { + format!( + "failed to load rollout `{}` for thread {conversation_id}: {err}", + rollout_path.display() + ) + })?; + + let mut turns = read_rollout_items_from_rollout(rollout_path) + .await + .map(|items| build_turns_from_rollout_items(&items)) + .map_err(|err| { + format!( + "failed to load rollout `{}` for thread {conversation_id}: {err}", + rollout_path.display() + ) + })?; + if let Some(active_turn) = active_turn { + merge_turn_history_with_active_turn(&mut turns, active_turn.clone()); + } + thread.turns = turns; + Ok(thread) +} + +fn merge_turn_history_with_active_turn(turns: &mut Vec, active_turn: Turn) { + turns.retain(|turn| turn.id != active_turn.id); + turns.push(active_turn); +} + fn collect_resume_override_mismatches( request: &ThreadResumeParams, config_snapshot: &ThreadConfigSnapshot, @@ -6540,6 +6667,8 @@ async fn read_summary_from_state_db_context_by_thread_id( metadata.cwd, metadata.cli_version, metadata.source, + metadata.agent_nickname, + metadata.agent_role, metadata.git_sha, metadata.git_branch, metadata.git_origin_url, @@ -6560,9 +6689,12 @@ async fn summary_from_thread_list_item( .unwrap_or_else(|| fallback_provider.to_string()); let cwd = it.cwd?; let cli_version = it.cli_version.unwrap_or_default(); - let source = it - .source - .unwrap_or(codex_protocol::protocol::SessionSource::Unknown); + let source = with_thread_spawn_agent_metadata( + it.source + .unwrap_or(codex_protocol::protocol::SessionSource::Unknown), + it.agent_nickname.clone(), + it.agent_role.clone(), + ); return Some(ConversationSummary { conversation_id: thread_id, path: it.path, @@ -6617,13 +6749,17 @@ fn summary_from_state_db_metadata( cwd: PathBuf, cli_version: String, source: String, + agent_nickname: Option, + agent_role: Option, git_sha: Option, git_branch: Option, git_origin_url: Option, ) -> ConversationSummary { let preview = first_user_message.unwrap_or_default(); - let source = serde_json::from_value(serde_json::Value::String(source)) + let source = serde_json::from_str(&source) + .or_else(|_| serde_json::from_value(serde_json::Value::String(source.clone()))) .unwrap_or(codex_protocol::protocol::SessionSource::Unknown); + let source = with_thread_spawn_agent_metadata(source, agent_nickname, agent_role); let git_info = if git_sha.is_none() && git_branch.is_none() && git_origin_url.is_none() { None } else { @@ -6671,6 +6807,12 @@ pub(crate) async fn read_summary_from_rollout( meta: session_meta, git, } = session_meta_line; + let mut session_meta = session_meta; + session_meta.source = with_thread_spawn_agent_metadata( + session_meta.source.clone(), + session_meta.agent_nickname.clone(), + session_meta.agent_role.clone(), + ); let created_at = if session_meta.timestamp.is_empty() { None @@ -6783,6 +6925,35 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { } } +fn with_thread_spawn_agent_metadata( + source: codex_protocol::protocol::SessionSource, + agent_nickname: Option, + agent_role: Option, +) -> codex_protocol::protocol::SessionSource { + if agent_nickname.is_none() && agent_role.is_none() { + return source; + } + + match source { + codex_protocol::protocol::SessionSource::SubAgent( + codex_protocol::protocol::SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_nickname: existing_agent_nickname, + agent_role: existing_agent_role, + }, + ) => codex_protocol::protocol::SessionSource::SubAgent( + codex_protocol::protocol::SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_nickname: agent_nickname.or(existing_agent_nickname), + agent_role: agent_role.or(existing_agent_role), + }, + ), + _ => source, + } +} + fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { chrono::DateTime::parse_from_rfc3339(ts) @@ -6819,6 +6990,8 @@ fn build_thread_from_snapshot( path, cwd: config_snapshot.cwd.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), + agent_nickname: config_snapshot.session_source.get_nickname(), + agent_role: config_snapshot.session_source.get_agent_role(), source: config_snapshot.session_source.clone().into(), git_info: None, turns: Vec::new(), @@ -6857,6 +7030,8 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { path: Some(path), cwd, cli_version, + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), source: source.into(), git_info, turns: Vec::new(), @@ -6868,6 +7043,7 @@ mod tests { use super::*; use anyhow::Result; use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; @@ -7013,6 +7189,87 @@ mod tests { Ok(()) } + #[tokio::test] + async fn read_summary_from_rollout_preserves_agent_nickname() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let parent_thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: None, + }), + agent_nickname: Some("atlas".to_string()), + agent_role: Some("explorer".to_string()), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp, + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + let thread = summary_to_thread(summary); + + assert_eq!(thread.agent_nickname, Some("atlas".to_string())); + assert_eq!(thread.agent_role, Some("explorer".to_string())); + Ok(()) + } + + #[test] + fn summary_from_state_db_metadata_preserves_agent_nickname() -> Result<()> { + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let source = + serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?, + depth: 1, + agent_nickname: None, + agent_role: None, + }))?; + + let summary = summary_from_state_db_metadata( + conversation_id, + PathBuf::from("/tmp/rollout.jsonl"), + Some("hi".to_string()), + "2025-09-05T16:53:11Z".to_string(), + "2025-09-05T16:53:12Z".to_string(), + "test-provider".to_string(), + PathBuf::from("/"), + "0.0.0".to_string(), + source, + Some("atlas".to_string()), + Some("explorer".to_string()), + None, + None, + None, + ); + + let thread = summary_to_thread(summary); + + assert_eq!(thread.agent_nickname, Some("atlas".to_string())); + assert_eq!(thread.agent_role, Some("explorer".to_string())); + Ok(()) + } + #[tokio::test] async fn removing_one_listener_does_not_cancel_other_subscriptions_for_same_thread() -> Result<()> { diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index e1531dce2f..c9317ac9f5 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -161,6 +161,7 @@ fn map_network_requirements_to_api( allow_upstream_proxy: network.allow_upstream_proxy, dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin: network.dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, allowed_domains: network.allowed_domains, denied_domains: network.denied_domains, allow_unix_sockets: network.allow_unix_sockets, @@ -221,6 +222,7 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_non_loopback_admin: Some(false), + dangerously_allow_all_unix_sockets: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), @@ -258,6 +260,7 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_non_loopback_admin: Some(false), + dangerously_allow_all_unix_sockets: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), diff --git a/codex-rs/app-server/src/filters.rs b/codex-rs/app-server/src/filters.rs index bd784c3dcc..a597509612 100644 --- a/codex-rs/app-server/src/filters.rs +++ b/codex-rs/app-server/src/filters.rs @@ -133,6 +133,8 @@ mod tests { let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_nickname: None, + agent_role: None, }); assert!(source_kind_matches( diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 6f2a374be4..e34afa9cdf 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -44,6 +44,7 @@ use codex_core::config_loader::TextRange as CoreTextRange; use codex_feedback::CodexFeedback; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use tracing::error; use tracing::info; @@ -52,6 +53,7 @@ use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; use tracing_subscriber::fmt::writer::BoxMakeWriter; use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; mod bespoke_event_handling; @@ -70,6 +72,16 @@ mod transport; pub use crate::transport::AppServerTransport; +const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LogFormat { + Default, + Json, +} + +type StderrLogLayer = Box + Send + Sync + 'static>; + /// Control-plane messages from the processor/transport side to the outbound router task. /// /// `run_main_with_transport` now uses two loops/tasks: @@ -84,6 +96,7 @@ enum OutboundControlEvent { Opened { connection_id: ConnectionId, writer: mpsc::Sender, + disconnect_sender: Option, initialized: Arc, opted_out_notification_methods: Arc>>, }, @@ -201,6 +214,20 @@ fn project_config_warning(config: &Config) -> Option }) } +impl LogFormat { + fn from_env_value(value: Option<&str>) -> Self { + match value.map(str::trim).map(str::to_ascii_lowercase) { + Some(value) if value == "json" => Self::Json, + _ => Self::Default, + } + } +} + +fn log_format_from_env() -> LogFormat { + let value = std::env::var(LOG_FORMAT_ENV_VAR).ok(); + LogFormat::from_env_value(value.as_deref()) +} + pub async fn run_main( codex_linux_sandbox_exe: Option, cli_config_overrides: CliConfigOverrides, @@ -241,7 +268,8 @@ pub async fn run_main_with_transport( Some(start_websocket_acceptor(bind_address, transport_event_tx.clone()).await?); } } - let shutdown_when_no_connections = matches!(transport, AppServerTransport::Stdio); + let single_client_mode = matches!(transport, AppServerTransport::Stdio); + let shutdown_when_no_connections = single_client_mode; // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. @@ -344,22 +372,26 @@ pub async fn run_main_with_transport( ) })?; - // Install a simple subscriber so `tracing` output is visible. Users can - // control the log level with `RUST_LOG`. - let stderr_fmt = tracing_subscriber::fmt::layer() - .with_writer(std::io::stderr) - .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) - .with_filter( - EnvFilter::builder() - .with_default_directive(LevelFilter::TRACE.into()) - .from_env_lossy(), - ); + // Install a simple subscriber so `tracing` output is visible. Users can + // control the log level with `RUST_LOG` and switch to JSON logs with + // `LOG_FORMAT=json`. + let stderr_fmt: StderrLogLayer = match log_format_from_env() { + LogFormat::Json => tracing_subscriber::fmt::layer() + .json() + .with_writer(std::io::stderr) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_filter(EnvFilter::from_default_env()) + .boxed(), + LogFormat::Default => tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_filter(EnvFilter::from_default_env()) + .boxed(), + }; let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); - let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); - let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); let mut trace_log_setup_warning = None; @@ -444,61 +476,43 @@ pub async fn run_main_with_transport( } } - let transport_event_tx_for_outbound = transport_event_tx.clone(); let outbound_handle = tokio::spawn(async move { let mut outbound_connections = HashMap::::new(); - let mut pending_closed_connections = VecDeque::::new(); loop { tokio::select! { - biased; - event = outbound_control_rx.recv() => { - let Some(event) = event else { - break; - }; - match event { - OutboundControlEvent::Opened { - connection_id, - writer, - initialized, - opted_out_notification_methods, - } => { - outbound_connections.insert( + biased; + event = outbound_control_rx.recv() => { + let Some(event) = event else { + break; + }; + match event { + OutboundControlEvent::Opened { connection_id, - OutboundConnectionState::new( - writer, - initialized, - opted_out_notification_methods, - ), - ); - } - OutboundControlEvent::Closed { connection_id } => { - outbound_connections.remove(&connection_id); + writer, + disconnect_sender, + initialized, + opted_out_notification_methods, + } => { + outbound_connections.insert( + connection_id, + OutboundConnectionState::new( + writer, + initialized, + opted_out_notification_methods, + disconnect_sender, + ), + ); + } + OutboundControlEvent::Closed { connection_id } => { + outbound_connections.remove(&connection_id); + } } } - } - envelope = outgoing_rx.recv() => { + envelope = outgoing_rx.recv() => { let Some(envelope) = envelope else { break; }; - let disconnected_connections = - route_outgoing_envelope(&mut outbound_connections, envelope).await; - pending_closed_connections.extend(disconnected_connections); - } - } - - while let Some(connection_id) = pending_closed_connections.front().copied() { - match transport_event_tx_for_outbound - .try_send(TransportEvent::ConnectionClosed { connection_id }) - { - Ok(()) => { - pending_closed_connections.pop_front(); - } - Err(mpsc::error::TrySendError::Full(_)) => { - break; - } - Err(mpsc::error::TrySendError::Closed(_)) => { - return; - } + route_outgoing_envelope(&mut outbound_connections, envelope).await; } } } @@ -514,6 +528,7 @@ pub async fn run_main_with_transport( outgoing: outgoing_message_sender, codex_linux_sandbox_exe, config: Arc::new(config), + single_client_mode, cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), @@ -531,7 +546,11 @@ pub async fn run_main_with_transport( break; }; match event { - TransportEvent::ConnectionOpened { connection_id, writer } => { + TransportEvent::ConnectionOpened { + connection_id, + writer, + disconnect_sender, + } => { let outbound_initialized = Arc::new(AtomicBool::new(false)); let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new())); @@ -539,6 +558,7 @@ pub async fn run_main_with_transport( .send(OutboundControlEvent::Opened { connection_id, writer, + disconnect_sender, initialized: Arc::clone(&outbound_initialized), opted_out_notification_methods: Arc::clone( &outbound_opted_out_notification_methods, @@ -558,6 +578,9 @@ pub async fn run_main_with_transport( ); } TransportEvent::ConnectionClosed { connection_id } => { + if connections.remove(&connection_id).is_none() { + continue; + } if outbound_control_tx .send(OutboundControlEvent::Closed { connection_id }) .await @@ -566,7 +589,6 @@ pub async fn run_main_with_transport( break; } processor.connection_closed(connection_id).await; - connections.remove(&connection_id); if shutdown_when_no_connections && connections.is_empty() { break; } @@ -575,7 +597,7 @@ pub async fn run_main_with_transport( match message { JSONRPCMessage::Request(request) => { let Some(connection_state) = connections.get_mut(&connection_id) else { - warn!("dropping request from unknown connection: {:?}", connection_id); + warn!("dropping request from unknown connection: {connection_id:?}"); continue; }; let was_initialized = connection_state.session.initialized; @@ -605,12 +627,24 @@ pub async fn run_main_with_transport( } } JSONRPCMessage::Response(response) => { + if !connections.contains_key(&connection_id) { + warn!("dropping response from unknown connection: {connection_id:?}"); + continue; + } processor.process_response(response).await; } JSONRPCMessage::Notification(notification) => { + if !connections.contains_key(&connection_id) { + warn!("dropping notification from unknown connection: {connection_id:?}"); + continue; + } processor.process_notification(notification).await; } JSONRPCMessage::Error(err) => { + if !connections.contains_key(&connection_id) { + warn!("dropping error from unknown connection: {connection_id:?}"); + continue; + } processor.process_error(err).await; } } @@ -668,3 +702,24 @@ pub async fn run_main_with_transport( Ok(()) } + +#[cfg(test)] +mod tests { + use super::LogFormat; + use pretty_assertions::assert_eq; + + #[test] + fn log_format_from_env_value_matches_json_values_case_insensitively() { + assert_eq!(LogFormat::from_env_value(Some("json")), LogFormat::Json); + assert_eq!(LogFormat::from_env_value(Some("JSON")), LogFormat::Json); + assert_eq!(LogFormat::from_env_value(Some(" Json ")), LogFormat::Json); + } + + #[test] + fn log_format_from_env_value_defaults_for_non_json_values() { + assert_eq!(LogFormat::from_env_value(None), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("")), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); + } +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index f1c6029a21..e80e1790f8 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -139,6 +139,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) config: Arc, + pub(crate) single_client_mode: bool, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, @@ -154,6 +155,7 @@ impl MessageProcessor { outgoing, codex_linux_sandbox_exe, config, + single_client_mode, cli_overrides, loader_overrides, cloud_requirements, @@ -184,6 +186,7 @@ impl MessageProcessor { config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), cloud_requirements: cloud_requirements.clone(), + single_client_mode, feedback, }); let config_api = ConfigApi::new( diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index fd0f58abd1..a9d35e0b7c 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -1,13 +1,19 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; +use codex_core::ThreadConfigSnapshot; use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; use std::collections::HashMap; use std::collections::HashSet; +use std::path::PathBuf; use std::sync::Arc; use std::sync::Weak; use tokio::sync::Mutex; +use tokio::sync::mpsc; use tokio::sync::oneshot; use uuid::Uuid; @@ -16,6 +22,16 @@ type PendingInterruptQueue = Vec<( crate::codex_message_processor::ApiVersion, )>; +pub(crate) struct PendingThreadResumeRequest { + pub(crate) request_id: ConnectionRequestId, + pub(crate) rollout_path: PathBuf, + pub(crate) config_snapshot: ThreadConfigSnapshot, +} + +pub(crate) enum ThreadListenerCommand { + SendThreadResumeResponse(PendingThreadResumeRequest), +} + /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { @@ -31,6 +47,9 @@ pub(crate) struct ThreadState { pub(crate) turn_summary: TurnSummary, pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, + pub(crate) listener_generation: u64, + listener_command_tx: Option>, + current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, subscribed_connections: HashSet, } @@ -47,17 +66,23 @@ impl ThreadState { &mut self, cancel_tx: oneshot::Sender<()>, conversation: &Arc, - ) { + ) -> (mpsc::UnboundedReceiver, u64) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); } + self.listener_generation = self.listener_generation.wrapping_add(1); + let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); + self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); + (listener_command_rx, self.listener_generation) } pub(crate) fn clear_listener(&mut self) { if let Some(cancel_tx) = self.cancel_tx.take() { let _ = cancel_tx.send(()); } + self.listener_command_tx = None; + self.current_turn_history.reset(); self.listener_thread = None; } @@ -76,6 +101,23 @@ impl ThreadState { pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) { self.experimental_raw_events = enabled; } + + pub(crate) fn listener_command_tx( + &self, + ) -> Option> { + self.listener_command_tx.clone() + } + + pub(crate) fn active_turn_snapshot(&self) -> Option { + self.current_turn_history.active_turn_snapshot() + } + + pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + self.current_turn_history.handle_event(event); + if !self.current_turn_history.has_active_turn() { + self.current_turn_history.reset(); + } + } } #[derive(Clone, Copy)] @@ -200,12 +242,24 @@ impl ThreadStateManager { } pub(crate) async fn remove_connection(&mut self, connection_id: ConnectionId) { - let Some(thread_ids) = self.thread_ids_by_connection.remove(&connection_id) else { - return; - }; + let thread_ids = self + .thread_ids_by_connection + .remove(&connection_id) + .unwrap_or_default(); self.subscription_state_by_id .retain(|_, state| state.connection_id != connection_id); + if thread_ids.is_empty() { + for thread_state in self.thread_states.values() { + let mut thread_state = thread_state.lock().await; + thread_state.remove_connection(connection_id); + if thread_state.subscribed_connection_ids().is_empty() { + thread_state.clear_listener(); + } + } + return; + } + for thread_id in thread_ids { if let Some(thread_state) = self.thread_states.get(&thread_id) { let mut thread_state = thread_state.lock().await; diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 7576791d37..301c4b1d07 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -622,6 +622,8 @@ mod tests { path: None, cwd: PathBuf::from("/tmp"), cli_version: "test".to_string(), + agent_nickname: None, + agent_role: None, source, git_info: None, turns: Vec::new(), diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 558d786137..80768620bb 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -32,6 +32,7 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; use tracing::info; @@ -135,6 +136,7 @@ pub(crate) enum TransportEvent { ConnectionOpened { connection_id: ConnectionId, writer: mpsc::Sender, + disconnect_sender: Option, }, ConnectionClosed { connection_id: ConnectionId, @@ -168,6 +170,7 @@ pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, pub(crate) opted_out_notification_methods: Arc>>, pub(crate) writer: mpsc::Sender, + disconnect_sender: Option, } impl OutboundConnectionState { @@ -175,11 +178,23 @@ impl OutboundConnectionState { writer: mpsc::Sender, initialized: Arc, opted_out_notification_methods: Arc>>, + disconnect_sender: Option, ) -> Self { Self { initialized, opted_out_notification_methods, writer, + disconnect_sender, + } + } + + fn can_disconnect(&self) -> bool { + self.disconnect_sender.is_some() + } + + fn request_disconnect(&self) { + if let Some(disconnect_sender) = &self.disconnect_sender { + disconnect_sender.cancel(); } } } @@ -195,6 +210,7 @@ pub(crate) async fn start_stdio_connection( .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, + disconnect_sender: None, }) .await .map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?; @@ -299,12 +315,14 @@ async fn run_websocket_connection( } }; - let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); + let disconnect_token = CancellationToken::new(); if transport_event_tx .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, + disconnect_sender: Some(disconnect_token.clone()), }) .await .is_err() @@ -312,9 +330,62 @@ async fn run_websocket_connection( return; } - let (mut websocket_writer, mut websocket_reader) = websocket_stream.split(); + let (websocket_writer, websocket_reader) = websocket_stream.split(); + let (writer_control_tx, writer_control_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let mut outbound_task = tokio::spawn(run_websocket_outbound_loop( + websocket_writer, + writer_rx, + writer_control_rx, + disconnect_token.clone(), + )); + let mut inbound_task = tokio::spawn(run_websocket_inbound_loop( + websocket_reader, + transport_event_tx.clone(), + writer_tx_for_reader, + writer_control_tx, + connection_id, + disconnect_token.clone(), + )); + + tokio::select! { + _ = &mut outbound_task => { + disconnect_token.cancel(); + inbound_task.abort(); + } + _ = &mut inbound_task => { + disconnect_token.cancel(); + outbound_task.abort(); + } + } + + let _ = transport_event_tx + .send(TransportEvent::ConnectionClosed { connection_id }) + .await; +} + +async fn run_websocket_outbound_loop( + mut websocket_writer: futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream, + WebSocketMessage, + >, + mut writer_rx: mpsc::Receiver, + mut writer_control_rx: mpsc::Receiver, + disconnect_token: CancellationToken, +) { loop { tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + message = writer_control_rx.recv() => { + let Some(message) = message else { + break; + }; + if websocket_writer.send(message).await.is_err() { + break; + } + } outgoing_message = writer_rx.recv() => { let Some(outgoing_message) = outgoing_message else { break; @@ -326,6 +397,25 @@ async fn run_websocket_connection( break; } } + } + } +} + +async fn run_websocket_inbound_loop( + mut websocket_reader: futures::stream::SplitStream< + tokio_tungstenite::WebSocketStream, + >, + transport_event_tx: mpsc::Sender, + writer_tx_for_reader: mpsc::Sender, + writer_control_tx: mpsc::Sender, + connection_id: ConnectionId, + disconnect_token: CancellationToken, +) { + loop { + tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } incoming_message = websocket_reader.next() => { match incoming_message { Some(Ok(WebSocketMessage::Text(text))) => { @@ -341,8 +431,13 @@ async fn run_websocket_connection( } } Some(Ok(WebSocketMessage::Ping(payload))) => { - if websocket_writer.send(WebSocketMessage::Pong(payload)).await.is_err() { - break; + match writer_control_tx.try_send(WebSocketMessage::Pong(payload)) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + warn!("websocket control queue full while replying to ping; closing connection"); + break; + } } } Some(Ok(WebSocketMessage::Pong(_))) => {} @@ -359,10 +454,6 @@ async fn run_websocket_connection( } } } - - let _ = transport_event_tx - .send(TransportEvent::ConnectionClosed { connection_id }) - .await; } async fn forward_incoming_message( @@ -461,30 +552,61 @@ fn should_skip_notification_for_connection( } } +fn disconnect_connection( + connections: &mut HashMap, + connection_id: ConnectionId, +) -> bool { + if let Some(connection_state) = connections.remove(&connection_id) { + connection_state.request_disconnect(); + return true; + } + false +} + +async fn send_message_to_connection( + connections: &mut HashMap, + connection_id: ConnectionId, + message: OutgoingMessage, +) -> bool { + let Some(connection_state) = connections.get(&connection_id) else { + warn!("dropping message for disconnected connection: {connection_id:?}"); + return false; + }; + if should_skip_notification_for_connection(connection_state, &message) { + return false; + } + + let writer = connection_state.writer.clone(); + if connection_state.can_disconnect() { + match writer.try_send(message) { + Ok(()) => false, + Err(mpsc::error::TrySendError::Full(_)) => { + warn!( + "disconnecting slow connection after outbound queue filled: {connection_id:?}" + ); + disconnect_connection(connections, connection_id) + } + Err(mpsc::error::TrySendError::Closed(_)) => { + disconnect_connection(connections, connection_id) + } + } + } else if writer.send(message).await.is_err() { + disconnect_connection(connections, connection_id) + } else { + false + } +} + pub(crate) async fn route_outgoing_envelope( connections: &mut HashMap, envelope: OutgoingEnvelope, -) -> Vec { - let mut disconnected = Vec::new(); +) { match envelope { OutgoingEnvelope::ToConnection { connection_id, message, } => { - let Some(connection_state) = connections.get(&connection_id) else { - warn!( - "dropping message for disconnected connection: {:?}", - connection_id - ); - return disconnected; - }; - if should_skip_notification_for_connection(connection_state, &message) { - return disconnected; - } - if connection_state.writer.send(message).await.is_err() { - connections.remove(&connection_id); - disconnected.push(connection_id); - } + let _ = send_message_to_connection(connections, connection_id, message).await; } OutgoingEnvelope::Broadcast { message } => { let target_connections: Vec = connections @@ -501,17 +623,11 @@ pub(crate) async fn route_outgoing_envelope( .collect(); for connection_id in target_connections { - let Some(connection_state) = connections.get(&connection_id) else { - continue; - }; - if connection_state.writer.send(message.clone()).await.is_err() { - connections.remove(&connection_id); - disconnected.push(connection_id); - } + let _ = + send_message_to_connection(connections, connection_id, message.clone()).await; } } } - disconnected } #[cfg(test)] @@ -520,6 +636,8 @@ mod tests { use crate::error_code::OVERLOADED_ERROR_CODE; use pretty_assertions::assert_eq; use serde_json::json; + use tokio::time::Duration; + use tokio::time::timeout; #[test] fn app_server_transport_parses_stdio_listen_url() { @@ -754,10 +872,15 @@ mod tests { let mut connections = HashMap::new(); connections.insert( connection_id, - OutboundConnectionState::new(writer_tx, initialized, opted_out_notification_methods), + OutboundConnectionState::new( + writer_tx, + initialized, + opted_out_notification_methods, + None, + ), ); - let disconnected = route_outgoing_envelope( + route_outgoing_envelope( &mut connections, OutgoingEnvelope::ToConnection { connection_id, @@ -771,10 +894,161 @@ mod tests { ) .await; - assert_eq!(disconnected, Vec::::new()); assert!( writer_rx.try_recv().is_err(), "opted-out notification should be dropped" ); } + + #[tokio::test] + async fn broadcast_does_not_block_on_slow_connection() { + let fast_connection_id = ConnectionId(1); + let slow_connection_id = ConnectionId(2); + + let (fast_writer_tx, mut fast_writer_rx) = mpsc::channel(1); + let (slow_writer_tx, mut slow_writer_rx) = mpsc::channel(1); + let fast_disconnect_token = CancellationToken::new(); + let slow_disconnect_token = CancellationToken::new(); + + let mut connections = HashMap::new(); + connections.insert( + fast_connection_id, + OutboundConnectionState::new( + fast_writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + Some(fast_disconnect_token.clone()), + ), + ); + connections.insert( + slow_connection_id, + OutboundConnectionState::new( + slow_writer_tx.clone(), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + Some(slow_disconnect_token.clone()), + ), + ); + + let queued_message = + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method: "codex/event/already-buffered".to_string(), + params: None, + }); + slow_writer_tx + .try_send(queued_message) + .expect("channel should have room"); + + let broadcast_message = + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method: "codex/event/test".to_string(), + params: None, + }); + timeout( + Duration::from_millis(100), + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::Broadcast { + message: broadcast_message, + }, + ), + ) + .await + .expect("broadcast should not block on a full writer"); + assert!(!connections.contains_key(&slow_connection_id)); + assert!(slow_disconnect_token.is_cancelled()); + assert!(!fast_disconnect_token.is_cancelled()); + let fast_message = fast_writer_rx + .try_recv() + .expect("fast connection should receive broadcast"); + assert!(matches!( + fast_message, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "codex/event/test" + )); + + let slow_message = slow_writer_rx + .try_recv() + .expect("slow connection should retain its original buffered message"); + assert!(matches!( + slow_message, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "codex/event/already-buffered" + )); + } + + #[tokio::test] + async fn to_connection_stdio_waits_instead_of_disconnecting_when_writer_queue_is_full() { + let connection_id = ConnectionId(3); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + writer_tx + .send(OutgoingMessage::Notification( + crate::outgoing_message::OutgoingNotification { + method: "queued".to_string(), + params: None, + }, + )) + .await + .expect("channel should accept the first queued message"); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + None, + ), + ); + + let route_task = tokio::spawn(async move { + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::Notification( + crate::outgoing_message::OutgoingNotification { + method: "second".to_string(), + params: None, + }, + ), + }, + ) + .await + }); + + let first = timeout(Duration::from_millis(100), writer_rx.recv()) + .await + .expect("first queued message should be readable") + .expect("first queued message should exist"); + let second = timeout(Duration::from_millis(100), writer_rx.recv()) + .await + .expect("second message should eventually be delivered") + .expect("second message should exist"); + + timeout(Duration::from_millis(100), route_task) + .await + .expect("routing should finish after writer drains") + .expect("routing task should succeed"); + + assert!(matches!( + first, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "queued" + )); + assert!(matches!( + second, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "second" + )); + } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 77212f9438..249a280213 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -61,6 +61,7 @@ use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnSteerParams; @@ -572,6 +573,63 @@ impl McpProcess { self.send_request("turn/interrupt", params).await } + /// Deterministically clean up an intentionally in-flight turn. + /// + /// Some tests assert behavior while a turn is still running. Returning from those tests + /// without an explicit interrupt + `codex/event/turn_aborted` wait can leave in-flight work + /// racing teardown and intermittently show up as `LEAK` in nextest. + /// + /// In rare races, the turn can also fail or complete on its own after we send + /// `turn/interrupt` but before the server emits the interrupt response. The helper treats a + /// buffered matching `turn/completed` notification as sufficient terminal cleanup in that + /// case so teardown does not flap on timing. + pub async fn interrupt_turn_and_wait_for_aborted( + &mut self, + thread_id: String, + turn_id: String, + read_timeout: std::time::Duration, + ) -> anyhow::Result<()> { + let interrupt_request_id = self + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + }) + .await?; + match tokio::time::timeout( + read_timeout, + self.read_stream_until_response_message(RequestId::Integer(interrupt_request_id)), + ) + .await + { + Ok(result) => { + result.with_context(|| "failed while waiting for turn interrupt response")?; + } + Err(err) => { + if self.pending_turn_completed_notification(&thread_id, &turn_id) { + return Ok(()); + } + return Err(err).with_context(|| "timed out waiting for turn interrupt response"); + } + } + match tokio::time::timeout( + read_timeout, + self.read_stream_until_notification_message("codex/event/turn_aborted"), + ) + .await + { + Ok(result) => { + result.with_context(|| "failed while waiting for turn aborted notification")?; + } + Err(err) => { + if self.pending_turn_completed_notification(&thread_id, &turn_id) { + return Ok(()); + } + return Err(err).with_context(|| "timed out waiting for turn aborted notification"); + } + } + Ok(()) + } + /// Send a `turn/steer` JSON-RPC request (v2). pub async fn send_turn_steer_request( &mut self, @@ -940,6 +998,25 @@ impl McpProcess { None } + fn pending_turn_completed_notification(&self, thread_id: &str, turn_id: &str) -> bool { + self.pending_messages.iter().any(|message| { + let JSONRPCMessage::Notification(notification) = message else { + return false; + }; + if notification.method != "turn/completed" { + return false; + } + let Some(params) = notification.params.as_ref() else { + return false; + }; + let Ok(payload) = serde_json::from_value::(params.clone()) + else { + return false; + }; + payload.thread_id == thread_id && payload.turn.id == turn_id + }) + } + fn message_request_id(message: &JSONRPCMessage) -> Option<&RequestId> { match message { JSONRPCMessage::Request(request) => Some(&request.id), diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 14dce02c64..8122e7cd01 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -79,6 +79,8 @@ pub fn create_fake_rollout_with_source( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, + agent_nickname: None, + agent_role: None, model_provider: model_provider.map(str::to_string), base_instructions: None, dynamic_tools: None, @@ -158,6 +160,8 @@ pub fn create_fake_rollout_with_text_elements( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, model_provider: model_provider.map(str::to_string), base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 1fd3f5a447..a9bc14e932 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -15,6 +15,7 @@ use axum::Router; use axum::extract::State; use axum::http::HeaderMap; use axum::http::StatusCode; +use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppBranding; @@ -491,39 +492,60 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { }) .await?; + let expected_directory_first = vec![ + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + }, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), + is_accessible: false, + is_enabled: true, + }, + ]; + let expected_accessible_first = vec![AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), + is_accessible: true, + is_enabled: true, + }]; + let first_update = read_app_list_updated_notification(&mut mcp).await?; - assert_eq!( - first_update.data, - vec![ - AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha connector".to_string()), - logo_url: Some("https://example.com/alpha.png".to_string()), - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), - is_accessible: false, - is_enabled: true, - }, - AppInfo { - id: "beta".to_string(), - name: "beta".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), - is_accessible: false, - is_enabled: true, - }, - ] + // app/list emits an update after whichever async load finishes first. Even with + // a tools delay in this test, the accessible-tools path can return first if the + // process-global Codex Apps tools cache is warm from another test. + assert!( + first_update.data == expected_directory_first + || first_update.data == expected_accessible_first, + "unexpected first app/list update: {:#?}", + first_update.data ); let expected = vec![ @@ -1194,6 +1216,7 @@ async fn start_apps_server_with_delays_and_control( async fn list_directory_connectors( State(state): State>, headers: HeaderMap, + uri: Uri, ) -> Result { if state.directory_delay > Duration::ZERO { tokio::time::sleep(state.directory_delay).await; @@ -1207,16 +1230,21 @@ async fn list_directory_connectors( .get("chatgpt-account-id") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value == state.expected_account_id); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); - if bearer_ok && account_ok { + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { let response = state .response .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); Ok(Json(response)) - } else { - Err(StatusCode::UNAUTHORIZED) } } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index de4c51cde4..cd74710876 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -4,7 +4,7 @@ use app_test_support::test_path_buf_with_windows; use app_test_support::test_tmp_path_buf; use app_test_support::to_response; use codex_app_server_protocol::AppConfig; -use codex_app_server_protocol::AppDisabledReason; +use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -156,7 +156,8 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps.app1] enabled = false -disabled_reason = "user" +destructive_enabled = false +default_tools_approval_mode = "prompt" "#, )?; let codex_home_path = codex_home.path().canonicalize()?; @@ -185,11 +186,16 @@ disabled_reason = "user" assert_eq!( config.apps, Some(AppsConfig { + default: None, apps: std::collections::HashMap::from([( "app1".to_string(), AppConfig { enabled: false, - disabled_reason: Some(AppDisabledReason::User), + destructive_enabled: Some(false), + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, }, )]), }) @@ -202,7 +208,16 @@ disabled_reason = "user" ); assert_eq!( origins - .get("apps.app1.disabled_reason") + .get("apps.app1.destructive_enabled") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins + .get("apps.app1.default_tools_approval_mode") .expect("origin") .name, ConfigLayerSource::User { diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 46e1d170f6..1f6e0627eb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -582,6 +582,8 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_nickname: None, + agent_role: None, }), )?; @@ -643,6 +645,8 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_nickname: None, + agent_role: None, }), )?; let other_id = create_fake_rollout_with_source( diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4a57767ab8..15bc4012eb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -15,8 +15,6 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; -use codex_app_server_protocol::TurnInterruptParams; -use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; @@ -317,9 +315,14 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { responses::ev_assistant_message("msg-1", "Done"), responses::ev_completed("resp-1"), ]); - let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); let _first_response_mock = responses::mount_sse_once(&server, first_body).await; - let _second_response_mock = responses::mount_sse_once(&server, second_body).await; + let _second_response_mock = responses::mount_response_once(&server, second_response).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -413,28 +416,9 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { resume_err.error.message ); - // This test intentionally keeps a turn running to exercise the resume error path. - // Keep this explicit interrupt + turn_aborted wait so teardown does not leave - // in-flight work behind (which can show up as LEAK in nextest). - let interrupt_id = primary - .send_turn_interrupt_request(TurnInterruptParams { - thread_id, - turn_id: running_turn.id, - }) + primary + .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) .await?; - let interrupt_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_response_message(RequestId::Integer(interrupt_id)), - ) - .await??; - let _turn_interrupt_response: TurnInterruptResponse = - to_response::(interrupt_resp)?; - - timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_notification_message("codex/event/turn_aborted"), - ) - .await??; Ok(()) } @@ -447,9 +431,14 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul responses::ev_assistant_message("msg-1", "Done"), responses::ev_completed("resp-1"), ]); - let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); let _first_response_mock = responses::mount_sse_once(&server, first_body).await; - let _second_response_mock = responses::mount_sse_once(&server, second_body).await; + let _second_response_mock = responses::mount_response_once(&server, second_response).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -533,28 +522,9 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul resume_err.error.message ); - // This test intentionally keeps a turn running to exercise the resume error path. - // Keep this explicit interrupt + turn_aborted wait so teardown does not leave - // in-flight work behind (which can show up as LEAK in nextest). - let interrupt_id = primary - .send_turn_interrupt_request(TurnInterruptParams { - thread_id, - turn_id: running_turn.id, - }) + primary + .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) .await?; - let interrupt_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_response_message(RequestId::Integer(interrupt_id)), - ) - .await??; - let _turn_interrupt_response: TurnInterruptResponse = - to_response::(interrupt_resp)?; - - timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_notification_message("codex/event/turn_aborted"), - ) - .await??; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 2eecfc5590..d2d850176d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -747,7 +747,12 @@ async fn turn_start_accepts_local_image_input() -> Result<()> { let TurnStartResponse { turn } = to_response::(turn_resp)?; assert!(!turn.id.is_empty()); - // This test only validates that turn/start responds and returns a turn. + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 4233ce6494..4be127dff8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -29,6 +29,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::features::FEATURES; @@ -100,7 +101,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "run echo hi".to_string(), text_elements: Vec::new(), @@ -114,11 +115,12 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { ..Default::default() }) .await?; - timeout( + let turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { loop { @@ -149,6 +151,9 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { assert!(command.contains(" -lc 'echo hi'")); assert_eq!(cwd, workspace); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) + .await?; + Ok(()) } @@ -504,11 +509,12 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() ..Default::default() }) .await?; - timeout( + let turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; let mut approval_ids = Vec::new(); for decision in [ @@ -577,6 +583,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() assert_eq!(approval_ids.len(), 2); assert_ne!(approval_ids[0], approval_ids[1]); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) + .await?; + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 89704326fd..779e77577b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -136,7 +136,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -152,6 +152,9 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) + .await?; + Ok(()) } diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 6dc2161f0c..a3470ff1b9 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -4,8 +4,6 @@ use std::sync::LazyLock; use std::sync::Mutex as StdMutex; use codex_core::config::Config; -use codex_core::default_client::is_first_party_chat_originator; -use codex_core::default_client::originator; use codex_core::features::Feature; use codex_core::token_data::TokenData; use serde::Deserialize; @@ -22,8 +20,10 @@ use codex_core::connectors::AppMetadata; use codex_core::connectors::CONNECTORS_CACHE_TTL; pub use codex_core::connectors::connector_display_label; use codex_core::connectors::connector_install_url; +use codex_core::connectors::filter_disallowed_connectors; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options; +pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status; pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools; use codex_core::connectors::merge_connectors; pub use codex_core::connectors::with_app_enabled_state; @@ -106,7 +106,7 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> } let token_data = get_chatgpt_token_data()?; let cache_key = all_connectors_cache_key(config, &token_data); - read_cached_all_connectors(&cache_key) + read_cached_all_connectors(&cache_key).map(filter_disallowed_connectors) } pub async fn list_all_connectors_with_options( @@ -123,7 +123,7 @@ pub async fn list_all_connectors_with_options( get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; let cache_key = all_connectors_cache_key(config, &token_data); if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) { - return Ok(cached_connectors); + return Ok(filter_disallowed_connectors(cached_connectors)); } let mut apps = list_directory_connectors(config).await?; @@ -149,6 +149,7 @@ pub async fn list_all_connectors_with_options( .cmp(&right.name) .then_with(|| left.id.cmp(&right.id)) }); + let connectors = filter_disallowed_connectors(connectors); write_cached_all_connectors(cache_key, &connectors); Ok(connectors) } @@ -219,9 +220,11 @@ async fn list_directory_connectors(config: &Config) -> anyhow::Result { let encoded_token = urlencoding::encode(token); - format!("/connectors/directory/list?tier=categorized&token={encoded_token}") + format!( + "/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true" + ) } - None => "/connectors/directory/list?tier=categorized".to_string(), + None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(), }; let response: DirectoryListResponse = chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT)) @@ -246,7 +249,7 @@ async fn list_directory_connectors(config: &Config) -> anyhow::Result anyhow::Result> { let response: anyhow::Result = chatgpt_get_request_with_timeout( config, - "/connectors/directory/list_workspace".to_string(), + "/connectors/directory/list_workspace?external_logos=true".to_string(), Some(DIRECTORY_CONNECTORS_TIMEOUT), ) .await; @@ -453,45 +456,6 @@ fn normalize_connector_value(value: Option<&str>) -> Option { .filter(|value| !value.is_empty()) .map(str::to_string) } - -const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ - "asdk_app_6938a94a61d881918ef32cb999ff937c", - "connector_2b0a9009c9c64bf9933a3dae3f2b1254", - "connector_68de829bf7648191acd70a907364c67c", - "connector_68e004f14af881919eb50893d3d9f523", - "connector_69272cb413a081919685ec3c88d1744e", - "connector_0f9c9d4592e54d0a9a12b3f44a1e2010", -]; -const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = - &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; -const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; - -fn filter_disallowed_connectors(connectors: Vec) -> Vec { - filter_disallowed_connectors_for_originator(connectors, originator().value.as_str()) -} - -fn filter_disallowed_connectors_for_originator( - connectors: Vec, - originator_value: &str, -) -> Vec { - let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) { - FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS - } else { - DISALLOWED_CONNECTOR_IDS - }; - - connectors - .into_iter() - .filter(|connector| is_connector_allowed(connector, disallowed_connector_ids)) - .collect() -} - -fn is_connector_allowed(connector: &AppInfo, disallowed_connector_ids: &[&str]) -> bool { - let connector_id = connector.id.as_str(); - !connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) - && !disallowed_connector_ids.contains(&connector_id) -} - #[cfg(test)] mod tests { use super::*; @@ -554,22 +518,6 @@ mod tests { assert_eq!(filtered, vec![app("delta")]); } - #[test] - fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { - let filtered = filter_disallowed_connectors_for_originator( - vec![ - app("connector_openai_foo"), - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), - ], - "codex_atlas", - ); - assert_eq!( - filtered, - vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c"),] - ); - } - fn merged_app(id: &str, is_accessible: bool) -> AppInfo { AppInfo { id: id.to_string(), diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index aa559e9836..bdd32fbd5a 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -175,6 +175,19 @@ pub struct ResponsesWebsocketConnection { telemetry: Option>, } +impl std::fmt::Debug for ResponsesWebsocketConnection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResponsesWebsocketConnection") + .field("stream", &"") + .field("idle_timeout", &self.idle_timeout) + .field("server_reasoning_included", &self.server_reasoning_included) + .field("models_etag", &self.models_etag) + .field("server_model", &self.server_model) + .field("telemetry", &self.telemetry.as_ref().map(|_| "")) + .finish() + } +} + impl ResponsesWebsocketConnection { fn new( stream: WsStream, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index c6b63258f2..db81b45e35 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -135,6 +135,7 @@ pub struct NetworkRequirementsToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -150,6 +151,7 @@ pub struct NetworkConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -165,6 +167,7 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -177,6 +180,7 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -1040,6 +1044,7 @@ mod tests { [experimental_network] enabled = true allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true allowed_domains = ["api.example.com", "*.openai.com"] denied_domains = ["blocked.example.com"] allow_unix_sockets = ["/tmp/example.sock"] @@ -1058,6 +1063,10 @@ mod tests { assert_eq!(sourced_network.source, source); assert_eq!(sourced_network.value.enabled, Some(true)); assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); assert_eq!( sourced_network.value.allowed_domains.as_ref(), Some(&vec![ diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index fe7c2b5aa9..2b62d96254 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -48,7 +48,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } -codex-utils-sanitizer = { workspace = true } +codex-secrets = { workspace = true } codex-utils-string = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } dirs = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5eab764fc3..4de4da6e48 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -85,34 +85,111 @@ "additionalProperties": false, "description": "Config values for a single app/connector.", "properties": { - "disabled_reason": { + "default_tools_approval_mode": { "allOf": [ { - "$ref": "#/definitions/AppDisabledReason" + "$ref": "#/definitions/AppToolApproval" } ], - "description": "Reason this app was disabled." + "description": "Approval mode for tools in this app unless a tool override exists." + }, + "default_tools_enabled": { + "description": "Whether tools are enabled by default for this app.", + "type": "boolean" + }, + "destructive_enabled": { + "description": "Whether tools with `destructive_hint = true` are allowed for this app.", + "type": "boolean" }, "enabled": { "default": true, "description": "When `false`, Codex does not surface this app.", "type": "boolean" + }, + "open_world_enabled": { + "description": "Whether tools with `open_world_hint = true` are allowed for this app.", + "type": "boolean" + }, + "tools": { + "allOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + } + ], + "description": "Per-tool settings for this app." } }, "type": "object" }, - "AppDisabledReason": { + "AppToolApproval": { "enum": [ - "unknown", - "user" + "auto", + "prompt", + "approve" ], "type": "string" }, + "AppToolConfig": { + "additionalProperties": false, + "description": "Per-tool settings for a single app tool.", + "properties": { + "approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for this tool." + }, + "enabled": { + "description": "Whether this tool is enabled. `Some(true)` explicitly allows this tool.", + "type": "boolean" + } + }, + "type": "object" + }, + "AppToolsConfig": { + "additionalProperties": { + "$ref": "#/definitions/AppToolConfig" + }, + "description": "Tool settings for a single app.", + "type": "object" + }, "AppsConfigToml": { "additionalProperties": { "$ref": "#/definitions/AppConfig" }, "description": "App/connector settings loaded from `config.toml`.", + "properties": { + "_default": { + "allOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + } + ], + "description": "Default settings for all apps." + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "additionalProperties": false, + "description": "Default settings that apply to all apps.", + "properties": { + "destructive_enabled": { + "description": "Whether tools with `destructive_hint = true` are allowed by default.", + "type": "boolean" + }, + "enabled": { + "default": true, + "description": "When `false`, apps are disabled unless overridden by per-app settings.", + "type": "boolean" + }, + "open_world_enabled": { + "description": "Whether tools with `open_world_hint = true` are allowed by default.", + "type": "boolean" + } + }, "type": "object" }, "AskForApproval": { @@ -656,6 +733,9 @@ }, "type": "array" }, + "dangerously_allow_all_unix_sockets": { + "type": "boolean" + }, "dangerously_allow_non_loopback_admin": { "type": "boolean" }, @@ -1389,6 +1469,10 @@ ], "description": "Agent-related settings (thread limits, etc.)." }, + "allow_login_shell": { + "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", + "type": "boolean" + }, "analytics": { "allOf": [ { @@ -1723,7 +1807,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Optional path to a JSON file containing a complete model catalog. When set, this replaces the bundled catalog for this process." + "description": "Optional path to a JSON model catalog (applied on startup only). Per-thread `config` overrides are accepted but do not reapply this (no-ops)." }, "model_context_window": { "description": "Size of the context window for the model, in tokens.", diff --git a/codex-rs/core/src/agent/agent_names.txt b/codex-rs/core/src/agent/agent_names.txt new file mode 100644 index 0000000000..e5a712ff5c --- /dev/null +++ b/codex-rs/core/src/agent/agent_names.txt @@ -0,0 +1,87 @@ +Ash +Elm +Yew +Fir +Oak +Pine +Spruce +Cedar +Birch +Maple +Beech +Alder +Willow +Poplar +Aspen +Larch +Juniper +Cypress +Hemlock +Sequoia +Baobab +Acacia +Banyan +Teak +Olive +Cherry +Apple +Pear +Plum +Peach +Mango +Guava +Fig +Date +Palm +Holly +Ivy +Fern +Moss +Reed +Cane +Rush +Sage +Mint +Thyme +Basil +Dill +Fennel +Parsley +Laurel +Myrtle +Lotus +Tulip +Rose +Daisy +Lily +Iris +Poppy +Aster +Peony +Camellia +Azalea +Begonia +Petunia +Verbena +Marigold +Daffodil +Orchid +Violet +Clover +Bamboo +Eucalypt +Oleander +Yucca +Agave +Aloe +Cactus +Sedum +Boxwood +Hawthorn +Redwood +Ironwood +Sandal +Kapok +Mahonia +Spirea +Wisteria \ No newline at end of file diff --git a/codex-rs/core/src/agent/builtins/explorer.toml b/codex-rs/core/src/agent/builtins/explorer.toml index 0b6def0323..e69de29bb2 100644 --- a/codex-rs/core/src/agent/builtins/explorer.toml +++ b/codex-rs/core/src/agent/builtins/explorer.toml @@ -1,2 +0,0 @@ -model = "gpt-5.1-codex-mini" -model_reasoning_effort = "medium" diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 847bb7f761..7287d25283 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -3,7 +3,9 @@ use crate::agent::guards::Guards; use crate::agent::status::is_final; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::find_thread_path_by_id_str; use crate::session_prefix::format_subagent_notification_message; +use crate::state_db; use crate::thread_manager::ThreadManagerState; use codex_protocol::ThreadId; use codex_protocol::protocol::Op; @@ -11,11 +13,20 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; -use std::path::PathBuf; use std::sync::Arc; use std::sync::Weak; use tokio::sync::watch; +const AGENT_NAMES: &str = include_str!("agent_names.txt"); + +fn agent_nickname_list() -> Vec<&'static str> { + AGENT_NAMES + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect() +} + /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -48,7 +59,24 @@ impl AgentControl { session_source: Option, ) -> CodexResult { let state = self.upgrade()?; - let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let session_source = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_role, + .. + })) => { + let agent_nickname = reservation.reserve_agent_nickname(&agent_nickname_list())?; + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_nickname: Some(agent_nickname), + agent_role, + })) + } + other => other, + }; let notification_source = session_source.clone(); // The same `AgentControl` is sent to spawn the thread. @@ -77,12 +105,51 @@ impl AgentControl { pub(crate) async fn resume_agent_from_rollout( &self, config: crate::config::Config, - rollout_path: PathBuf, + thread_id: ThreadId, session_source: SessionSource, ) -> CodexResult { let state = self.upgrade()?; - let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let session_source = match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + .. + }) => { + // Collab resume callers rebuild a placeholder ThreadSpawn source. Rehydrate the + // stored nickname/role from sqlite when available; otherwise leave both unset. + let (resumed_agent_nickname, resumed_agent_role) = + if let Some(state_db_ctx) = state_db::get_state_db(&config, None).await { + match state_db_ctx.get_thread(thread_id).await { + Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), + Ok(None) | Err(_) => (None, None), + } + } else { + (None, None) + }; + let reserved_agent_nickname = resumed_agent_nickname + .as_deref() + .map(|agent_nickname| { + reservation.reserve_agent_nickname_with_preference( + &agent_nickname_list(), + Some(agent_nickname), + ) + }) + .transpose()?; + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_nickname: reserved_agent_nickname, + agent_role: resumed_agent_role, + }) + } + other => other, + }; let notification_source = session_source.clone(); + let rollout_path = + find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string()) + .await? + .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?; let resumed_thread = state .resume_thread_from_rollout_with_source( @@ -151,6 +218,23 @@ impl AgentControl { thread.agent_status().await } + pub(crate) async fn get_agent_nickname_and_role( + &self, + agent_id: ThreadId, + ) -> Option<(Option, Option)> { + let Ok(state) = self.upgrade() else { + return None; + }; + let Ok(thread) = state.get_thread(agent_id).await else { + return None; + }; + let session_source = thread.config_snapshot().await.session_source; + Some(( + session_source.get_nickname(), + session_source.get_agent_role(), + )) + } + /// Subscribe to status updates for `agent_id`, yielding the latest value and changes. pub(crate) async fn subscribe_status( &self, @@ -234,6 +318,8 @@ mod tests { use crate::agent::agent_status_from_event; use crate::config::Config; use crate::config::ConfigBuilder; + use crate::config_loader::LoaderOverrides; + use crate::features::Feature; use crate::session_prefix::SUBAGENT_NOTIFICATION_OPEN_TAG; use assert_matches::assert_matches; use codex_protocol::config_types::ModeKind; @@ -261,6 +347,12 @@ mod tests { let config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) .cli_overrides(cli_overrides) + .loader_overrides(LoaderOverrides { + #[cfg(target_os = "macos")] + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some(String::new()), + ..LoaderOverrides::default() + }) .build() .await .expect("load default test config"); @@ -441,11 +533,7 @@ mod tests { let control = AgentControl::default(); let (_home, config) = test_config().await; let err = control - .resume_agent_from_rollout( - config, - PathBuf::from("/tmp/missing-rollout.jsonl"), - SessionSource::Exec, - ) + .resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec) .await .expect_err("resume_agent should fail without a manager"); assert_eq!( @@ -717,12 +805,6 @@ mod tests { .spawn_agent(config.clone(), text_input("hello"), None) .await .expect("spawn_agent should succeed"); - let rollout_path = manager - .get_thread(resumable_id) - .await - .expect("thread should exist") - .rollout_path() - .expect("rollout path should exist"); let _ = control .shutdown_agent(resumable_id) .await @@ -734,7 +816,7 @@ mod tests { .expect("spawn_agent should succeed for active slot"); let err = control - .resume_agent_from_rollout(config, rollout_path, SessionSource::Exec) + .resume_agent_from_rollout(config, resumable_id, SessionSource::Exec) .await .expect_err("resume should respect max threads"); let CodexErr::AgentLimitReached { @@ -766,9 +848,8 @@ mod tests { ); let control = manager.agent_control(); - let missing_rollout = config.codex_home.join("sessions/missing-rollout.jsonl"); let _ = control - .resume_agent_from_rollout(config.clone(), missing_rollout, SessionSource::Exec) + .resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec) .await .expect_err("resume should fail for missing rollout path"); @@ -795,6 +876,8 @@ mod tests { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), })), ) .await @@ -812,4 +895,176 @@ mod tests { assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); } + + #[tokio::test] + async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let snapshot = child_thread.config_snapshot().await; + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: seen_parent_thread_id, + depth, + agent_nickname, + agent_role, + }) = snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(seen_parent_thread_id, parent_thread_id); + assert_eq!(depth, 1); + assert!(agent_nickname.is_some()); + assert_eq!(agent_role, Some("explorer".to_string())); + } + + #[tokio::test] + async fn resume_thread_subagent_restores_stored_nickname_and_role() { + let (home, mut config) = test_config().await; + config.features.enable(Feature::Sqlite); + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + let harness = AgentControlHarness { + _home: home, + config, + manager, + control, + }; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let mut status_rx = harness + .control + .subscribe_status(child_thread_id) + .await + .expect("status subscription should succeed"); + if matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { + timeout(Duration::from_secs(5), async { + loop { + status_rx + .changed() + .await + .expect("child status should advance past pending init"); + if !matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { + break; + } + } + }) + .await + .expect("child should initialize before shutdown"); + } + let original_snapshot = child_thread.config_snapshot().await; + let original_nickname = original_snapshot + .session_source + .get_nickname() + .expect("spawned sub-agent should have a nickname"); + let state_db = child_thread + .state_db() + .expect("sqlite state db should be available for nickname resume test"); + timeout(Duration::from_secs(5), async { + loop { + if let Ok(Some(metadata)) = state_db.get_thread(child_thread_id).await + && metadata.agent_nickname.is_some() + && metadata.agent_role.as_deref() == Some("explorer") + { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child thread metadata should be persisted to sqlite before shutdown"); + + let _ = harness + .control + .shutdown_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + + let resumed_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: None, + }), + ) + .await + .expect("resume should succeed"); + assert_eq!(resumed_thread_id, child_thread_id); + + let resumed_snapshot = harness + .manager + .get_thread(resumed_thread_id) + .await + .expect("resumed child thread should exist") + .config_snapshot() + .await; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: resumed_parent_thread_id, + depth: resumed_depth, + agent_nickname: resumed_nickname, + agent_role: resumed_role, + }) = resumed_snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_eq!(resumed_depth, 1); + assert_eq!(resumed_nickname, Some(original_nickname)); + assert_eq!(resumed_role, Some("explorer".to_string())); + + let _ = harness + .control + .shutdown_agent(resumed_thread_id) + .await + .expect("resumed child shutdown should submit"); + } } diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index 1964169cd7..223373c6df 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -3,6 +3,8 @@ use crate::error::Result; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use rand::prelude::IndexedRandom; +use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; use std::sync::Mutex; @@ -17,10 +19,18 @@ use std::sync::atomic::Ordering; /// is). #[derive(Default)] pub(crate) struct Guards { - threads_set: Mutex>, + active_agents: Mutex, total_count: AtomicUsize, } +#[derive(Default)] +struct ActiveAgents { + threads_set: HashSet, + thread_agent_nicknames: HashMap, + used_agent_nicknames: HashSet, + nickname_reset_count: usize, +} + fn session_depth(session_source: &SessionSource) -> i32 { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, @@ -52,28 +62,69 @@ impl Guards { Ok(SpawnReservation { state: Arc::clone(self), active: true, + reserved_agent_nickname: None, }) } pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) { let removed = { - let mut threads = self - .threads_set + let mut active_agents = self + .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - threads.remove(&thread_id) + let removed = active_agents.threads_set.remove(&thread_id); + active_agents.thread_agent_nicknames.remove(&thread_id); + removed }; if removed { self.total_count.fetch_sub(1, Ordering::AcqRel); } } - fn register_spawned_thread(&self, thread_id: ThreadId) { - let mut threads = self - .threads_set + fn register_spawned_thread(&self, thread_id: ThreadId, agent_nickname: Option) { + let mut active_agents = self + .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - threads.insert(thread_id); + active_agents.threads_set.insert(thread_id); + if let Some(agent_nickname) = agent_nickname { + active_agents + .used_agent_nicknames + .insert(agent_nickname.clone()); + active_agents + .thread_agent_nicknames + .insert(thread_id, agent_nickname); + } + } + + fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let agent_nickname = if let Some(preferred) = preferred { + preferred.to_string() + } else { + if names.is_empty() { + return None; + } + let available_names: Vec<&str> = names + .iter() + .copied() + .filter(|name| !active_agents.used_agent_nicknames.contains(*name)) + .collect(); + if let Some(name) = available_names.choose(&mut rand::rng()) { + (*name).to_string() + } else { + active_agents.used_agent_nicknames.clear(); + active_agents.nickname_reset_count += 1; + names.choose(&mut rand::rng())?.to_string() + } + }; + active_agents + .used_agent_nicknames + .insert(agent_nickname.clone()); + Some(agent_nickname) } fn try_increment_spawned(&self, max_threads: usize) -> bool { @@ -98,11 +149,41 @@ impl Guards { pub(crate) struct SpawnReservation { state: Arc, active: bool, + reserved_agent_nickname: Option, } impl SpawnReservation { - pub(crate) fn commit(mut self, thread_id: ThreadId) { - self.state.register_spawned_thread(thread_id); + pub(crate) fn reserve_agent_nickname(&mut self, names: &[&str]) -> Result { + self.reserve_agent_nickname_with_preference(names, None) + } + + pub(crate) fn reserve_agent_nickname_with_preference( + &mut self, + names: &[&str], + preferred: Option<&str>, + ) -> Result { + let agent_nickname = self + .state + .reserve_agent_nickname(names, preferred) + .ok_or_else(|| { + CodexErr::UnsupportedOperation("no available agent nicknames".to_string()) + })?; + self.reserved_agent_nickname = Some(agent_nickname.clone()); + Ok(agent_nickname) + } + + pub(crate) fn commit(self, thread_id: ThreadId) { + self.commit_with_agent_nickname(thread_id, None); + } + + pub(crate) fn commit_with_agent_nickname( + mut self, + thread_id: ThreadId, + agent_nickname: Option, + ) { + let agent_nickname = self.reserved_agent_nickname.take().or(agent_nickname); + self.state + .register_spawned_thread(thread_id, agent_nickname); self.active = false; } } @@ -130,6 +211,8 @@ mod tests { let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: ThreadId::new(), depth: 1, + agent_nickname: None, + agent_role: None, }); let child_depth = next_thread_spawn_depth(&session_source); assert_eq!(child_depth, 2); @@ -232,4 +315,83 @@ mod tests { .expect("slot released after second thread removal"); drop(reservation); } + + #[test] + fn failed_spawn_keeps_nickname_marked_used() { + let guards = Arc::new(Guards::default()); + let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname(&["alpha"]) + .expect("reserve agent name"); + assert_eq!(agent_nickname, "alpha"); + drop(reservation); + + let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname(&["alpha", "beta"]) + .expect("unused name should still be preferred"); + assert_eq!(agent_nickname, "beta"); + } + + #[test] + fn agent_nickname_resets_used_pool_when_exhausted() { + let guards = Arc::new(Guards::default()); + let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname(&["alpha"]) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(first_id); + assert_eq!(first_name, "alpha"); + + let mut second = guards + .reserve_spawn_slot(None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname(&["alpha"]) + .expect("name should be reused after pool reset"); + assert_eq!(second_name, "alpha"); + let active_agents = guards + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); + } + + #[test] + fn released_nickname_stays_used_until_pool_reset() { + let guards = Arc::new(Guards::default()); + + let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname(&["alpha"]) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(first_id); + assert_eq!(first_name, "alpha"); + + guards.release_spawned_thread(first_id); + + let mut second = guards + .reserve_spawn_slot(None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname(&["alpha", "beta"]) + .expect("released name should still be marked used"); + assert_eq!(second_name, "beta"); + let second_id = ThreadId::new(); + second.commit(second_id); + guards.release_spawned_thread(second_id); + + let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname(&["alpha", "beta"]) + .expect("pool reset should permit a duplicate"); + assert!(third_name == "alpha" || third_name == "beta"); + let active_agents = guards + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); + } } diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 97cde2c8a3..16460fb7fd 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -162,11 +162,10 @@ mod built_in { ( "explorer".to_string(), AgentRoleConfig { - description: Some(r#"Use `explorer` for all codebase questions. + description: Some(r#"Use `explorer` for specific codebase questions. Explorers are fast and authoritative. -Always prefer them over manual search or file reading. +They must be used to ask specific, well-scoped questions on the codebase. Rules: -- Ask explorers first and precisely. - Do not re-read or re-search code they cover. - Trust explorer results without verification. - Run explorers in parallel when useful. @@ -268,6 +267,7 @@ mod tests { } #[tokio::test] + #[ignore = "No role requiring it for now"] async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() { let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; let before_layers = session_flags_layer_count(&config); diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 658e212fa7..6f33910ce7 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -28,6 +28,7 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -123,6 +124,7 @@ struct ModelClientState { include_timing_metrics: bool, beta_features_header: Option, disable_websockets: AtomicBool, + cached_websocket_connection: StdMutex>, } /// Resolved API client setup for a single request attempt. @@ -228,6 +230,7 @@ impl ModelClient { include_timing_metrics, beta_features_header, disable_websockets: AtomicBool::new(false), + cached_websocket_connection: StdMutex::new(None), }), } } @@ -239,13 +242,29 @@ impl ModelClient { pub fn new_session(&self) -> ModelClientSession { ModelClientSession { client: self.clone(), - connection: None, + connection: self.take_cached_websocket_connection(), websocket_last_request: None, websocket_last_response_rx: None, turn_state: Arc::new(OnceLock::new()), } } + fn take_cached_websocket_connection(&self) -> Option { + self.state + .cached_websocket_connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } + + fn store_cached_websocket_connection(&self, connection: ApiWebSocketConnection) { + *self + .state + .cached_websocket_connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(connection); + } + /// Compacts the current conversation history using the Compact endpoint. /// /// This is a unary call (no streaming) that returns a new list of @@ -452,6 +471,14 @@ impl ModelClient { } } +impl Drop for ModelClientSession { + fn drop(&mut self) { + if let Some(connection) = self.connection.take() { + self.client.store_cached_websocket_connection(connection); + } + } +} + impl ModelClientSession { fn activate_http_fallback(&self, websocket_enabled: bool) -> bool { websocket_enabled diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d9ba1ca3f6..fcddf4e83e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -149,6 +149,7 @@ use crate::mcp::effective_mcp_servers; use crate::mcp::maybe_prompt_and_install_mcp_dependencies; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; +use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only; use crate::mcp_connection_manager::filter_mcp_tools_by_name; use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only; @@ -608,6 +609,7 @@ impl TurnContext { features: &features, web_search_mode: self.tools_config.web_search_mode, }) + .with_allow_login_shell(self.tools_config.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); Self { @@ -940,6 +942,7 @@ impl Session { features: &per_turn_config.features, web_search_mode: Some(per_turn_config.web_search_mode.value()), }) + .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); @@ -1420,6 +1423,8 @@ impl Session { &session_configuration.approval_policy, tx_event.clone(), sandbox_state, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth), ) .await; { @@ -3068,6 +3073,8 @@ impl Session { &turn_context.config.permissions.approval_policy, self.get_tx_event(), sandbox_state, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth.as_ref()), ) .await; { @@ -4117,6 +4124,7 @@ async fn spawn_review_thread( features: &review_features, web_search_mode: Some(review_web_search_mode), }) + .with_allow_login_shell(config.permissions.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); let review_prompt = resolved.prompt.clone(); @@ -4826,6 +4834,7 @@ fn connector_inserted_in_messages( fn filter_codex_apps_mcp_tools( mcp_tools: &HashMap, connectors: &[connectors::AppInfo], + config: &Config, ) -> HashMap { let allowed: HashSet<&str> = connectors .iter() @@ -4841,7 +4850,7 @@ fn filter_codex_apps_mcp_tools( let Some(connector_id) = codex_apps_connector_id(tool) else { return false; }; - allowed.contains(connector_id) + allowed.contains(connector_id) && connectors::codex_app_tool_is_enabled(config, tool) }) .map(|(name, tool)| (name.clone(), tool.clone())) .collect() @@ -5015,9 +5024,9 @@ async fn built_tools( None }; - let app_tools = connectors - .as_ref() - .map(|connectors| filter_codex_apps_mcp_tools(&mcp_tools, connectors)); + let app_tools = connectors.as_ref().map(|connectors| { + filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config) + }); if let Some(connectors) = connectors.as_ref() { let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| { @@ -5042,7 +5051,8 @@ async fn built_tools( explicitly_enabled.as_ref(), )); - mcp_tools = selected_mcp_tools; + mcp_tools = + connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config); } Ok(Arc::new(ToolRouter::from_config( @@ -5993,7 +6003,7 @@ mod tests { let config_toml_path = codex_home.join(CONFIG_TOML_FILE); std::fs::write( &config_toml_path, - "[apps.calendar]\nenabled = false\ndisabled_reason = \"user\"\n", + "[apps.calendar]\nenabled = false\ndestructive_enabled = false\n", ) .expect("write user config"); @@ -6015,10 +6025,7 @@ mod tests { .expect("calendar app config exists"); assert!(!app.enabled); - assert_eq!( - app.disabled_reason, - Some(crate::config::types::AppDisabledReason::User) - ); + assert_eq!(app.destructive_enabled, Some(false)); } #[test] @@ -8342,7 +8349,6 @@ mod tests { expiration: timeout_ms.into(), env: HashMap::new(), network: None, - network_attempt_id: None, sandbox_permissions, windows_sandbox_level: turn_context.windows_sandbox_level, justification: Some("test".to_string()), @@ -8356,7 +8362,6 @@ mod tests { expiration: timeout_ms.into(), env: HashMap::new(), network: None, - network_attempt_id: None, windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a27508d1a9..76ba2fcb2d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -139,6 +139,15 @@ pub struct Permissions { pub sandbox_policy: Constrained, /// Effective network configuration applied to all spawned processes. pub network: Option, + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: bool, /// Policy used to build process environments for shell/unified exec. pub shell_environment_policy: ShellEnvironmentPolicy, /// Effective Windows sandbox mode derived from `[windows].sandbox` or @@ -952,6 +961,16 @@ pub struct ConfigToml { #[serde(default)] pub shell_environment_policy: ShellEnvironmentPolicyToml, + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: Option, + /// Sandbox mode to use. pub sandbox_mode: Option, @@ -1090,8 +1109,8 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, - /// Optional path to a JSON file containing a complete model catalog. - /// When set, this replaces the bundled catalog for this process. + /// Optional path to a JSON model catalog (applied on startup only). + /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). pub model_catalog_json: Option, /// Optionally specify a personality for the model @@ -1710,6 +1729,7 @@ impl Config { .clone(); let shell_environment_policy = cfg.shell_environment_policy.into(); + let allow_login_shell = cfg.allow_login_shell.unwrap_or(true); let history = cfg.history.unwrap_or_default(); @@ -1950,6 +1970,7 @@ impl Config { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, network, + allow_login_shell, shell_environment_policy, windows_sandbox_mode, macos_seatbelt_profile_extensions: None, @@ -2401,6 +2422,7 @@ allowed_domains = ["openai.com"] allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: None, dangerously_allow_non_loopback_admin: None, + dangerously_allow_all_unix_sockets: None, mode: None, allowed_domains: Some(vec!["openai.com".to_string()]), denied_domains: None, @@ -4518,6 +4540,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4636,6 +4659,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4752,6 +4776,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4854,6 +4879,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -5428,6 +5454,27 @@ mcp_oauth_callback_port = 5678 Ok(()) } + #[test] + fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg: ConfigToml = toml::from_str( + r#" +model = "gpt-5.1" +allow_login_shell = false +"#, + ) + .expect("TOML deserialization should succeed for allow_login_shell"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.permissions.allow_login_shell); + Ok(()) + } + #[test] fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 1957455d6f..cd65a4dc41 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -178,6 +178,13 @@ impl NetworkProxySpec { constraints.dangerously_allow_non_loopback_admin = Some(dangerously_allow_non_loopback_admin); } + if let Some(dangerously_allow_all_unix_sockets) = + requirements.dangerously_allow_all_unix_sockets + { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + constraints.dangerously_allow_all_unix_sockets = + Some(dangerously_allow_all_unix_sockets); + } if let Some(allowed_domains) = requirements.allowed_domains.clone() { config.network.allowed_domains = allowed_domains.clone(); constraints.allowed_domains = Some(allowed_domains); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index a03b5273da..dd242bd857 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -24,6 +24,7 @@ pub struct NetworkToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, #[schemars(with = "Option")] pub mode: Option, pub allowed_domains: Option>, @@ -74,6 +75,9 @@ impl NetworkToml { config.network.dangerously_allow_non_loopback_admin = dangerously_allow_non_loopback_admin; } + if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + } if let Some(mode) = self.mode { config.network.mode = mode; } diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 02a9da23fd..1033db728d 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -701,7 +701,7 @@ mod tests { use super::*; use anyhow::Result; use codex_app_server_protocol::AppConfig; - use codex_app_server_protocol::AppDisabledReason; + use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; @@ -827,13 +827,13 @@ personality = true service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "apps.app1.disabled_reason".to_string(), - value: serde_json::json!("user"), + key_path: "apps.app1.default_tools_approval_mode".to_string(), + value: serde_json::json!("prompt"), merge_strategy: MergeStrategy::Replace, expected_version: None, }) .await - .expect("write apps.app1.disabled_reason succeeds"); + .expect("write apps.app1.default_tools_approval_mode succeeds"); let read = service .read(ConfigReadParams { @@ -846,11 +846,16 @@ personality = true assert_eq!( read.config.apps, Some(AppsConfig { + default: None, apps: std::collections::HashMap::from([( "app1".to_string(), AppConfig { enabled: false, - disabled_reason: Some(AppDisabledReason::User), + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, }, )]), }) diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index ca90414c27..2e3573f770 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -425,20 +425,58 @@ impl From for MemoriesConfig { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] #[serde(rename_all = "snake_case")] -pub enum AppDisabledReason { - Unknown, - User, +pub enum AppToolApproval { + #[default] + Auto, + Prompt, + Approve, } -impl fmt::Display for AppDisabledReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AppDisabledReason::Unknown => write!(f, "unknown"), - AppDisabledReason::User => write!(f, "user"), - } - } +/// Default settings that apply to all apps. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsDefaultConfig { + /// When `false`, apps are disabled unless overridden by per-app settings. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Whether tools with `destructive_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub destructive_enabled: bool, + + /// Whether tools with `open_world_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub open_world_enabled: bool, +} + +/// Per-tool settings for a single app tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolConfig { + /// Whether this tool is enabled. `Some(true)` explicitly allows this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + +/// Tool settings for a single app. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolsConfig { + /// Per-tool overrides keyed by tool name (for example `repos/list`). + #[serde(default, flatten)] + pub tools: HashMap, } /// Config values for a single app/connector. @@ -449,15 +487,35 @@ pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, - /// Reason this app was disabled. + /// Whether tools with `destructive_hint = true` are allowed for this app. #[serde(default, skip_serializing_if = "Option::is_none")] - pub disabled_reason: Option, + pub destructive_enabled: Option, + + /// Whether tools with `open_world_hint = true` are allowed for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_world_enabled: Option, + + /// Approval mode for tools in this app unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Whether tools are enabled by default for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_enabled: Option, + + /// Per-tool settings for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, } /// App/connector settings loaded from `config.toml`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct AppsConfigToml { + /// Default settings for all apps. + #[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")] + pub default: Option, + /// Per-app settings keyed by app ID (for example `[apps.google_drive]`). #[serde(default, flatten)] pub apps: HashMap, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 884e32dd17..33b003770e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -11,6 +11,7 @@ pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; use codex_protocol::protocol::SandboxPolicy; +use rmcp::model::ToolAnnotations; use serde::Deserialize; use tracing::warn; @@ -18,16 +19,35 @@ use crate::AuthManager; use crate::CodexAuth; use crate::SandboxState; use crate::config::Config; +use crate::config::types::AppToolApproval; use crate::config::types::AppsConfigToml; +use crate::default_client::is_first_party_chat_originator; +use crate::default_client::originator; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; -use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; use crate::mcp_connection_manager::McpConnectionManager; +use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); +const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppToolPolicy { + pub enabled: bool, + pub approval: AppToolApproval, +} + +impl Default for AppToolPolicy { + fn default() -> Self { + Self { + enabled: true, + approval: AppToolApproval::Auto, + } + } +} #[derive(Clone, PartialEq, Eq)] struct AccessibleConnectorsCacheKey { @@ -47,10 +67,20 @@ struct CachedAccessibleConnectors { static ACCESSIBLE_CONNECTORS_CACHE: LazyLock>> = LazyLock::new(|| StdMutex::new(None)); +#[derive(Debug, Clone)] +pub struct AccessibleConnectorsStatus { + pub connectors: Vec, + pub codex_apps_ready: bool, +} + pub async fn list_accessible_connectors_from_mcp_tools( config: &Config, ) -> anyhow::Result> { - list_accessible_connectors_from_mcp_tools_with_options(config, false).await + Ok( + list_accessible_connectors_from_mcp_tools_with_options_and_status(config, false) + .await? + .connectors, + ) } pub async fn list_cached_accessible_connectors_from_mcp_tools( @@ -63,15 +93,29 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( let auth_manager = auth_manager_from_config(config); let auth = auth_manager.auth().await; let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); - read_cached_accessible_connectors(&cache_key) + read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors) } pub async fn list_accessible_connectors_from_mcp_tools_with_options( config: &Config, force_refetch: bool, ) -> anyhow::Result> { + Ok( + list_accessible_connectors_from_mcp_tools_with_options_and_status(config, force_refetch) + .await? + .connectors, + ) +} + +pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( + config: &Config, + force_refetch: bool, +) -> anyhow::Result { if !config.features.enabled(Feature::Apps) { - return Ok(Vec::new()); + return Ok(AccessibleConnectorsStatus { + connectors: Vec::new(), + codex_apps_ready: true, + }); } let auth_manager = auth_manager_from_config(config); @@ -79,12 +123,19 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key) { - return Ok(cached_connectors); + let cached_connectors = filter_disallowed_connectors(cached_connectors); + return Ok(AccessibleConnectorsStatus { + connectors: cached_connectors, + codex_apps_ready: true, + }); } let mcp_servers = with_codex_apps_mcp(HashMap::new(), true, auth.as_ref(), config); if mcp_servers.is_empty() { - return Ok(Vec::new()); + return Ok(AccessibleConnectorsStatus { + connectors: Vec::new(), + codex_apps_ready: true, + }); } let auth_status_entries = @@ -107,6 +158,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( &config.permissions.approval_policy, tx_event, sandbox_state, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth.as_ref()), ) .await; @@ -120,23 +173,45 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( ); } + let mut tools = mcp_connection_manager.list_all_tools().await; + let mut should_reload_tools = false; let codex_apps_ready = if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { - let timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); - mcp_connection_manager - .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) - .await + let immediate_ready = mcp_connection_manager + .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO) + .await; + if immediate_ready { + true + } else if tools.is_empty() { + let timeout = cfg + .startup_timeout_sec + .unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS); + let ready = mcp_connection_manager + .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) + .await; + should_reload_tools = ready; + ready + } else { + false + } } else { false }; + if should_reload_tools { + tools = mcp_connection_manager.list_all_tools().await; + } + if codex_apps_ready { + cancel_token.cancel(); + } - let tools = mcp_connection_manager.list_all_tools().await; - cancel_token.cancel(); - - let accessible_connectors = accessible_connectors_from_mcp_tools(&tools); + let accessible_connectors = + filter_disallowed_connectors(accessible_connectors_from_mcp_tools(&tools)); if codex_apps_ready || !accessible_connectors.is_empty() { write_cached_accessible_connectors(cache_key, &accessible_connectors); } - Ok(accessible_connectors) + Ok(AccessibleConnectorsStatus { + connectors: accessible_connectors, + codex_apps_ready, + }) } fn accessible_connectors_cache_key( @@ -279,21 +354,185 @@ pub fn merge_connectors( } pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { - let apps = read_apps_config(config).map(|apps_config| apps_config.apps); - for connector in &mut connectors { - if let Some(app) = apps.as_ref().and_then(|apps| apps.get(&connector.id)) { - connector.is_enabled = app.enabled; + let apps_config = read_apps_config(config); + if let Some(apps_config) = apps_config.as_ref() { + for connector in &mut connectors { + connector.is_enabled = app_is_enabled(apps_config, Some(connector.id.as_str())); } } connectors } +pub(crate) fn app_tool_policy( + config: &Config, + connector_id: Option<&str>, + tool_name: &str, + tool_title: Option<&str>, + annotations: Option<&ToolAnnotations>, +) -> AppToolPolicy { + let apps_config = read_apps_config(config); + app_tool_policy_from_apps_config( + apps_config.as_ref(), + connector_id, + tool_name, + tool_title, + annotations, + ) +} + +pub(crate) fn codex_app_tool_is_enabled( + config: &Config, + tool_info: &crate::mcp_connection_manager::ToolInfo, +) -> bool { + if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME { + return true; + } + + app_tool_policy( + config, + tool_info.connector_id.as_deref(), + &tool_info.tool_name, + tool_info.tool.title.as_deref(), + tool_info.tool.annotations.as_ref(), + ) + .enabled +} + +pub(crate) fn filter_codex_apps_tools_by_policy( + mut mcp_tools: HashMap, + config: &Config, +) -> HashMap { + mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info)); + mcp_tools +} + +const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ + "asdk_app_6938a94a61d881918ef32cb999ff937c", + "connector_2b0a9009c9c64bf9933a3dae3f2b1254", + "connector_68de829bf7648191acd70a907364c67c", + "connector_68e004f14af881919eb50893d3d9f523", + "connector_69272cb413a081919685ec3c88d1744e", +]; +const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = + &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; +const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; + +pub fn filter_disallowed_connectors(connectors: Vec) -> Vec { + filter_disallowed_connectors_for_originator(connectors, originator().value.as_str()) +} + +pub(crate) fn is_connector_id_allowed(connector_id: &str) -> bool { + is_connector_id_allowed_for_originator(connector_id, originator().value.as_str()) +} + +fn filter_disallowed_connectors_for_originator( + connectors: Vec, + originator_value: &str, +) -> Vec { + connectors + .into_iter() + .filter(|connector| { + is_connector_id_allowed_for_originator(connector.id.as_str(), originator_value) + }) + .collect() +} + +fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: &str) -> bool { + let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) { + FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS + } else { + DISALLOWED_CONNECTOR_IDS + }; + + !connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) + && !disallowed_connector_ids.contains(&connector_id) +} + fn read_apps_config(config: &Config) -> Option { let effective_config = config.config_layer_stack.effective_config(); let apps_config = effective_config.as_table()?.get("apps")?.clone(); AppsConfigToml::deserialize(apps_config).ok() } +fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool { + let default_enabled = apps_config + .default + .as_ref() + .map(|defaults| defaults.enabled) + .unwrap_or(true); + + connector_id + .and_then(|connector_id| apps_config.apps.get(connector_id)) + .map(|app| app.enabled) + .unwrap_or(default_enabled) +} + +fn app_tool_policy_from_apps_config( + apps_config: Option<&AppsConfigToml>, + connector_id: Option<&str>, + tool_name: &str, + tool_title: Option<&str>, + annotations: Option<&ToolAnnotations>, +) -> AppToolPolicy { + let Some(apps_config) = apps_config else { + return AppToolPolicy::default(); + }; + + let app = connector_id.and_then(|connector_id| apps_config.apps.get(connector_id)); + let tools = app.and_then(|app| app.tools.as_ref()); + let tool_config = tools.and_then(|tools| { + tools + .tools + .get(tool_name) + .or_else(|| tool_title.and_then(|title| tools.tools.get(title))) + }); + let approval = tool_config + .and_then(|tool| tool.approval_mode) + .or_else(|| app.and_then(|app| app.default_tools_approval_mode)) + .unwrap_or(AppToolApproval::Auto); + + if !app_is_enabled(apps_config, connector_id) { + return AppToolPolicy { + enabled: false, + approval, + }; + } + + if let Some(enabled) = tool_config.and_then(|tool| tool.enabled) { + return AppToolPolicy { enabled, approval }; + } + + if let Some(enabled) = app.and_then(|app| app.default_tools_enabled) { + return AppToolPolicy { enabled, approval }; + } + + let app_defaults = apps_config.default.as_ref(); + let destructive_enabled = app + .and_then(|app| app.destructive_enabled) + .unwrap_or_else(|| { + app_defaults + .map(|defaults| defaults.destructive_enabled) + .unwrap_or(true) + }); + let open_world_enabled = app + .and_then(|app| app.open_world_enabled) + .unwrap_or_else(|| { + app_defaults + .map(|defaults| defaults.open_world_enabled) + .unwrap_or(true) + }); + let destructive_hint = annotations + .and_then(|annotations| annotations.destructive_hint) + .unwrap_or(false); + let open_world_hint = annotations + .and_then(|annotations| annotations.open_world_hint) + .unwrap_or(false); + let enabled = + (destructive_enabled || !destructive_hint) && (open_world_enabled || !open_world_hint); + + AppToolPolicy { enabled, approval } +} + fn collect_accessible_connectors(tools: I) -> Vec where I: IntoIterator)>, @@ -368,3 +607,406 @@ pub fn connector_name_slug(name: &str) -> String { fn format_connector_label(name: &str, _id: &str) -> String { name.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::AppConfig; + use crate::config::types::AppToolConfig; + use crate::config::types::AppToolsConfig; + use crate::config::types::AppsDefaultConfig; + use pretty_assertions::assert_eq; + + fn annotations( + destructive_hint: Option, + open_world_hint: Option, + ) -> ToolAnnotations { + ToolAnnotations { + destructive_hint, + idempotent_hint: None, + open_world_hint, + read_only_hint: None, + title: None, + } + } + + fn app(id: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + branding: None, + app_metadata: None, + labels: None, + is_accessible: false, + is_enabled: true, + } + } + + #[test] + fn app_tool_policy_uses_global_defaults_for_destructive_hints() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_is_enabled_uses_default_for_unconfigured_apps() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + assert!(!app_is_enabled(&apps_config, Some("calendar"))); + assert!(!app_is_enabled(&apps_config, None)); + } + + #[test] + fn app_is_enabled_prefers_per_app_override_over_default() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: None, + }, + )]), + }; + + assert!(app_is_enabled(&apps_config, Some("calendar"))); + assert!(!app_is_enabled(&apps_config, Some("drive"))); + } + + #[test] + fn app_tool_policy_honors_default_app_enabled_false() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "events/create".to_string(), + AppToolConfig { + enabled: Some(true), + approval_mode: None, + }, + )]), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: None, + default_tools_enabled: Some(true), + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(true), + open_world_enabled: Some(true), + default_tools_approval_mode: Some(AppToolApproval::Approve), + default_tools_enabled: Some(false), + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Approve, + } + ); + } + + #[test] + fn app_tool_policy_uses_default_tools_approval_mode() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::new(), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Prompt, + } + ); + } + + #[test] + fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: Some(AppToolApproval::Auto), + default_tools_enabled: Some(false), + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "events/create".to_string(), + AppToolConfig { + enabled: Some(true), + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "calendar_events/create", + Some("events/create"), + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Approve, + } + ); + } + + #[test] + fn filter_disallowed_connectors_allows_non_disallowed_connectors() { + let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); + assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); + } + + #[test] + fn filter_disallowed_connectors_filters_openai_prefix() { + let filtered = filter_disallowed_connectors(vec![ + app("connector_openai_foo"), + app("connector_openai_bar"), + app("gamma"), + ]); + assert_eq!(filtered, vec![app("gamma")]); + } + + #[test] + fn filter_disallowed_connectors_filters_disallowed_connector_ids() { + let filtered = filter_disallowed_connectors(vec![ + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("delta"), + ]); + assert_eq!(filtered, vec![app("delta")]); + } + + #[test] + fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { + let filtered = filter_disallowed_connectors_for_originator( + vec![ + app("connector_openai_foo"), + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), + ], + "codex_atlas", + ); + assert_eq!( + filtered, + vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")] + ); + } +} diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 4e1220b87b..1e0e59d4e0 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -15,7 +15,6 @@ use tokio::io::AsyncReadExt; use tokio::io::BufReader; use tokio::process::Child; use tokio_util::sync::CancellationToken; -use uuid::Uuid; use crate::error::CodexErr; use crate::error::Result; @@ -67,7 +66,6 @@ pub struct ExecParams { pub expiration: ExecExpiration, pub env: HashMap, pub network: Option, - pub network_attempt_id: Option, pub sandbox_permissions: SandboxPermissions, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub justification: Option, @@ -186,15 +184,13 @@ pub async fn process_exec_tool_call( mut env, expiration, network, - network_attempt_id, sandbox_permissions, windows_sandbox_level, justification, arg0: _, } = params; - let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); if let Some(network) = network.as_ref() { - network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); + network.apply_to_env(&mut env); } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -242,7 +238,6 @@ pub(crate) async fn execute_exec_env( cwd, env, network, - network_attempt_id, expiration, sandbox, windows_sandbox_level, @@ -251,18 +246,12 @@ pub(crate) async fn execute_exec_env( arg0, } = env; - let network_attempt_id = match network_attempt_id.as_deref() { - Some(attempt_id) => Uuid::parse_str(attempt_id).ok(), - None => network.as_ref().map(|_| Uuid::new_v4()), - }; - let params = ExecParams { command, cwd, expiration, env, network: network.clone(), - network_attempt_id, sandbox_permissions, windows_sandbox_level, justification, @@ -356,14 +345,12 @@ async fn exec_windows_sandbox( cwd, mut env, network, - network_attempt_id, expiration, windows_sandbox_level, .. } = params; - let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); if let Some(network) = network.as_ref() { - network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); + network.apply_to_env(&mut env); } // TODO(iceweasel-oai): run_windows_sandbox_capture should support all @@ -717,16 +704,13 @@ async fn exec( cwd, mut env, network, - network_attempt_id, arg0, expiration, windows_sandbox_level: _, .. } = params; - let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); - if let Some(network) = network.as_ref() { - network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); + network.apply_to_env(&mut env); } let (program, args) = command.split_first().ok_or_else(|| { @@ -1137,7 +1121,6 @@ mod tests { expiration: 500.into(), env, network: None, - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, @@ -1191,7 +1174,6 @@ mod tests { expiration: ExecExpiration::Cancellation(cancel_token), env, network: None, - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 681b1674b6..3abe4e9233 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -24,13 +24,14 @@ use crate::features::Feature; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::SandboxState; +use crate::mcp_connection_manager::codex_apps_tools_cache_key; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com"; -const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/mcp/"; +const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp"; // Legacy vs new MCP gateway #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -208,6 +209,8 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent &config.permissions.approval_policy, tx_event, sandbox_state, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth.as_ref()), ) .await; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index c872a9732f..12e3d325d3 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -12,8 +12,9 @@ use std::env; use std::ffi::OsString; use std::path::PathBuf; use std::sync::Arc; -use std::sync::LazyLock; use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; @@ -72,10 +73,12 @@ use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use tracing::instrument; use tracing::warn; +use url::Url; use crate::codex::INITIAL_SUBMIT_ID; use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; +use crate::connectors::is_connector_id_allowed; /// Delimiter used to separate the server name from the tool name in a fully /// qualified tool name. @@ -89,9 +92,10 @@ const MAX_TOOL_NAME_LENGTH: usize = 64; pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); /// Default timeout for individual tool calls. -const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60); +const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120); -const CODEX_APPS_TOOLS_CACHE_TTL: Duration = Duration::from_secs(3600); +const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 1; +const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms"; @@ -123,6 +127,27 @@ fn sha1_hex(s: &str) -> String { format!("{sha1:x}") } +pub(crate) fn codex_apps_tools_cache_key( + auth: Option<&crate::CodexAuth>, +) -> CodexAppsToolsCacheKey { + let token_data = auth.and_then(|auth| auth.get_token_data().ok()); + let account_id = token_data + .as_ref() + .and_then(|token_data| token_data.account_id.clone()); + let chatgpt_user_id = token_data + .as_ref() + .and_then(|token_data| token_data.id_token.chatgpt_user_id.clone()); + let is_workspace_account = token_data + .as_ref() + .is_some_and(|token_data| token_data.id_token.is_workspace_account()); + + CodexAppsToolsCacheKey { + account_id, + chatgpt_user_id, + is_workspace_account, + } +} + fn qualify_tools(tools: I) -> HashMap where I: IntoIterator, @@ -165,7 +190,7 @@ where qualified_tools } -#[derive(Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ToolInfo { pub(crate) server_name: String, pub(crate) tool_name: String, @@ -174,14 +199,40 @@ pub(crate) struct ToolInfo { pub(crate) connector_name: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct CodexAppsToolsCacheKey { + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + #[derive(Clone)] -struct CachedCodexAppsTools { - expires_at: Instant, +struct CodexAppsToolsCacheContext { + codex_home: PathBuf, + user_key: CodexAppsToolsCacheKey, +} + +impl CodexAppsToolsCacheContext { + fn cache_path(&self) -> PathBuf { + let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); + let user_key_hash = sha1_hex(&user_key_json); + self.codex_home + .join(CODEX_APPS_TOOLS_CACHE_DIR) + .join(format!("{user_key_hash}.json")) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAppsToolsDiskCache { + schema_version: u8, tools: Vec, } -static CODEX_APPS_TOOLS_CACHE: LazyLock>> = - LazyLock::new(|| StdMutex::new(None)); +enum CachedCodexAppsToolsLoad { + Hit(Vec), + Missing, + Invalid, +} type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; @@ -289,9 +340,35 @@ struct ManagedClient { tool_filter: ToolFilter, tool_timeout: Option, server_supports_sandbox_state_capability: bool, + codex_apps_tools_cache_context: Option, } impl ManagedClient { + fn listed_tools(&self) -> Vec { + let total_start = Instant::now(); + if let Some(cache_context) = self.codex_apps_tools_cache_context.as_ref() + && let CachedCodexAppsToolsLoad::Hit(tools) = + load_cached_codex_apps_tools(cache_context) + { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "hit")], + ); + return filter_tools(tools, &self.tool_filter); + } + + if self.codex_apps_tools_cache_context.is_some() { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "miss")], + ); + } + + self.tools.clone() + } + /// Returns once the server has ack'd the sandbox state update. async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> { if !self.server_supports_sandbox_state_capability { @@ -312,6 +389,8 @@ impl ManagedClient { #[derive(Clone)] struct AsyncManagedClient { client: Shared>>, + startup_snapshot: Option>, + startup_complete: Arc, } impl AsyncManagedClient { @@ -322,33 +401,63 @@ impl AsyncManagedClient { cancel_token: CancellationToken, tx_event: Sender, elicitation_requests: ElicitationRequestManager, + codex_apps_tools_cache_context: Option, ) -> Self { let tool_filter = ToolFilter::from_config(&config); + let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ) + .map(|tools| filter_tools(tools, &tool_filter)); + let startup_tool_filter = tool_filter; + let startup_complete = Arc::new(AtomicBool::new(false)); + let startup_complete_for_fut = Arc::clone(&startup_complete); let fut = async move { - if let Err(error) = validate_mcp_server_name(&server_name) { - return Err(error.into()); - } + let outcome = async { + if let Err(error) = validate_mcp_server_name(&server_name) { + return Err(error.into()); + } - let client = - Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?); - match start_server_task( - server_name, - client, - config.startup_timeout_sec.or(Some(DEFAULT_STARTUP_TIMEOUT)), - config.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT), - tool_filter, - tx_event, - elicitation_requests, - ) - .or_cancel(&cancel_token) - .await - { - Ok(result) => result, - Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), + let client = + Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?); + match start_server_task( + server_name, + client, + StartServerTaskParams { + startup_timeout: config + .startup_timeout_sec + .or(Some(DEFAULT_STARTUP_TIMEOUT)), + tool_timeout: config.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT), + tool_filter: startup_tool_filter, + tx_event, + elicitation_requests, + codex_apps_tools_cache_context, + }, + ) + .or_cancel(&cancel_token) + .await + { + Ok(result) => result, + Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), + } } + .await; + + startup_complete_for_fut.store(true, Ordering::Release); + outcome }; + let client = fut.boxed().shared(); + if startup_snapshot.is_some() { + let startup_task = client.clone(); + tokio::spawn(async move { + let _ = startup_task.await; + }); + } + Self { - client: fut.boxed().shared(), + client, + startup_snapshot, + startup_complete, } } @@ -356,6 +465,24 @@ impl AsyncManagedClient { self.client.clone().await } + fn startup_snapshot_while_initializing(&self) -> Option> { + if !self.startup_complete.load(Ordering::Acquire) { + return self.startup_snapshot.clone(); + } + None + } + + async fn listed_tools(&self) -> Option> { + if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + return Some(startup_tools); + } + + match self.client().await { + Ok(client) => Some(client.listed_tools()), + Err(_) => self.startup_snapshot.clone(), + } + } + async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> { let managed = self.client().await?; managed.notify_sandbox_state_change(sandbox_state).await @@ -381,6 +508,7 @@ pub struct SandboxState { /// A thin wrapper around a set of running [`RmcpClient`] instances. pub(crate) struct McpConnectionManager { clients: HashMap, + server_origins: HashMap, elicitation_requests: ElicitationRequestManager, } @@ -388,6 +516,7 @@ impl McpConnectionManager { pub(crate) fn new_uninitialized(approval_policy: &Constrained) -> Self { Self { clients: HashMap::new(), + server_origins: HashMap::new(), elicitation_requests: ElicitationRequestManager::new(approval_policy.value()), } } @@ -403,6 +532,10 @@ impl McpConnectionManager { !self.clients.is_empty() } + pub(crate) fn server_origin(&self, server_name: &str) -> Option<&str> { + self.server_origins.get(server_name).map(String::as_str) + } + pub fn set_approval_policy(&self, approval_policy: &Constrained) { if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { *policy = approval_policy.value(); @@ -417,13 +550,19 @@ impl McpConnectionManager { approval_policy: &Constrained, tx_event: Sender, initial_sandbox_state: SandboxState, + codex_home: PathBuf, + codex_apps_tools_cache_key: CodexAppsToolsCacheKey, ) -> (Self, CancellationToken) { let cancel_token = CancellationToken::new(); let mut clients = HashMap::new(); + let mut server_origins = HashMap::new(); let mut join_set = JoinSet::new(); let elicitation_requests = ElicitationRequestManager::new(approval_policy.value()); let mcp_servers = mcp_servers.clone(); for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { + if let Some(origin) = transport_origin(&cfg.transport) { + server_origins.insert(server_name.clone(), origin); + } let cancel_token = cancel_token.child_token(); let _ = emit_update( &tx_event, @@ -433,6 +572,14 @@ impl McpConnectionManager { }, ) .await; + let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { + Some(CodexAppsToolsCacheContext { + codex_home: codex_home.clone(), + user_key: codex_apps_tools_cache_key.clone(), + }) + } else { + None + }; let async_managed_client = AsyncManagedClient::new( server_name.clone(), cfg, @@ -440,6 +587,7 @@ impl McpConnectionManager { cancel_token.clone(), tx_event.clone(), elicitation_requests.clone(), + codex_apps_tools_cache_context, ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); @@ -487,6 +635,7 @@ impl McpConnectionManager { } let manager = Self { clients, + server_origins, elicitation_requests: elicitation_requests.clone(), }; tokio::spawn(async move { @@ -575,29 +724,11 @@ impl McpConnectionManager { #[instrument(level = "trace", skip_all)] pub async fn list_all_tools(&self) -> HashMap { let mut tools = HashMap::new(); - for (server_name, managed_client) in &self.clients { - let client = managed_client.client().await.ok(); - if let Some(client) = client { - let rmcp_client = client.client; - let tool_timeout = client.tool_timeout; - let tool_filter = client.tool_filter; - let mut server_tools = client.tools; - - if server_name == CODEX_APPS_MCP_SERVER_NAME { - match list_tools_for_client(server_name, &rmcp_client, tool_timeout).await { - Ok(fresh_or_cached_tools) => { - server_tools = fresh_or_cached_tools; - } - Err(err) => { - warn!( - "Failed to refresh tools for MCP server '{server_name}', using startup snapshot: {err:#}" - ); - } - } - } - - tools.extend(qualify_tools(filter_tools(server_tools, tool_filter))); - } + for managed_client in self.clients.values() { + let Some(server_tools) = managed_client.listed_tools().await else { + continue; + }; + tools.extend(qualify_tools(server_tools)); } tools } @@ -615,6 +746,8 @@ impl McpConnectionManager { .await .context("failed to get client")?; + let list_start = Instant::now(); + let fetch_start = Instant::now(); let tools = list_tools_for_client_uncached( CODEX_APPS_MCP_SERVER_NAME, &managed_client.client, @@ -624,8 +757,22 @@ impl McpConnectionManager { .with_context(|| { format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'") })?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); - write_cached_codex_apps_tools(&tools); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + managed_client.codex_apps_tools_cache_context.as_ref(), + &tools, + ); + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + list_start.elapsed(), + &[("cache", "miss")], + ); Ok(()) } @@ -934,7 +1081,7 @@ impl ToolFilter { } } -fn filter_tools(tools: Vec, filter: ToolFilter) -> Vec { +fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { tools .into_iter() .filter(|tool| filter.allows(&tool.tool_name)) @@ -1077,14 +1224,17 @@ fn elicitation_capability_for_server(server_name: &str) -> Option, - startup_timeout: Option, // TODO: cancel_token should handle this. - tool_timeout: Duration, - tool_filter: ToolFilter, - tx_event: Sender, - elicitation_requests: ElicitationRequestManager, + params: StartServerTaskParams, ) -> Result { + let StartServerTaskParams { + startup_timeout, + tool_timeout, + tool_filter, + tx_event, + elicitation_requests, + codex_apps_tools_cache_context, + } = params; let elicitation = elicitation_capability_for_server(&server_name); - let params = InitializeRequestParams { meta: None, capabilities: ClientCapabilities { @@ -1113,9 +1263,29 @@ async fn start_server_task( .await .map_err(StartupOutcomeError::from)?; - let tools = list_tools_for_client(&server_name, &client, startup_timeout) + let list_start = Instant::now(); + let fetch_start = Instant::now(); + let tools = list_tools_for_client_uncached(&server_name, &client, startup_timeout) .await .map_err(StartupOutcomeError::from)?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + write_cached_codex_apps_tools_if_needed( + &server_name, + codex_apps_tools_cache_context.as_ref(), + &tools, + ); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + list_start.elapsed(), + &[("cache", "miss")], + ); + } + let tools = filter_tools(tools, &tool_filter); let server_supports_sandbox_state_capability = initialize_result .capabilities @@ -1130,11 +1300,21 @@ async fn start_server_task( tool_timeout: Some(tool_timeout), tool_filter, server_supports_sandbox_state_capability, + codex_apps_tools_cache_context, }; Ok(managed) } +struct StartServerTaskParams { + startup_timeout: Option, // TODO: cancel_token should handle this. + tool_timeout: Duration, + tool_filter: ToolFilter, + tx_event: Sender, + elicitation_requests: ElicitationRequestManager, + codex_apps_tools_cache_context: Option, +} + async fn make_rmcp_client( server_name: &str, transport: McpServerTransportConfig, @@ -1179,80 +1359,99 @@ async fn make_rmcp_client( } } -async fn list_tools_for_client( +fn write_cached_codex_apps_tools_if_needed( server_name: &str, - client: &Arc, - timeout: Option, -) -> Result> { - let total_start = Instant::now(); - if server_name == CODEX_APPS_MCP_SERVER_NAME - && let Some(cached_tools) = read_cached_codex_apps_tools() - { - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - total_start.elapsed(), - &[("cache", "hit")], - ); - return Ok(cached_tools); + cache_context: Option<&CodexAppsToolsCacheContext>, + tools: &[ToolInfo], +) { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return; } - let fetch_start = Instant::now(); - let tools = list_tools_for_client_uncached(server_name, client, timeout).await?; - emit_duration( - MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, - fetch_start.elapsed(), - &[], - ); - - if server_name == CODEX_APPS_MCP_SERVER_NAME { + if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); - write_cached_codex_apps_tools(&tools); + write_cached_codex_apps_tools(cache_context, tools); emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), &[], ); } - - if server_name == CODEX_APPS_MCP_SERVER_NAME { - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - total_start.elapsed(), - &[("cache", "miss")], - ); - } - Ok(tools) } -fn read_cached_codex_apps_tools() -> Option> { - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let now = Instant::now(); - - if let Some(cached) = cache_guard.as_ref() - && now < cached.expires_at - { - return Some(cached.tools.clone()); +fn load_startup_cached_codex_apps_tools_snapshot( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, +) -> Option> { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; } - if cache_guard - .as_ref() - .is_some_and(|cached| now >= cached.expires_at) - { - *cache_guard = None; + let cache_context = cache_context?; + + match load_cached_codex_apps_tools(cache_context) { + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, } - None } -fn write_cached_codex_apps_tools(tools: &[ToolInfo]) { - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *cache_guard = Some(CachedCodexAppsTools { - expires_at: Instant::now() + CODEX_APPS_TOOLS_CACHE_TTL, - tools: tools.to_vec(), - }); +#[cfg(test)] +fn read_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, +) -> Option> { + match load_cached_codex_apps_tools(cache_context) { + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, + } +} + +fn load_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, +) -> CachedCodexAppsToolsLoad { + let cache_path = cache_context.cache_path(); + let bytes = match std::fs::read(cache_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return CachedCodexAppsToolsLoad::Missing; + } + Err(_) => return CachedCodexAppsToolsLoad::Invalid, + }; + let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) { + Ok(cache) => cache, + Err(_) => return CachedCodexAppsToolsLoad::Invalid, + }; + if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { + return CachedCodexAppsToolsLoad::Invalid; + } + CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) +} + +fn write_cached_codex_apps_tools(cache_context: &CodexAppsToolsCacheContext, tools: &[ToolInfo]) { + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() + && std::fs::create_dir_all(parent).is_err() + { + return; + } + let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); + let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { + schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + tools, + }) else { + return; + }; + let _ = std::fs::write(cache_path, bytes); +} + +fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { + tools + .into_iter() + .filter(|tool| { + tool.connector_id + .as_deref() + .is_none_or(is_connector_id_allowed) + }) + .collect() } fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { @@ -1261,13 +1460,23 @@ fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { } } +fn transport_origin(transport: &McpServerTransportConfig) -> Option { + match transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + let parsed = Url::parse(url).ok()?; + Some(parsed.origin().ascii_serialization()) + } + McpServerTransportConfig::Stdio { .. } => Some("stdio".to_string()), + } +} + async fn list_tools_for_client_uncached( server_name: &str, client: &Arc, timeout: Option, ) -> Result> { let resp = client.list_tools_with_connector_ids(None, timeout).await?; - Ok(resp + let tools = resp .tools .into_iter() .map(|tool| { @@ -1288,7 +1497,11 @@ async fn list_tools_for_client_uncached( connector_name, } }) - .collect()) + .collect(); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + return Ok(filter_disallowed_codex_apps_tools(tools)); + } + Ok(tools) } fn validate_mcp_server_name(server_name: &str) -> Result<()> { @@ -1376,6 +1589,7 @@ mod tests { use rmcp::model::JsonObject; use std::collections::HashSet; use std::sync::Arc; + use tempfile::tempdir; fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { ToolInfo { @@ -1397,19 +1611,31 @@ mod tests { } } - fn with_clean_codex_apps_tools_cache(f: impl FnOnce() -> T) -> T { - let previous_cache = { - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - cache_guard.take() - }; - let result = f(); - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *cache_guard = previous_cache; - result + fn create_test_tool_with_connector( + server_name: &str, + tool_name: &str, + connector_id: &str, + connector_name: Option<&str>, + ) -> ToolInfo { + let mut tool = create_test_tool(server_name, tool_name); + tool.connector_id = Some(connector_id.to_string()); + tool.connector_name = connector_name.map(ToOwned::to_owned); + tool + } + + fn create_codex_apps_tools_cache_context( + codex_home: PathBuf, + account_id: Option<&str>, + chatgpt_user_id: Option<&str>, + ) -> CodexAppsToolsCacheContext { + CodexAppsToolsCacheContext { + codex_home, + user_key: CodexAppsToolsCacheKey { + account_id: account_id.map(ToOwned::to_owned), + chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), + is_workspace_account: false, + }, + } } #[test] @@ -1586,9 +1812,9 @@ mod tests { disabled: HashSet::from(["tool_a".to_string()]), }; - let filtered: Vec<_> = filter_tools(server1_tools, server1_filter) + let filtered: Vec<_> = filter_tools(server1_tools, &server1_filter) .into_iter() - .chain(filter_tools(server2_tools, server2_filter)) + .chain(filter_tools(server2_tools, &server2_filter)) .collect(); assert_eq!(filtered.len(), 1); @@ -1598,43 +1824,260 @@ mod tests { #[test] fn codex_apps_tools_cache_is_overwritten_by_last_write() { - with_clean_codex_apps_tools_cache(|| { - let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; - let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; + let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; - write_cached_codex_apps_tools(&tools_gateway_1); - let cached_gateway_1 = - read_cached_codex_apps_tools().expect("cache entry exists for first write"); - assert_eq!(cached_gateway_1[0].tool_name, "one"); + write_cached_codex_apps_tools(&cache_context, &tools_gateway_1); + let cached_gateway_1 = read_cached_codex_apps_tools(&cache_context) + .expect("cache entry exists for first write"); + assert_eq!(cached_gateway_1[0].tool_name, "one"); - write_cached_codex_apps_tools(&tools_gateway_2); - let cached_gateway_2 = - read_cached_codex_apps_tools().expect("cache entry exists for second write"); - assert_eq!(cached_gateway_2[0].tool_name, "two"); - }); + write_cached_codex_apps_tools(&cache_context, &tools_gateway_2); + let cached_gateway_2 = read_cached_codex_apps_tools(&cache_context) + .expect("cache entry exists for second write"); + assert_eq!(cached_gateway_2[0].tool_name, "two"); } #[test] - fn codex_apps_tools_cache_is_cleared_when_expired() { - with_clean_codex_apps_tools_cache(|| { - let tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "stale_tool")]; - write_cached_codex_apps_tools(&tools); + fn codex_apps_tools_cache_is_scoped_per_user() { + let codex_home = tempdir().expect("tempdir"); + let cache_context_user_1 = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_context_user_2 = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-two"), + Some("user-two"), + ); + let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; + let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; - { - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - cache_guard.as_mut().expect("cache exists").expires_at = - Instant::now() - Duration::from_secs(1); - } + write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1); + write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2); - assert!(read_cached_codex_apps_tools().is_none()); + let read_user_1 = + read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one"); + let read_user_2 = + read_cached_codex_apps_tools(&cache_context_user_2).expect("cache entry for user two"); - let cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!(cache_guard.is_none()); - }); + assert_eq!(read_user_1[0].tool_name, "one"); + assert_eq!(read_user_2[0].tool_name, "two"); + assert_ne!( + cache_context_user_1.cache_path(), + cache_context_user_2.cache_path(), + "each user should get an isolated cache file" + ); + } + + #[test] + fn codex_apps_tools_cache_filters_disallowed_connectors() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let tools = vec![ + create_test_tool_with_connector( + CODEX_APPS_MCP_SERVER_NAME, + "blocked_tool", + "connector_openai_hidden", + Some("Hidden"), + ), + create_test_tool_with_connector( + CODEX_APPS_MCP_SERVER_NAME, + "allowed_tool", + "calendar", + Some("Calendar"), + ), + ]; + + write_cached_codex_apps_tools(&cache_context, &tools); + let cached = + read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); + + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].tool_name, "allowed_tool"); + assert_eq!(cached[0].connector_id.as_deref(), Some("calendar")); + } + + #[test] + fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION + 1, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write"); + + assert!(read_cached_codex_apps_tools(&cache_context).is_none()); + } + + #[test] + fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(cache_path, b"{not json").expect("write"); + + assert!(read_cached_codex_apps_tools(&cache_context).is_none()); + } + + #[test] + fn startup_cached_codex_apps_tools_loads_from_disk_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cached_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_search", + )]; + write_cached_codex_apps_tools(&cache_context, &cached_tools); + + let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ); + let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); + + assert_eq!(startup_tools.len(), 1); + assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(startup_tools[0].tool_name, "calendar_search"); + } + + #[tokio::test] + async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { + let startup_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + )]; + let pending_client = + futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(startup_tools), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .get("mcp__codex_apps__calendar_create_event") + .expect("tool from startup cache"); + assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(tool.tool_name, "calendar_create_event"); + } + + #[tokio::test] + async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { + let pending_client = + futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }, + ); + + let timeout_result = + tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; + assert!(timeout_result.is_err()); + } + + #[tokio::test] + async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { + let pending_client = + futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(Vec::new()), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }, + ); + + let timeout_result = + tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; + let tools = timeout_result.expect("cache-hit startup snapshot should not block"); + assert!(tools.is_empty()); + } + + #[tokio::test] + async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { + let startup_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + )]; + let failed_client = futures::future::ready::>( + Err(StartupOutcomeError::Failed { + error: "startup failed".to_string(), + }), + ) + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true)); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: failed_client, + startup_snapshot: Some(startup_tools), + startup_complete, + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .get("mcp__codex_apps__calendar_create_event") + .expect("tool from startup cache"); + assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(tool.tool_name, "calendar_create_event"); } #[test] @@ -1743,4 +2186,32 @@ mod tests { display ); } + + #[test] + fn transport_origin_extracts_http_origin() { + let transport = McpServerTransportConfig::StreamableHttp { + url: "https://example.com:8443/path?query=1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }; + + assert_eq!( + transport_origin(&transport), + Some("https://example.com:8443".to_string()) + ); + } + + #[test] + fn transport_origin_is_stdio_for_stdio_transport() { + let transport = McpServerTransportConfig::Stdio { + command: "server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }; + + assert_eq!(transport_origin(&transport), Some("stdio".to_string())); + } } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 5f7734118c..f194ee79a6 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -7,6 +7,8 @@ use crate::analytics_client::AppInvocation; use crate::analytics_client::build_track_events_context; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config::types::AppToolApproval; +use crate::connectors; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; @@ -64,9 +66,51 @@ pub(crate) async fn handle_mcp_tool_call( arguments: arguments_value.clone(), }; - if let Some(decision) = - maybe_request_mcp_tool_approval(sess.as_ref(), turn_context, &call_id, &server, &tool_name) - .await + let metadata = lookup_mcp_tool_metadata(sess.as_ref(), &server, &tool_name).await; + let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME { + connectors::app_tool_policy( + &turn_context.config, + metadata + .as_ref() + .and_then(|metadata| metadata.connector_id.as_deref()), + &tool_name, + metadata + .as_ref() + .and_then(|metadata| metadata.tool_title.as_deref()), + metadata + .as_ref() + .and_then(|metadata| metadata.annotations.as_ref()), + ) + } else { + connectors::AppToolPolicy::default() + }; + + if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled { + let result = notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context, + &call_id, + invocation, + "MCP tool call blocked by app configuration".to_string(), + ) + .await; + let status = if result.is_ok() { "ok" } else { "error" }; + turn_context + .otel_manager + .counter("codex.mcp.call", 1, &[("status", status)]); + return ResponseInputItem::McpToolCallOutput { call_id, result }; + } + + if let Some(decision) = maybe_request_mcp_tool_approval( + sess.as_ref(), + turn_context, + &call_id, + &server, + &tool_name, + metadata.as_ref(), + app_tool_policy.approval, + ) + .await { let result = match decision { McpToolApprovalDecision::Accept | McpToolApprovalDecision::AcceptAndRemember => { @@ -267,7 +311,7 @@ enum McpToolApprovalDecision { } struct McpToolApprovalMetadata { - annotations: ToolAnnotations, + annotations: Option, connector_id: Option, connector_name: Option, tool_title: Option, @@ -292,21 +336,39 @@ async fn maybe_request_mcp_tool_approval( call_id: &str, server: &str, tool_name: &str, + metadata: Option<&McpToolApprovalMetadata>, + approval_mode: AppToolApproval, ) -> Option { - if is_full_access_mode(turn_context) { + if approval_mode == AppToolApproval::Approve { return None; } + let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); + if approval_mode == AppToolApproval::Auto { + if is_full_access_mode(turn_context) { + return None; + } + if !annotations.is_some_and(requires_mcp_tool_approval) { + return None; + } + } - let metadata = lookup_mcp_tool_metadata(sess, server, tool_name).await?; - if !requires_mcp_tool_approval(&metadata.annotations) { - return None; - } - let approval_key = McpToolApprovalKey { - server: server.to_string(), - connector_id: metadata.connector_id.clone(), - tool_name: tool_name.to_string(), + let approval_key = if approval_mode == AppToolApproval::Auto { + let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone()); + if server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() { + None + } else { + Some(McpToolApprovalKey { + server: server.to_string(), + connector_id, + tool_name: tool_name.to_string(), + }) + } + } else { + None }; - if mcp_tool_approval_is_remembered(sess, &approval_key).await { + if let Some(key) = approval_key.as_ref() + && mcp_tool_approval_is_remembered(sess, key).await + { return Some(McpToolApprovalDecision::Accept); } @@ -315,10 +377,10 @@ async fn maybe_request_mcp_tool_approval( question_id.clone(), server, tool_name, - metadata.tool_title.as_deref(), - metadata.connector_name.as_deref(), - &metadata.annotations, - true, + metadata.and_then(|metadata| metadata.tool_title.as_deref()), + metadata.and_then(|metadata| metadata.connector_name.as_deref()), + annotations, + approval_key.is_some(), ); let args = RequestUserInputArgs { questions: vec![question], @@ -326,9 +388,14 @@ async fn maybe_request_mcp_tool_approval( let response = sess .request_user_input(turn_context, call_id.to_string(), args) .await; - let decision = parse_mcp_tool_approval_response(response, &question_id); - if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) { - remember_mcp_tool_approval(sess, approval_key).await; + let decision = normalize_approval_decision_for_mode( + parse_mcp_tool_approval_response(response, &question_id), + approval_mode, + ); + if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) + && let Some(key) = approval_key + { + remember_mcp_tool_approval(sess, key).await; } Some(decision) } @@ -356,15 +423,12 @@ async fn lookup_mcp_tool_metadata( tools.into_values().find_map(|tool_info| { if tool_info.server_name == server && tool_info.tool_name == tool_name { - tool_info - .tool - .annotations - .map(|annotations| McpToolApprovalMetadata { - annotations, - connector_id: tool_info.connector_id, - connector_name: tool_info.connector_name, - tool_title: tool_info.tool.title, - }) + Some(McpToolApprovalMetadata { + annotations: tool_info.tool.annotations, + connector_id: tool_info.connector_id, + connector_name: tool_info.connector_name, + tool_title: tool_info.tool.title, + }) } else { None } @@ -402,11 +466,12 @@ fn build_mcp_tool_approval_question( tool_name: &str, tool_title: Option<&str>, connector_name: Option<&str>, - annotations: &ToolAnnotations, + annotations: Option<&ToolAnnotations>, allow_remember_option: bool, ) -> RequestUserInputQuestion { - let destructive = annotations.destructive_hint == Some(true); - let open_world = annotations.open_world_hint == Some(true); + let destructive = + annotations.and_then(|annotations| annotations.destructive_hint) == Some(true); + let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true); let reason = match (destructive, open_world) { (true, true) => "may modify data and access external systems", (true, false) => "may modify or delete data", @@ -493,6 +558,19 @@ fn parse_mcp_tool_approval_response( } } +fn normalize_approval_decision_for_mode( + decision: McpToolApprovalDecision, + approval_mode: AppToolApproval, +) -> McpToolApprovalDecision { + if approval_mode == AppToolApproval::Prompt + && decision == McpToolApprovalDecision::AcceptAndRemember + { + McpToolApprovalDecision::Accept + } else { + decision + } +} + async fn mcp_tool_approval_is_remembered(sess: &Session, key: &McpToolApprovalKey) -> bool { let store = sess.services.tool_approvals.lock().await; matches!(store.get(key), Some(ReviewDecision::ApprovedForSession)) @@ -504,8 +582,11 @@ async fn remember_mcp_tool_approval(sess: &Session, key: McpToolApprovalKey) { } fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { - annotations.read_only_hint == Some(false) - && (annotations.destructive_hint == Some(true) || annotations.open_world_hint == Some(true)) + if annotations.destructive_hint == Some(true) { + return true; + } + + annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true) } async fn notify_mcp_tool_call_skip( @@ -563,9 +644,20 @@ mod tests { } #[test] - fn approval_not_required_when_read_only_true() { + fn approval_required_when_destructive_even_if_read_only_true() { let annotations = annotations(Some(true), Some(true), Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), false); + assert_eq!(requires_mcp_tool_approval(&annotations), true); + } + + #[test] + fn prompt_mode_does_not_allow_session_remember() { + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptAndRemember, + AppToolApproval::Prompt, + ), + McpToolApprovalDecision::Accept + ); } #[test] @@ -576,7 +668,7 @@ mod tests { "run_action", Some("Run Action"), None, - &annotations(Some(false), Some(true), None), + Some(&annotations(Some(false), Some(true), None)), true, ); @@ -603,7 +695,7 @@ mod tests { "run_action", Some("Run Action"), None, - &annotations(Some(false), Some(true), None), + Some(&annotations(Some(false), Some(true), None)), true, ); diff --git a/codex-rs/core/src/memories/mod.rs b/codex-rs/core/src/memories/mod.rs index cab19915df..1f892646ce 100644 --- a/codex-rs/core/src/memories/mod.rs +++ b/codex-rs/core/src/memories/mod.rs @@ -13,6 +13,8 @@ mod storage; mod tests; pub(crate) mod usage; +use codex_protocol::openai_models::ReasoningEffort; + /// Starts the memory startup pipeline for eligible root sessions. /// This is the single entrypoint that `codex` uses to trigger memory startup. /// @@ -28,6 +30,8 @@ mod artifacts { mod phase_one { /// Default model used for phase 1. pub(super) const MODEL: &str = "gpt-5.1-codex-mini"; + /// Default reasoning effort used for phase 1. + pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Low; /// Prompt used for phase 1. pub(super) const PROMPT: &str = include_str!("../../templates/memories/stage_one_system.md"); /// Concurrency cap for startup memory extraction and consolidation scheduling. @@ -56,6 +60,8 @@ mod phase_one { mod phase_two { /// Default model used for phase 2. pub(super) const MODEL: &str = "gpt-5.3-codex"; + /// Default reasoning effort used for phase 2. + pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Medium; /// Lease duration (seconds) for phase-2 consolidation job ownership. pub(super) const JOB_LEASE_SECONDS: i64 = 3_600; /// Backoff delay (seconds) before retrying a failed phase-2 consolidation diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index 36be849c14..d2fb6d11f4 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -20,7 +20,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::TokenUsage; -use codex_utils_sanitizer::redact_secrets; +use codex_secrets::redact_secrets; use futures::StreamExt; use serde::Deserialize; use serde_json::Value; @@ -142,7 +142,7 @@ impl RequestContext { model_info, turn_metadata_header, otel_manager: turn_context.otel_manager.clone(), - reasoning_effort: turn_context.reasoning_effort, + reasoning_effort: Some(phase_one::REASONING_EFFORT), reasoning_summary: turn_context.reasoning_summary, } } diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index aa17cc1585..6794a4574f 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -2,6 +2,7 @@ use crate::agent::AgentStatus; use crate::agent::status::is_final as is_final_agent_status; use crate::codex::Session; use crate::config::Config; +use crate::features::Feature; use crate::memories::memory_root; use crate::memories::metrics; use crate::memories::phase_two; @@ -227,6 +228,8 @@ mod agent { agent_config.cwd = root; // Approval policy agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); + // Consolidation runs as an internal sub-agent and must not recursively delegate. + agent_config.features.disable(Feature::Collab); // Sandbox policy let mut writable_roots = Vec::new(); @@ -258,6 +261,7 @@ mod agent { .clone() .unwrap_or(phase_two::MODEL.to_string()), ); + agent_config.model_reasoning_effort = Some(phase_two::REASONING_EFFORT); Some(agent_config) } diff --git a/codex-rs/core/src/memories/storage.rs b/codex-rs/core/src/memories/storage.rs index ea2b00acf5..1a07dbe9b8 100644 --- a/codex-rs/core/src/memories/storage.rs +++ b/codex-rs/core/src/memories/storage.rs @@ -82,11 +82,11 @@ async fn rebuild_raw_memories_file( ) .map_err(raw_memories_format_error)?; writeln!(body, "cwd: {}", memory.cwd.display()).map_err(raw_memories_format_error)?; - writeln!(body).map_err(raw_memories_format_error)?; let rollout_summary_file = format!("{}.md", rollout_summary_file_stem(memory)); - let raw_memory = - replace_rollout_summary_file_in_raw_memory(&memory.raw_memory, &rollout_summary_file); - body.push_str(raw_memory.trim()); + writeln!(body, "rollout_summary_file: {rollout_summary_file}") + .map_err(raw_memories_format_error)?; + writeln!(body).map_err(raw_memories_format_error)?; + body.push_str(memory.raw_memory.trim()); body.push_str("\n\n"); } @@ -161,26 +161,6 @@ fn rollout_summary_format_error(err: std::fmt::Error) -> std::io::Error { std::io::Error::other(format!("format rollout summary: {err}")) } -fn replace_rollout_summary_file_in_raw_memory( - raw_memory: &str, - rollout_summary_file: &str, -) -> String { - const ROLLOUT_SUMMARY_PREFIX: &str = "rollout_summary_file: "; - - let replacement = format!("rollout_summary_file: {rollout_summary_file}"); - raw_memory - .split('\n') - .map(|line| { - if line.starts_with(ROLLOUT_SUMMARY_PREFIX) { - replacement.as_str() - } else { - line - } - }) - .collect::>() - .join("\n") -} - pub(crate) fn rollout_summary_file_stem(memory: &Stage1Output) -> String { rollout_summary_file_stem_from_parts( memory.thread_id, @@ -270,7 +250,6 @@ pub(super) fn rollout_summary_file_stem_from_parts( #[cfg(test)] mod tests { - use super::replace_rollout_summary_file_in_raw_memory; use super::rollout_summary_file_stem; use super::rollout_summary_file_stem_from_parts; use chrono::TimeZone; @@ -339,71 +318,4 @@ mod tests { assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX); } - - #[test] - fn replace_rollout_summary_file_in_raw_memory_replaces_existing_value() { - let raw_memory = "\ ---- -rollout_summary_file: wrong.md -description: demo -keywords: one, two ---- -- body line"; - let normalized = replace_rollout_summary_file_in_raw_memory( - raw_memory, - "2025-01-01T00-00-00-abcd-demo.md", - ); - - assert_eq!( - normalized, - "\ ---- -rollout_summary_file: 2025-01-01T00-00-00-abcd-demo.md -description: demo -keywords: one, two ---- -- body line" - ); - } - - #[test] - fn replace_rollout_summary_file_in_raw_memory_replaces_placeholder() { - let raw_memory = "\ ---- -rollout_summary_file: -description: demo -keywords: one, two ---- -- body line"; - let normalized = replace_rollout_summary_file_in_raw_memory( - raw_memory, - "2025-01-01T00-00-00-abcd-demo.md", - ); - - assert_eq!( - normalized, - "\ ---- -rollout_summary_file: 2025-01-01T00-00-00-abcd-demo.md -description: demo -keywords: one, two ---- -- body line" - ); - } - - #[test] - fn replace_rollout_summary_file_in_raw_memory_leaves_text_without_field_unchanged() { - let raw_memory = "\ ---- -description: demo -keywords: one, two ---- -- body line"; - let normalized = replace_rollout_summary_file_in_raw_memory( - raw_memory, - "2025-01-01T00-00-00-abcd-demo.md", - ); - assert_eq!(normalized, raw_memory); - } } diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index 7d86f75c9d..b47e7f8aab 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -127,6 +127,7 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only } files.sort_unstable(); assert_eq!(files.len(), 1); + let canonical_rollout_summary_file = &files[0]; let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root)) .await @@ -134,6 +135,30 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only assert!(raw_memories.contains("raw memory")); assert!(raw_memories.contains(&keep_id)); assert!(raw_memories.contains("cwd: /tmp/workspace")); + assert!(raw_memories.contains(&format!( + "rollout_summary_file: {canonical_rollout_summary_file}" + ))); + let thread_header = format!("## Thread `{keep_id}`"); + let thread_pos = raw_memories + .find(&thread_header) + .expect("thread header should exist"); + let updated_pos = raw_memories[thread_pos..] + .find("updated_at: ") + .map(|offset| thread_pos + offset) + .expect("updated_at should exist after thread header"); + let cwd_pos = raw_memories[thread_pos..] + .find("cwd: /tmp/workspace") + .map(|offset| thread_pos + offset) + .expect("cwd should exist after thread header"); + let file_pos = raw_memories[thread_pos..] + .find(&format!( + "rollout_summary_file: {canonical_rollout_summary_file}" + )) + .map(|offset| thread_pos + offset) + .expect("rollout_summary_file should exist after thread header"); + assert!(thread_pos < updated_pos); + assert!(updated_pos < cwd_pos); + assert!(cwd_pos < file_pos); } #[tokio::test] @@ -229,7 +254,7 @@ async fn sync_rollout_summaries_uses_timestamp_hash_and_sanitized_slug_filename( } #[tokio::test] -async fn rebuild_raw_memories_file_rewrites_rollout_summary_file_to_canonical_filename() { +async fn rebuild_raw_memories_file_adds_canonical_rollout_summary_file_header() { let dir = tempdir().expect("tempdir"); let root = dir.path().join("memory"); ensure_layout(&root).await.expect("ensure layout"); @@ -241,11 +266,20 @@ async fn rebuild_raw_memories_file_rewrites_rollout_summary_file_to_canonical_fi source_updated_at: Utc.timestamp_opt(200, 0).single().expect("timestamp"), raw_memory: "\ --- -rollout_summary_file: state_migration_uniqueness_test.md description: Added a migration test keywords: codex-state, migrations --- -- Kept details." +### Task 1: migration-test +task: add-migration-test +task_group: codex-state +task_outcome: success +- Added regression coverage for migration uniqueness. + +### Task 2: validate-migration +task: validate-migration-ordering +task_group: codex-state +task_outcome: success +- Confirmed no ordering regressions." .to_string(), rollout_summary: "short summary".to_string(), rollout_slug: Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345".to_string()), @@ -285,8 +319,11 @@ keywords: codex-state, migrations assert!(raw_memories.contains(&format!( "rollout_summary_file: {canonical_rollout_summary_file}" ))); - assert!(!raw_memories.contains("rollout_summary_file: state_migration_uniqueness_test.md")); assert!(raw_memories.contains("description: Added a migration test")); + assert!(raw_memories.contains("### Task 1: migration-test")); + assert!(raw_memories.contains("task: add-migration-test")); + assert!(raw_memories.contains("task_group: codex-state")); + assert!(raw_memories.contains("task_outcome: success")); } mod phase2 { diff --git a/codex-rs/core/src/memories/usage.rs b/codex-rs/core/src/memories/usage.rs index dbdcd25735..ff82d6e164 100644 --- a/codex-rs/core/src/memories/usage.rs +++ b/codex-rs/core/src/memories/usage.rs @@ -84,22 +84,28 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) .ok() .map(|params| { + if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) { + return (Vec::new(), invocation.turn.resolve_path(params.workdir)); + } + let use_login_shell = params + .login + .unwrap_or(invocation.turn.tools_config.allow_login_shell); let command = invocation .session .user_shell() - .derive_exec_args(¶ms.command, params.login.unwrap_or(true)); + .derive_exec_args(¶ms.command, use_login_shell); (command, invocation.turn.resolve_path(params.workdir)) }), "exec_command" => serde_json::from_str::(arguments) .ok() - .map(|params| { - ( - crate::tools::handlers::unified_exec::get_command( - ¶ms, - invocation.session.user_shell(), - ), - invocation.turn.resolve_path(params.workdir), + .and_then(|params| { + let command = crate::tools::handlers::unified_exec::get_command( + ¶ms, + invocation.session.user_shell(), + invocation.turn.tools_config.allow_login_shell, ) + .ok()?; + Some((command, invocation.turn.resolve_path(params.workdir))) }), _ => None, } diff --git a/codex-rs/core/src/network_policy_decision.rs b/codex-rs/core/src/network_policy_decision.rs index 3da6d34d6c..81ba7db82d 100644 --- a/codex-rs/core/src/network_policy_decision.rs +++ b/codex-rs/core/src/network_policy_decision.rs @@ -220,7 +220,6 @@ mod tests { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -238,7 +237,6 @@ mod tests { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), decision: Some("deny".to_string()), source: Some("baseline_policy".to_string()), port: Some(80), diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 6fcf91f086..1af36b2acb 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -133,6 +133,9 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx constraints.dangerously_allow_non_loopback_admin = Some(dangerously_allow_non_loopback_admin); } + if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets { + constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); + } if let Some(allowed_domains) = network.allowed_domains { constraints.allowed_domains = Some(allowed_domains); } @@ -288,4 +291,24 @@ allowed_domains = ["higher.example.com"] assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); } + + #[test] + fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { + let config: toml::Value = toml::from_str( + r#" +[network] +dangerously_allow_all_unix_sockets = true +"#, + ) + .expect("network table should parse"); + let network = network_tables_from_toml(&config) + .expect("network table should deserialize") + .network + .expect("network table should be present"); + + let mut constraints = NetworkProxyConstraints::default(); + apply_network_constraints(network, &mut constraints); + + assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); + } } diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index 4ec78696b6..c207295e96 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -172,6 +172,8 @@ mod tests { originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 3d2b55ab5b..0147a766fe 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -40,9 +40,11 @@ fn render_js_repl_instructions(config: &Config) -> Option { } let mut section = String::from("## JavaScript REPL (Node)\n"); - section.push_str("- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n"); + section.push_str( + "- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n", + ); section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n"); - section.push_str("- Helpers: `codex.state`, `codex.tmpDir`, and `codex.tool(name, args?)`.\n"); + section.push_str("- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n"); section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n"); section.push_str("- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n"); section.push_str("- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n"); @@ -415,7 +417,7 @@ mod tests { let res = get_user_instructions(&cfg, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.state`, `codex.tmpDir`, and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; assert_eq!(res, expected); } @@ -430,7 +432,7 @@ mod tests { let res = get_user_instructions(&cfg, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.state`, `codex.tmpDir`, and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; assert_eq!(res, expected); } diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 9b2b00c78b..8c9ee7afec 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -39,7 +39,7 @@ pub struct ThreadsPage { } /// Summary information for a thread rollout file. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Default)] pub struct ThreadItem { /// Absolute path to the rollout file. pub path: PathBuf, @@ -57,6 +57,10 @@ pub struct ThreadItem { pub git_origin_url: Option, /// Session source from session metadata. pub source: Option, + /// Random unique nickname from session metadata for AgentControl-spawned sub-agents. + pub agent_nickname: Option, + /// Role (agent_role) from session metadata for AgentControl-spawned sub-agents. + pub agent_role: Option, /// Model provider from session metadata. pub model_provider: Option, /// CLI version from session metadata. @@ -87,6 +91,8 @@ struct HeadTailSummary { git_sha: Option, git_origin_url: Option, source: Option, + agent_nickname: Option, + agent_role: Option, model_provider: Option, cli_version: Option, created_at: Option, @@ -715,6 +721,8 @@ async fn build_thread_item( git_sha, git_origin_url, source, + agent_nickname, + agent_role, model_provider, cli_version, created_at, @@ -733,6 +741,8 @@ async fn build_thread_item( git_sha, git_origin_url, source, + agent_nickname, + agent_role, model_provider, cli_version, created_at, @@ -1017,6 +1027,8 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { if !summary.saw_session_meta { summary.source = Some(session_meta_line.meta.source.clone()); + summary.agent_nickname = session_meta_line.meta.agent_nickname.clone(); + summary.agent_role = session_meta_line.meta.agent_role.clone(); summary.model_provider = session_meta_line.meta.model_provider.clone(); summary.thread_id = Some(session_meta_line.meta.id); summary.cwd = Some(session_meta_line.meta.cwd.clone()); diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index 7298877751..9196931daf 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -47,6 +47,8 @@ pub(crate) fn builder_from_session_meta( session_meta.meta.source.clone(), ); builder.model_provider = session_meta.meta.model_provider.clone(); + builder.agent_nickname = session_meta.meta.agent_nickname.clone(); + builder.agent_role = session_meta.meta.agent_role.clone(); builder.cwd = session_meta.meta.cwd.clone(); builder.cli_version = Some(session_meta.meta.cli_version.clone()); builder.sandbox_policy = SandboxPolicy::new_read_only_policy(); @@ -512,6 +514,8 @@ mod tests { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + agent_nickname: None, + agent_role: None, model_provider: Some("openai".to_string()), base_instructions: None, dynamic_tools: None, @@ -660,6 +664,8 @@ mod tests { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + agent_nickname: None, + agent_role: None, model_provider: Some("test-provider".to_string()), base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index eab442743e..7e639e382e 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -391,6 +391,8 @@ impl RolloutRecorder { cwd: config.cwd.clone(), originator: originator().value, cli_version: env!("CARGO_PKG_VERSION").to_string(), + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), source, model_provider: Some(config.model_provider_id.clone()), base_instructions: Some(base_instructions), @@ -929,9 +931,12 @@ impl From for ThreadsPage { git_sha: item.git_sha, git_origin_url: item.git_origin_url, source: Some( - serde_json::from_value(Value::String(item.source)) + serde_json::from_str(item.source.as_str()) + .or_else(|_| serde_json::from_value(Value::String(item.source))) .unwrap_or(SessionSource::Unknown), ), + agent_nickname: item.agent_nickname, + agent_role: item.agent_role, model_provider: Some(item.model_provider), cli_version: Some(item.cli_version), created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index b97edb0f66..0eabaee745 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -560,6 +560,8 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-03T12-00-00".into()), @@ -574,6 +576,8 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-02T12-00-00".into()), @@ -588,6 +592,8 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-01T12-00-00".into()), @@ -695,6 +701,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-05T09-00-00".into()), @@ -709,6 +717,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-04T09-00-00".into()), @@ -759,6 +769,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-03T09-00-00".into()), @@ -773,6 +785,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-02T09-00-00".into()), @@ -814,6 +828,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-01T09-00-00".into()), @@ -894,6 +910,8 @@ async fn test_get_thread_contents() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.into()), @@ -1086,6 +1104,8 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, + agent_nickname: None, + agent_role: None, model_provider: Some("test-provider".into()), base_instructions: None, dynamic_tools: None, @@ -1203,6 +1223,8 @@ async fn test_stable_ordering_same_second_pagination() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), @@ -1217,6 +1239,8 @@ async fn test_stable_ordering_same_second_pagination() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), @@ -1258,6 +1282,8 @@ async fn test_stable_ordering_same_second_pagination() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 4bf8f66092..f9c3c171e7 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -46,7 +46,6 @@ pub struct ExecRequest { pub cwd: PathBuf, pub env: HashMap, pub network: Option, - pub network_attempt_id: Option, pub expiration: ExecExpiration, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, @@ -222,7 +221,6 @@ impl SandboxManager { cwd: spec.cwd, env, network: network.cloned(), - network_attempt_id: None, expiration: spec.expiration, sandbox, windows_sandbox_level, diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 03cb45d918..e9d8879f85 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,16 +1,18 @@ #![cfg(target_os = "macos")] -use codex_network_proxy::ALLOW_LOCAL_BINDING_ENV_KEY; use codex_network_proxy::NetworkProxy; use codex_network_proxy::PROXY_URL_ENV_KEYS; use codex_network_proxy::has_proxy_url_env_vars; use codex_network_proxy::proxy_url_env_value; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; use tokio::process::Child; +use tracing::warn; use url::Url; use crate::protocol::SandboxPolicy; @@ -110,34 +112,131 @@ fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { ports.into_iter().collect() } -fn local_binding_enabled(env: &HashMap) -> bool { - env.get(ALLOW_LOCAL_BINDING_ENV_KEY).is_some_and(|value| { - let trimmed = value.trim(); - trimmed == "1" || trimmed.eq_ignore_ascii_case("true") - }) -} - #[derive(Debug, Default)] struct ProxyPolicyInputs { ports: Vec, has_proxy_config: bool, allow_local_binding: bool, + unix_domain_socket_policy: UnixDomainSocketPolicy, +} + +#[derive(Debug, Clone)] +// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. +enum UnixDomainSocketPolicy { + AllowAll, + Restricted { allowed: Vec }, +} + +impl Default for UnixDomainSocketPolicy { + fn default() -> Self { + Self::Restricted { allowed: vec![] } + } +} + +#[derive(Debug, Clone)] +struct UnixSocketPathParam { + key: String, + path: AbsolutePathBuf, } fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { if let Some(network) = network { let mut env = HashMap::new(); network.apply_to_env(&mut env); + let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { + UnixDomainSocketPolicy::AllowAll + } else { + let allowed = network + .allow_unix_sockets() + .iter() + .filter_map( + |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { + Some(path) => Some((path.to_string_lossy().to_string(), path)), + None => { + warn!( + "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" + ); + None + } + }, + ) + .collect::>() + .into_values() + .collect(); + UnixDomainSocketPolicy::Restricted { allowed } + }; return ProxyPolicyInputs { ports: proxy_loopback_ports_from_env(&env), has_proxy_config: has_proxy_url_env_vars(&env), - allow_local_binding: local_binding_enabled(&env), + allow_local_binding: network.allow_local_binding(), + unix_domain_socket_policy, }; } ProxyPolicyInputs::default() } +fn normalize_path_for_sandbox(path: &Path) -> Option { + // `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current + // working directory, so keep the explicit check to avoid silently accepting relative entries. + if !path.is_absolute() { + return None; + } + + let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?; + let normalized_path = absolute_path + .as_path() + .canonicalize() + .ok() + .and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok()); + normalized_path.or(Some(absolute_path)) +} + +fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { + let mut deduped_paths: BTreeMap = BTreeMap::new(); + let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { + return vec![]; + }; + for path in allowed { + deduped_paths + .entry(path.to_string_lossy().to_string()) + .or_insert_with(|| path.clone()); + } + + deduped_paths + .into_values() + .enumerate() + .map(|(index, path)| UnixSocketPathParam { + key: format!("UNIX_SOCKET_PATH_{index}"), + path, + }) + .collect() +} + +fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { + unix_socket_path_params(proxy) + .into_iter() + .map(|param| (param.key, param.path.into_path_buf())) + .collect() +} + +/// Returns zero or more complete Seatbelt policy lines for unix socket rules. +/// When non-empty, the returned string is newline-terminated so callers can +/// append it directly to larger policy blocks. +fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String { + if matches!( + proxy.unix_domain_socket_policy, + UnixDomainSocketPolicy::AllowAll + ) { + return "(allow network* (subpath \"/\"))\n".to_string(); + } + + unix_socket_path_params(proxy) + .iter() + .map(|param| format!("(allow network* (subpath (param \"{}\")))\n", param.key)) + .collect() +} + fn dynamic_network_policy( sandbox_policy: &SandboxPolicy, enforce_managed_network: bool, @@ -157,6 +256,11 @@ fn dynamic_network_policy( "(allow network-outbound (remote ip \"localhost:{port}\"))\n" )); } + let unix_socket_policy = unix_socket_policy(proxy); + if !unix_socket_policy.is_empty() { + policy.push_str("; allow unix domain sockets for local IPC\n"); + policy.push_str(&unix_socket_policy); + } return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); } @@ -339,6 +443,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions( file_write_dir_params, unix_socket_params, macos_dir_params(), + unix_socket_dir_params(&proxy), seatbelt_extensions.dir_params, ] .concat(); @@ -404,15 +509,20 @@ fn macos_dir_params() -> Vec<(String, PathBuf)> { mod tests { use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; + use super::UnixDomainSocketPolicy; use super::create_seatbelt_command_args; use super::create_seatbelt_command_args_with_extensions; use super::dynamic_network_policy; use super::macos_dir_params; + use super::normalize_path_for_sandbox; + use super::unix_socket_dir_params; + use super::unix_socket_policy; use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::seatbelt_permissions::MacOsAutomationPermission; use crate::seatbelt_permissions::MacOsPreferencesPermission; use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -430,6 +540,10 @@ mod tests { ); } + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(Path::new(path)).expect("absolute path") + } + #[test] fn base_policy_allows_node_cpu_sysctls() { assert!( @@ -451,6 +565,7 @@ mod tests { ports: vec![43128, 48081], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); @@ -574,6 +689,7 @@ mod tests { ports: vec![43128], has_proxy_config: true, allow_local_binding: true, + ..ProxyPolicyInputs::default() }, ); @@ -610,6 +726,7 @@ mod tests { ports: vec![], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); @@ -638,12 +755,109 @@ mod tests { ports: vec![], has_proxy_config: false, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); assert_eq!(policy, ""); } + #[test] + fn create_seatbelt_args_allowlists_unix_socket_paths() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![absolute_path("/tmp/example.sock")], + }, + }, + ); + + assert!( + policy.contains("(allow network* (subpath (param \"UNIX_SOCKET_PATH_0\")))"), + "policy should allow explicitly configured unix sockets:\n{policy}" + ); + } + + #[test] + fn unix_socket_policy_non_empty_output_is_newline_terminated() { + let allowlist_policy = unix_socket_policy(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![absolute_path("/tmp/example.sock")], + }, + ..ProxyPolicyInputs::default() + }); + assert!( + allowlist_policy.ends_with('\n'), + "allowlist unix socket policy should end with a newline:\n{allowlist_policy}" + ); + + let allow_all_policy = unix_socket_policy(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, + ..ProxyPolicyInputs::default() + }); + assert!( + allow_all_policy.ends_with('\n'), + "allow-all unix socket policy should end with a newline:\n{allow_all_policy}" + ); + } + + #[test] + fn unix_socket_dir_params_use_stable_param_names() { + let params = unix_socket_dir_params(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![ + absolute_path("/tmp/b.sock"), + absolute_path("/tmp/a.sock"), + absolute_path("/tmp/a.sock"), + ], + }, + ..ProxyPolicyInputs::default() + }); + + assert_eq!( + params, + vec![ + ( + "UNIX_SOCKET_PATH_0".to_string(), + PathBuf::from("/tmp/a.sock") + ), + ( + "UNIX_SOCKET_PATH_1".to_string(), + PathBuf::from("/tmp/b.sock") + ), + ] + ); + } + + #[test] + fn normalize_path_for_sandbox_rejects_relative_paths() { + assert_eq!(normalize_path_for_sandbox(Path::new("relative.sock")), None); + } + + #[test] + fn create_seatbelt_args_allows_all_unix_sockets_when_enabled() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, + }, + ); + + assert!( + policy.contains("(allow network* (subpath \"/\"))"), + "policy should allow all unix sockets when flag is enabled:\n{policy}" + ); + } + #[test] fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { let policy = dynamic_network_policy( @@ -659,6 +873,7 @@ mod tests { ports: vec![43128], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index e00bd42976..3c497bfcf8 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1372,6 +1372,7 @@ permissions: } ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions, @@ -1408,6 +1409,7 @@ permissions: {} crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: Some( @@ -1421,6 +1423,7 @@ permissions: {} crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -1515,6 +1518,7 @@ permissions: crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs index 1cf18e1052..24f3c9444c 100644 --- a/codex-rs/core/src/skills/permissions.rs +++ b/codex-rs/core/src/skills/permissions.rs @@ -112,6 +112,7 @@ pub(crate) fn compile_permission_profile( approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(sandbox_policy), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions, @@ -347,6 +348,7 @@ mod tests { exclude_slash_tmp: false, }), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] @@ -391,6 +393,7 @@ mod tests { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] @@ -439,6 +442,7 @@ mod tests { }, }), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 80112c61c7..d356193f58 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -151,7 +151,6 @@ pub(crate) async fn execute_user_shell_command( Some(session.conversation_id), ), network: turn_context.network.clone(), - network_attempt_id: None, // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 49c679e520..8f14fccedf 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -143,7 +143,6 @@ impl ToolHandler for ApplyPatchHandler { turn: turn.as_ref(), call_id: call_id.clone(), tool_name: tool_name.to_string(), - network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) @@ -234,7 +233,6 @@ pub(crate) async fn intercept_apply_patch( turn, call_id: call_id.to_string(), tool_name: tool_name.to_string(), - network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy) diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 2180574cc5..b0c4cc4126 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -20,8 +20,10 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabCloseBeginEvent; use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabResumeBeginEvent; @@ -33,6 +35,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; +use std::collections::HashMap; pub struct MultiAgentHandler; @@ -156,7 +159,11 @@ mod spawn { .spawn_agent( config, input_items, - Some(thread_spawn_source(session.conversation_id, child_depth)), + Some(thread_spawn_source( + session.conversation_id, + child_depth, + role_name, + )), ) .await .map_err(collab_spawn_error); @@ -167,6 +174,15 @@ mod spawn { ), Err(_) => (None, AgentStatus::NotFound), }; + let (new_agent_nickname, new_agent_role) = match new_thread_id { + Some(thread_id) => session + .services + .agent_control + .get_agent_nickname_and_role(thread_id) + .await + .unwrap_or((None, None)), + None => (None, None), + }; session .send_event( &turn, @@ -174,6 +190,8 @@ mod spawn { call_id, sender_thread_id: session.conversation_id, new_thread_id, + new_agent_nickname, + new_agent_role, prompt, status, } @@ -224,6 +242,12 @@ mod send_input { let receiver_thread_id = agent_id(&args.id)?; let input_items = parse_collab_input(args.message, args.items)?; let prompt = input_preview(&input_items); + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((None, None)); if args.interrupt { session .services @@ -262,6 +286,8 @@ mod send_input { call_id, sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, prompt, status, } @@ -284,7 +310,6 @@ mod send_input { mod resume_agent { use super::*; use crate::agent::next_thread_spawn_depth; - use crate::rollout::find_thread_path_by_id_str; use std::sync::Arc; #[derive(Debug, Deserialize)] @@ -305,6 +330,12 @@ mod resume_agent { ) -> Result { let args: ResumeAgentArgs = parse_arguments(&arguments)?; let receiver_thread_id = agent_id(&args.id)?; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((None, None)); let child_depth = next_thread_spawn_depth(&turn.session_source); if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) { return Err(FunctionCallError::RespondToModel( @@ -319,6 +350,8 @@ mod resume_agent { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname: receiver_agent_nickname.clone(), + receiver_agent_role: receiver_agent_role.clone(), } .into(), ) @@ -331,15 +364,7 @@ mod resume_agent { .await; let error = if matches!(status, AgentStatus::NotFound) { // If the thread is no longer active, attempt to restore it from rollout. - match try_resume_closed_agent( - &session, - &turn, - receiver_thread_id, - &args.id, - child_depth, - ) - .await - { + match try_resume_closed_agent(&session, &turn, receiver_thread_id, child_depth).await { Ok(resumed_status) => { status = resumed_status; None @@ -357,6 +382,12 @@ mod resume_agent { None }; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((receiver_agent_nickname, receiver_agent_role)); session .send_event( &turn, @@ -364,6 +395,8 @@ mod resume_agent { call_id, sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, status: status.clone(), } .into(), @@ -388,33 +421,16 @@ mod resume_agent { session: &Arc, turn: &Arc, receiver_thread_id: ThreadId, - receiver_id: &str, child_depth: i32, ) -> Result { - let rollout_path = find_thread_path_by_id_str( - turn.config.codex_home.as_path(), - receiver_id, - ) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "tool failed: failed to locate rollout for agent {receiver_thread_id}: {err}" - )) - })? - .ok_or_else(|| { - FunctionCallError::RespondToModel(format!( - "agent with id {receiver_thread_id} not found" - )) - })?; - let config = build_agent_resume_config(turn.as_ref(), child_depth)?; let resumed_thread_id = session .services .agent_control .resume_agent_from_rollout( config, - rollout_path, - thread_spawn_source(session.conversation_id, child_depth), + receiver_thread_id, + thread_spawn_source(session.conversation_id, child_depth, None), ) .await .map_err(|err| collab_agent_error(receiver_thread_id, err))?; @@ -470,6 +486,20 @@ pub(crate) mod wait { .iter() .map(|id| agent_id(id)) .collect::, _>>()?; + let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); + for receiver_thread_id in &receiver_thread_ids { + let (agent_nickname, agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(*receiver_thread_id) + .await + .unwrap_or((None, None)); + receiver_agents.push(CollabAgentRef { + thread_id: *receiver_thread_id, + agent_nickname, + agent_role, + }); + } // Validate timeout. // Very short timeouts encourage busy-polling loops in the orchestrator prompt and can @@ -490,6 +520,7 @@ pub(crate) mod wait { CollabWaitingBeginEvent { sender_thread_id: session.conversation_id, receiver_thread_ids: receiver_thread_ids.clone(), + receiver_agents: receiver_agents.clone(), call_id: call_id.clone(), } .into(), @@ -519,6 +550,10 @@ pub(crate) mod wait { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id: call_id.clone(), + agent_statuses: build_wait_agent_statuses( + &statuses, + &receiver_agents, + ), statuses, } .into(), @@ -565,6 +600,7 @@ pub(crate) mod wait { // Convert payload. let statuses_map = statuses.clone().into_iter().collect::>(); + let agent_statuses = build_wait_agent_statuses(&statuses_map, &receiver_agents); let result = WaitResult { status: statuses_map.clone(), timed_out: statuses.is_empty(), @@ -577,6 +613,7 @@ pub(crate) mod wait { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id, + agent_statuses, statuses: statuses_map, } .into(), @@ -633,6 +670,12 @@ pub mod close_agent { ) -> Result { let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = agent_id(&args.id)?; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(agent_id) + .await + .unwrap_or((None, None)); session .send_event( &turn, @@ -660,6 +703,8 @@ pub mod close_agent { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent_nickname.clone(), + receiver_agent_role: receiver_agent_role.clone(), status, } .into(), @@ -686,6 +731,8 @@ pub mod close_agent { call_id, sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, + receiver_agent_nickname, + receiver_agent_role, status: status.clone(), } .into(), @@ -709,6 +756,43 @@ fn agent_id(id: &str) -> Result { .map_err(|e| FunctionCallError::RespondToModel(format!("invalid agent id {id}: {e:?}"))) } +fn build_wait_agent_statuses( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut entries = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if let Some(status) = statuses.get(&receiver_agent.thread_id) { + entries.push(CollabAgentStatusEntry { + thread_id: receiver_agent.thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + status: status.clone(), + }); + } + } + + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains_key(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries +} + fn collab_spawn_error(err: CodexErr) -> FunctionCallError { match err { CodexErr::UnsupportedOperation(_) => { @@ -733,10 +817,16 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { } } -fn thread_spawn_source(parent_thread_id: ThreadId, depth: i32) -> SessionSource { +fn thread_spawn_source( + parent_thread_id: ThreadId, + depth: i32, + agent_role: Option<&str>, +) -> SessionSource { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth, + agent_nickname: None, + agent_role: agent_role.map(str::to_string), }) } @@ -990,6 +1080,7 @@ mod tests { } #[tokio::test] + #[ignore = "No role requiring it for now"] async fn spawn_agent_uses_explorer_role_and_sets_never_approval_policy() { #[derive(Debug, Deserialize)] struct SpawnAgentResult { @@ -1067,6 +1158,8 @@ mod tests { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: DEFAULT_AGENT_MAX_DEPTH, + agent_nickname: None, + agent_role: None, }); let invocation = invocation( @@ -1103,6 +1196,8 @@ mod tests { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: DEFAULT_AGENT_MAX_DEPTH, + agent_nickname: None, + agent_role: None, }); let invocation = invocation( @@ -1486,6 +1581,8 @@ mod tests { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: DEFAULT_AGENT_MAX_DEPTH, + agent_nickname: None, + agent_role: None, }); let invocation = invocation( diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index c5e267cb49..a4878b73a6 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -124,6 +124,7 @@ impl ToolHandler for SearchToolBm25Handler { &turn.config, ); let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors); + let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config); let mut entries: Vec = mcp_tools .into_iter() diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 31bf3f1294..f040b4ba64 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -54,7 +54,6 @@ impl ShellHandler { expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), network: turn_context.network.clone(), - network_attempt_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -64,8 +63,20 @@ impl ShellHandler { } impl ShellCommandHandler { - fn base_command(shell: &Shell, command: &str, login: Option) -> Vec { - let use_login_shell = login.unwrap_or(true); + fn resolve_use_login_shell( + login: Option, + allow_login_shell: bool, + ) -> Result { + if !allow_login_shell && login == Some(true) { + return Err(FunctionCallError::RespondToModel( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + )); + } + + Ok(login.unwrap_or(allow_login_shell)) + } + + fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec { shell.derive_exec_args(command, use_login_shell) } @@ -74,22 +85,23 @@ impl ShellCommandHandler { session: &crate::codex::Session, turn_context: &TurnContext, thread_id: ThreadId, - ) -> ExecParams { + allow_login_shell: bool, + ) -> Result { let shell = session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, params.login); + let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - ExecParams { + Ok(ExecParams { command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), network: turn_context.network.clone(), - network_attempt_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, - } + }) } } @@ -185,8 +197,15 @@ impl ToolHandler for ShellCommandHandler { serde_json::from_str::(arguments) .map(|params| { + let use_login_shell = match Self::resolve_use_login_shell( + params.login, + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(use_login_shell) => use_login_shell, + Err(_) => return true, + }; let shell = invocation.session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, params.login); + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); !is_known_safe_command(&command) }) .unwrap_or(true) @@ -215,7 +234,8 @@ impl ToolHandler for ShellCommandHandler { session.as_ref(), turn.as_ref(), session.conversation_id, - ); + turn.tools_config.allow_login_shell, + )?; ShellHandler::run_exec_like(RunExecLikeArgs { tool_name, exec_params, @@ -327,7 +347,6 @@ impl ShellHandler { turn: turn.as_ref(), call_id: call_id.clone(), tool_name, - network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) @@ -442,7 +461,9 @@ mod tests { &session, &turn_context, session.conversation_id, - ); + true, + ) + .expect("login shells should be allowed"); // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. assert_eq!(exec_params.command, expected_command); @@ -467,18 +488,57 @@ mod tests { shell_snapshot, }; - let login_command = - ShellCommandHandler::base_command(&shell, "echo login shell", Some(true)); + let login_command = ShellCommandHandler::base_command(&shell, "echo login shell", true); assert_eq!( login_command, shell.derive_exec_args("echo login shell", true) ); let non_login_command = - ShellCommandHandler::base_command(&shell, "echo non login shell", Some(false)); + ShellCommandHandler::base_command(&shell, "echo non login shell", false); assert_eq!( non_login_command, shell.derive_exec_args("echo non login shell", false) ); } + + #[tokio::test] + async fn shell_command_handler_defaults_to_non_login_when_disallowed() { + let (session, turn_context) = make_session_and_context().await; + let params = ShellCommandToolCallParams { + command: "echo hello".to_string(), + workdir: None, + login: None, + timeout_ms: None, + sandbox_permissions: None, + prefix_rule: None, + justification: None, + }; + + let exec_params = ShellCommandHandler::to_exec_params( + ¶ms, + &session, + &turn_context, + session.conversation_id, + false, + ) + .expect("non-login shells should still be allowed"); + + assert_eq!( + exec_params.command, + session.user_shell().derive_exec_args("echo hello", false) + ); + } + + #[test] + fn shell_command_handler_rejects_login_when_disallowed() { + let err = ShellCommandHandler::resolve_use_login_shell(Some(true), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.to_string() + .contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 1bad6fa638..62da64a877 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -32,8 +32,8 @@ pub(crate) struct ExecCommandArgs { pub(crate) workdir: Option, #[serde(default)] shell: Option, - #[serde(default = "default_login")] - login: bool, + #[serde(default)] + login: Option, #[serde(default = "default_tty")] tty: bool, #[serde(default = "default_exec_yield_time_ms")] @@ -68,10 +68,6 @@ fn default_write_stdin_yield_time_ms() -> u64 { 250 } -fn default_login() -> bool { - true -} - fn default_tty() -> bool { false } @@ -98,7 +94,14 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms, invocation.session.user_shell()); + let command = match get_command( + ¶ms, + invocation.session.user_shell(), + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(command) => command, + Err(_) => return true, + }; !is_known_safe_command(&command) } @@ -129,7 +132,12 @@ impl ToolHandler for UnifiedExecHandler { "exec_command" => { let args: ExecCommandArgs = parse_arguments(&arguments)?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args, session.user_shell()); + let command = get_command( + &args, + session.user_shell(), + turn.tools_config.allow_login_shell, + ) + .map_err(FunctionCallError::RespondToModel)?; let ExecCommandArgs { workdir, @@ -238,7 +246,11 @@ impl ToolHandler for UnifiedExecHandler { } } -pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { +pub(crate) fn get_command( + args: &ExecCommandArgs, + session_shell: Arc, + allow_login_shell: bool, +) -> Result, String> { let model_shell = args.shell.as_ref().map(|shell_str| { let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver(); @@ -246,8 +258,17 @@ pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> }); let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); + let use_login_shell = match args.login { + Some(true) if !allow_login_shell => { + return Err( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + ); + } + Some(use_login_shell) => use_login_shell, + None => allow_login_shell, + }; - shell.derive_exec_args(&args.cmd, args.login) + Ok(shell.derive_exec_args(&args.cmd, use_login_shell)) } fn format_response(response: &UnifiedExecResponse) -> String { @@ -283,6 +304,7 @@ fn format_response(response: &UnifiedExecResponse) -> String { mod tests { use super::*; use crate::shell::default_user_shell; + use pretty_assertions::assert_eq; use std::sync::Arc; #[test] @@ -293,7 +315,8 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -308,7 +331,8 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command.last(), Some(&"echo hello".to_string())); if command @@ -328,7 +352,8 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command[2], "echo hello"); Ok(()) @@ -342,9 +367,25 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command[2], "echo hello"); Ok(()) } + + #[test] + fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello", "login": true}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + let err = get_command(&args, Arc::new(default_user_shell()), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + Ok(()) + } } diff --git a/codex-rs/core/src/tools/js_repl/kernel.js b/codex-rs/core/src/tools/js_repl/kernel.js index 67a1f23a8b..283a9f4516 100644 --- a/codex-rs/core/src/tools/js_repl/kernel.js +++ b/codex-rs/core/src/tools/js_repl/kernel.js @@ -115,9 +115,6 @@ function isDeniedBuiltin(specifier) { const pendingTool = new Map(); let toolCounter = 0; const tmpDir = process.env.CODEX_JS_TMP_DIR || process.cwd(); -// Explicit long-lived mutable store exposed as `codex.state`. This is useful -// when callers want shared state without relying on lexical binding carry-over. -const state = {}; const nodeModuleDirEnv = process.env.CODEX_JS_REPL_NODE_MODULE_DIRS ?? ""; const moduleSearchBases = (() => { const bases = []; @@ -470,8 +467,7 @@ async function handleExec(message) { const { source, nextBindings } = await buildModuleSource(code); let output = ""; - context.state = state; - context.codex = { state, tmpDir, tool }; + context.codex = { tmpDir, tool }; context.tmpDir = tmpDir; await withCapturedConsole(context, async (logs) => { diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index d5643e4feb..4a893f74e9 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -10,12 +10,14 @@ use codex_network_proxy::NetworkProtocol; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::NetworkApprovalContext; use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; +use indexmap::IndexMap; use std::collections::HashMap; use std::collections::HashSet; -use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::sync::Notify; use tokio::sync::RwLock; use uuid::Uuid; @@ -27,168 +29,207 @@ pub(crate) enum NetworkApprovalMode { #[derive(Clone, Debug)] pub(crate) struct NetworkApprovalSpec { - pub command: Vec, - pub cwd: PathBuf, pub network: Option, pub mode: NetworkApprovalMode, } #[derive(Clone, Debug)] pub(crate) struct DeferredNetworkApproval { - attempt_id: String, + registration_id: String, } impl DeferredNetworkApproval { - pub(crate) fn attempt_id(&self) -> &str { - &self.attempt_id + pub(crate) fn registration_id(&self) -> &str { + &self.registration_id } } #[derive(Debug)] pub(crate) struct ActiveNetworkApproval { - attempt_id: Option, + registration_id: Option, mode: NetworkApprovalMode, } impl ActiveNetworkApproval { - pub(crate) fn attempt_id(&self) -> Option<&str> { - self.attempt_id.as_deref() - } - pub(crate) fn mode(&self) -> NetworkApprovalMode { self.mode } pub(crate) fn into_deferred(self) -> Option { - match (self.mode, self.attempt_id) { - (NetworkApprovalMode::Deferred, Some(attempt_id)) => { - Some(DeferredNetworkApproval { attempt_id }) + match (self.mode, self.registration_id) { + (NetworkApprovalMode::Deferred, Some(registration_id)) => { + Some(DeferredNetworkApproval { registration_id }) } _ => None, } } } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct HostApprovalKey { + host: String, + protocol: &'static str, + port: u16, +} + +impl HostApprovalKey { + fn from_request(request: &NetworkPolicyRequest, protocol: NetworkApprovalProtocol) -> Self { + Self { + host: request.host.to_ascii_lowercase(), + protocol: protocol_key_label(protocol), + port: request.port, + } + } +} + +fn protocol_key_label(protocol: NetworkApprovalProtocol) -> &'static str { + match protocol { + NetworkApprovalProtocol::Http => "http", + NetworkApprovalProtocol::Https => "https", + NetworkApprovalProtocol::Socks5Tcp => "socks5-tcp", + NetworkApprovalProtocol::Socks5Udp => "socks5-udp", + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PendingApprovalDecision { + AllowOnce, + AllowForSession, + Deny, +} + #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum NetworkApprovalOutcome { +enum NetworkApprovalOutcome { DeniedByUser, DeniedByPolicy(String), } -struct NetworkApprovalAttempt { - turn_id: String, - call_id: String, - command: Vec, - cwd: PathBuf, - approved_hosts: Mutex>, - outcome: Mutex>, +fn allows_network_prompt(policy: AskForApproval) -> bool { + !matches!(policy, AskForApproval::Never) +} + +impl PendingApprovalDecision { + fn to_network_decision(self) -> NetworkDecision { + match self { + Self::AllowOnce | Self::AllowForSession => NetworkDecision::Allow, + Self::Deny => NetworkDecision::deny("not_allowed"), + } + } +} + +struct PendingHostApproval { + decision: Mutex>, + notify: Notify, +} + +impl PendingHostApproval { + fn new() -> Self { + Self { + decision: Mutex::new(None), + notify: Notify::new(), + } + } + + async fn wait_for_decision(&self) -> PendingApprovalDecision { + loop { + let notified = self.notify.notified(); + if let Some(decision) = *self.decision.lock().await { + return decision; + } + notified.await; + } + } + + async fn set_decision(&self, decision: PendingApprovalDecision) { + { + let mut current = self.decision.lock().await; + *current = Some(decision); + } + self.notify.notify_waiters(); + } +} + +struct ActiveNetworkApprovalCall { + registration_id: String, } pub(crate) struct NetworkApprovalService { - attempts: Mutex>>, - session_approved_hosts: Mutex>, + active_calls: Mutex>>, + call_outcomes: Mutex>, + pending_host_approvals: Mutex>>, + session_approved_hosts: Mutex>, } impl Default for NetworkApprovalService { fn default() -> Self { Self { - attempts: Mutex::new(HashMap::new()), + active_calls: Mutex::new(IndexMap::new()), + call_outcomes: Mutex::new(HashMap::new()), + pending_host_approvals: Mutex::new(HashMap::new()), session_approved_hosts: Mutex::new(HashSet::new()), } } } impl NetworkApprovalService { - pub(crate) async fn register_attempt( - &self, - attempt_id: String, - turn_id: String, - call_id: String, - command: Vec, - cwd: PathBuf, - ) { - let mut attempts = self.attempts.lock().await; - attempts.insert( - attempt_id, - Arc::new(NetworkApprovalAttempt { - turn_id, - call_id, - command, - cwd, - approved_hosts: Mutex::new(HashSet::new()), - outcome: Mutex::new(None), - }), - ); + async fn register_call(&self, registration_id: String) { + let mut active_calls = self.active_calls.lock().await; + let key = registration_id.clone(); + active_calls.insert(key, Arc::new(ActiveNetworkApprovalCall { registration_id })); } - pub(crate) async fn unregister_attempt(&self, attempt_id: &str) { - let mut attempts = self.attempts.lock().await; - attempts.remove(attempt_id); + pub(crate) async fn unregister_call(&self, registration_id: &str) { + let mut active_calls = self.active_calls.lock().await; + active_calls.shift_remove(registration_id); + let mut call_outcomes = self.call_outcomes.lock().await; + call_outcomes.remove(registration_id); } - pub(crate) async fn take_outcome(&self, attempt_id: &str) -> Option { - let attempt = { - let attempts = self.attempts.lock().await; - attempts.get(attempt_id).cloned() - }?; - let mut outcome = attempt.outcome.lock().await; - outcome.take() - } - - pub(crate) async fn take_user_denial_outcome(&self, attempt_id: &str) -> bool { - let attempt = { - let attempts = self.attempts.lock().await; - attempts.get(attempt_id).cloned() - }; - let Some(attempt) = attempt else { - return false; - }; - let mut outcome = attempt.outcome.lock().await; - if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) { - outcome.take(); - return true; - } - false - } - - async fn resolve_attempt_for_request( - &self, - request: &NetworkPolicyRequest, - ) -> Option> { - let attempts = self.attempts.lock().await; - - if let Some(attempt_id) = request.attempt_id.as_deref() { - if let Some(attempt) = attempts.get(attempt_id).cloned() { - return Some(attempt); - } - return None; - } - - if attempts.len() == 1 { - return attempts.values().next().cloned(); + async fn resolve_single_active_call(&self) -> Option> { + let active_calls = self.active_calls.lock().await; + if active_calls.len() == 1 { + return active_calls.values().next().cloned(); } None } - async fn resolve_attempt_for_blocked_request( + async fn get_or_create_pending_approval( &self, - blocked: &BlockedRequest, - ) -> Option> { - let attempts = self.attempts.lock().await; - - if let Some(attempt_id) = blocked.attempt_id.as_deref() { - if let Some(attempt) = attempts.get(attempt_id).cloned() { - return Some(attempt); - } - return None; + key: HostApprovalKey, + ) -> (Arc, bool) { + let mut pending = self.pending_host_approvals.lock().await; + if let Some(existing) = pending.get(&key).cloned() { + return (existing, false); } - if attempts.len() == 1 { - return attempts.values().next().cloned(); - } + let created = Arc::new(PendingHostApproval::new()); + pending.insert(key, Arc::clone(&created)); + (created, true) + } - None + async fn record_outcome_for_single_active_call(&self, outcome: NetworkApprovalOutcome) { + let Some(owner_call) = self.resolve_single_active_call().await else { + return; + }; + self.record_call_outcome(&owner_call.registration_id, outcome) + .await; + } + + async fn take_call_outcome(&self, registration_id: &str) -> Option { + let mut call_outcomes = self.call_outcomes.lock().await; + call_outcomes.remove(registration_id) + } + + async fn record_call_outcome(&self, registration_id: &str, outcome: NetworkApprovalOutcome) { + let mut call_outcomes = self.call_outcomes.lock().await; + if matches!( + call_outcomes.get(registration_id), + Some(NetworkApprovalOutcome::DeniedByUser) + ) { + return; + } + call_outcomes.insert(registration_id.to_string(), outcome); } pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) { @@ -196,15 +237,24 @@ impl NetworkApprovalService { return; }; - let Some(attempt) = self.resolve_attempt_for_blocked_request(&blocked).await else { - return; - }; + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message)) + .await; + } - let mut outcome = attempt.outcome.lock().await; - if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) { - return; - } - *outcome = Some(NetworkApprovalOutcome::DeniedByPolicy(message)); + async fn active_turn_context(session: &Session) -> Option> { + let active_turn = session.active_turn.lock().await; + active_turn + .as_ref() + .and_then(|turn| turn.tasks.first()) + .map(|(_, task)| Arc::clone(&task.turn_context)) + } + + fn format_network_target(protocol: &str, host: &str, port: u16) -> String { + format!("{protocol}://{host}:{port}") + } + + fn approval_id_for_key(key: &HostApprovalKey) -> String { + format!("network#{}#{}#{}", key.protocol, key.host, key.port) } pub(crate) async fn handle_inline_policy_request( @@ -214,46 +264,63 @@ impl NetworkApprovalService { ) -> NetworkDecision { const REASON_NOT_ALLOWED: &str = "not_allowed"; - { - let approved_hosts = self.session_approved_hosts.lock().await; - if approved_hosts.contains(request.host.as_str()) { - return NetworkDecision::Allow; - } - } - - let Some(attempt) = self.resolve_attempt_for_request(&request).await else { - return NetworkDecision::deny(REASON_NOT_ALLOWED); - }; - - { - let approved_hosts = attempt.approved_hosts.lock().await; - if approved_hosts.contains(request.host.as_str()) { - return NetworkDecision::Allow; - } - } - let protocol = match request.protocol { NetworkProtocol::Http => NetworkApprovalProtocol::Http, NetworkProtocol::HttpsConnect => NetworkApprovalProtocol::Https, NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp, NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp, }; + let key = HostApprovalKey::from_request(&request, protocol); - let Some(turn_context) = session.turn_context_for_sub_id(&attempt.turn_id).await else { + { + let approved_hosts = self.session_approved_hosts.lock().await; + if approved_hosts.contains(&key) { + return NetworkDecision::Allow; + } + } + + let (pending, is_owner) = self.get_or_create_pending_approval(key.clone()).await; + if !is_owner { + return pending.wait_for_decision().await.to_network_decision(); + } + + let target = Self::format_network_target(key.protocol, request.host.as_str(), key.port); + let policy_denial_message = + format!("Network access to \"{target}\" was blocked by policy."); + let prompt_reason = format!("{} is not in the allowed_domains", request.host); + + let Some(turn_context) = Self::active_turn_context(session).await else { + pending.set_decision(PendingApprovalDecision::Deny).await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( + policy_denial_message, + )) + .await; return NetworkDecision::deny(REASON_NOT_ALLOWED); }; + if !allows_network_prompt(turn_context.approval_policy) { + pending.set_decision(PendingApprovalDecision::Deny).await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( + policy_denial_message, + )) + .await; + return NetworkDecision::deny(REASON_NOT_ALLOWED); + } + + let approval_id = Self::approval_id_for_key(&key); + let prompt_command = vec!["network-access".to_string(), target.clone()]; let approval_decision = session .request_command_approval( turn_context.as_ref(), - attempt.call_id.clone(), + approval_id, None, - attempt.command.clone(), - attempt.cwd.clone(), - Some(format!( - "Network access to \"{}\" is blocked by policy.", - request.host - )), + prompt_command, + turn_context.cwd.clone(), + Some(prompt_reason), Some(NetworkApprovalContext { host: request.host.clone(), protocol, @@ -262,23 +329,28 @@ impl NetworkApprovalService { ) .await; - match approval_decision { + let resolved = match approval_decision { ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { - let mut approved_hosts = attempt.approved_hosts.lock().await; - approved_hosts.insert(request.host); - NetworkDecision::Allow - } - ReviewDecision::ApprovedForSession => { - let mut approved_hosts = self.session_approved_hosts.lock().await; - approved_hosts.insert(request.host); - NetworkDecision::Allow + PendingApprovalDecision::AllowOnce } + ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession, ReviewDecision::Denied | ReviewDecision::Abort => { - let mut outcome = attempt.outcome.lock().await; - *outcome = Some(NetworkApprovalOutcome::DeniedByUser); - NetworkDecision::deny(REASON_NOT_ALLOWED) + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByUser) + .await; + PendingApprovalDecision::Deny } + }; + + if matches!(resolved, PendingApprovalDecision::AllowForSession) { + let mut approved_hosts = self.session_approved_hosts.lock().await; + approved_hosts.insert(key.clone()); } + + pending.set_decision(resolved).await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + + resolved.to_network_decision() } } @@ -313,8 +385,8 @@ pub(crate) fn build_network_policy_decider( pub(crate) async fn begin_network_approval( session: &Session, - turn_id: &str, - call_id: &str, + _turn_id: &str, + _call_id: &str, has_managed_network_requirements: bool, spec: Option, ) -> Option { @@ -323,21 +395,15 @@ pub(crate) async fn begin_network_approval( return None; } - let attempt_id = Uuid::new_v4().to_string(); + let registration_id = Uuid::new_v4().to_string(); session .services .network_approval - .register_attempt( - attempt_id.clone(), - turn_id.to_string(), - call_id.to_string(), - spec.command, - spec.cwd, - ) + .register_call(registration_id.clone()) .await; Some(ActiveNetworkApproval { - attempt_id: Some(attempt_id), + registration_id: Some(registration_id), mode: spec.mode, }) } @@ -346,20 +412,20 @@ pub(crate) async fn finish_immediate_network_approval( session: &Session, active: ActiveNetworkApproval, ) -> Result<(), ToolError> { - let Some(attempt_id) = active.attempt_id.as_deref() else { + let Some(registration_id) = active.registration_id.as_deref() else { return Ok(()); }; let approval_outcome = session .services .network_approval - .take_outcome(attempt_id) + .take_call_outcome(registration_id) .await; session .services .network_approval - .unregister_attempt(attempt_id) + .unregister_call(registration_id) .await; match approval_outcome { @@ -371,22 +437,6 @@ pub(crate) async fn finish_immediate_network_approval( } } -pub(crate) async fn deferred_rejection_message( - session: &Session, - deferred: &DeferredNetworkApproval, -) -> Option { - match session - .services - .network_approval - .take_outcome(deferred.attempt_id()) - .await - { - Some(NetworkApprovalOutcome::DeniedByUser) => Some("rejected by user".to_string()), - Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Some(message), - None => None, - } -} - pub(crate) async fn finish_deferred_network_approval( session: &Session, deferred: Option, @@ -397,7 +447,7 @@ pub(crate) async fn finish_deferred_network_approval( session .services .network_approval - .unregister_attempt(deferred.attempt_id()) + .unregister_call(deferred.registration_id()) .await; } @@ -405,272 +455,145 @@ pub(crate) async fn finish_deferred_network_approval( mod tests { use super::*; use codex_network_proxy::BlockedRequestArgs; - use codex_network_proxy::NetworkPolicyRequestArgs; + use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; - fn http_request(host: &str, attempt_id: Option<&str>) -> NetworkPolicyRequest { - NetworkPolicyRequest::new(NetworkPolicyRequestArgs { - protocol: NetworkProtocol::Http, + #[tokio::test] + async fn pending_approvals_are_deduped_per_host_protocol_and_port() { + let service = NetworkApprovalService::default(); + let key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "http", + port: 443, + }; + + let (first, first_is_owner) = service.get_or_create_pending_approval(key.clone()).await; + let (second, second_is_owner) = service.get_or_create_pending_approval(key).await; + + assert!(first_is_owner); + assert!(!second_is_owner); + assert!(Arc::ptr_eq(&first, &second)); + } + + #[tokio::test] + async fn pending_approvals_do_not_dedupe_across_ports() { + let service = NetworkApprovalService::default(); + let first_key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 443, + }; + let second_key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 8443, + }; + + let (first, first_is_owner) = service.get_or_create_pending_approval(first_key).await; + let (second, second_is_owner) = service.get_or_create_pending_approval(second_key).await; + + assert!(first_is_owner); + assert!(second_is_owner); + assert!(!Arc::ptr_eq(&first, &second)); + } + + #[tokio::test] + async fn pending_waiters_receive_owner_decision() { + let pending = Arc::new(PendingHostApproval::new()); + + let waiter = { + let pending = Arc::clone(&pending); + tokio::spawn(async move { pending.wait_for_decision().await }) + }; + + pending + .set_decision(PendingApprovalDecision::AllowOnce) + .await; + + let decision = waiter.await.expect("waiter should complete"); + assert_eq!(decision, PendingApprovalDecision::AllowOnce); + } + + #[test] + fn allow_once_and_allow_for_session_both_allow_network() { + assert_eq!( + PendingApprovalDecision::AllowOnce.to_network_decision(), + NetworkDecision::Allow + ); + assert_eq!( + PendingApprovalDecision::AllowForSession.to_network_decision(), + NetworkDecision::Allow + ); + } + + #[test] + fn never_policy_disables_network_prompts() { + assert!(!allows_network_prompt(AskForApproval::Never)); + assert!(allows_network_prompt(AskForApproval::OnRequest)); + assert!(allows_network_prompt(AskForApproval::OnFailure)); + assert!(allows_network_prompt(AskForApproval::UnlessTrusted)); + } + + fn denied_blocked_request(host: &str) -> BlockedRequest { + BlockedRequest::new(BlockedRequestArgs { host: host.to_string(), - port: 80, - client_addr: None, - method: Some("GET".to_string()), - command: None, - exec_policy_hint: None, - attempt_id: attempt_id.map(ToString::to_string), + reason: "not_allowed".to_string(), + client: None, + method: None, + mode: None, + protocol: "http".to_string(), + decision: Some("deny".to_string()), + source: Some("decider".to_string()), + port: Some(80), }) } #[tokio::test] - async fn resolve_attempt_for_request_falls_back_to_single_active_attempt() { + async fn record_blocked_request_sets_policy_outcome_for_owner_call() { let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) + .record_blocked_request(denied_blocked_request("example.com")) .await; - let resolved = service - .resolve_attempt_for_request(&http_request("example.com", None)) - .await - .expect("single active attempt should be used as fallback"); - assert_eq!(resolved.call_id, "call-1"); - } - - #[tokio::test] - async fn resolve_attempt_for_request_returns_exact_attempt_match() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - service - .register_attempt( - "attempt-2".to_string(), - "turn-2".to_string(), - "call-2".to_string(), - vec!["curl".to_string(), "openai.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let resolved = service - .resolve_attempt_for_request(&http_request("openai.com", Some("attempt-2"))) - .await - .expect("attempt-2 should resolve"); - assert_eq!(resolved.call_id, "call-2"); - } - - #[tokio::test] - async fn resolve_attempt_for_request_returns_none_for_unknown_attempt_id() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let resolved = service - .resolve_attempt_for_request(&http_request("example.com", Some("attempt-unknown"))) - .await; - assert!(resolved.is_none()); - } - - #[tokio::test] - async fn resolve_attempt_for_request_returns_none_when_ambiguous() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - service - .register_attempt( - "attempt-2".to_string(), - "turn-2".to_string(), - "call-2".to_string(), - vec!["curl".to_string(), "robinhood.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let resolved = service - .resolve_attempt_for_request(&http_request("example.com", None)) - .await; - assert!(resolved.is_none()); - } - - #[tokio::test] - async fn take_outcome_clears_stored_value() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let attempt = { - let attempts = service.attempts.lock().await; - attempts - .get("attempt-1") - .cloned() - .expect("attempt should exist") - }; - { - let mut outcome = attempt.outcome.lock().await; - *outcome = Some(NetworkApprovalOutcome::DeniedByUser); - } - assert_eq!( - service.take_outcome("attempt-1").await, - Some(NetworkApprovalOutcome::DeniedByUser) - ); - assert_eq!(service.take_outcome("attempt-1").await, None); - } - - #[tokio::test] - async fn take_user_denial_outcome_preserves_policy_denial() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let attempt = { - let attempts = service.attempts.lock().await; - attempts - .get("attempt-1") - .cloned() - .expect("attempt should exist") - }; - { - let mut outcome = attempt.outcome.lock().await; - *outcome = Some(NetworkApprovalOutcome::DeniedByPolicy( - "policy denied".to_string(), - )); - } - - assert!(!service.take_user_denial_outcome("attempt-1").await); - assert_eq!( - service.take_outcome("attempt-1").await, + service.take_call_outcome("registration-1").await, Some(NetworkApprovalOutcome::DeniedByPolicy( - "policy denied".to_string(), + "Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string() )) ); } #[tokio::test] - async fn record_blocked_request_stores_policy_denial_outcome() { + async fn blocked_request_policy_does_not_override_user_denial_outcome() { let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) + .record_call_outcome("registration-1", NetworkApprovalOutcome::DeniedByUser) .await; - service - .record_blocked_request(BlockedRequest::new(BlockedRequestArgs { - host: "example.com".to_string(), - reason: "denied".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), - decision: Some("deny".to_string()), - source: Some("baseline_policy".to_string()), - port: Some(80), - })) - .await; - - let outcome = service - .take_outcome("attempt-1") - .await - .expect("outcome should be recorded"); - match outcome { - NetworkApprovalOutcome::DeniedByPolicy(message) => { - assert_eq!( - message, - "Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string() - ); - } - NetworkApprovalOutcome::DeniedByUser => panic!("expected policy denial"), - } - } - - #[tokio::test] - async fn record_blocked_request_does_not_override_user_denial() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let attempt = { - let attempts = service.attempts.lock().await; - attempts - .get("attempt-1") - .cloned() - .expect("attempt should exist") - }; - { - let mut outcome = attempt.outcome.lock().await; - *outcome = Some(NetworkApprovalOutcome::DeniedByUser); - } - - service - .record_blocked_request(BlockedRequest::new(BlockedRequestArgs { - host: "example.com".to_string(), - reason: "denied".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), - decision: Some("deny".to_string()), - source: Some("baseline_policy".to_string()), - port: Some(80), - })) + .record_blocked_request(denied_blocked_request("example.com")) .await; assert_eq!( - service.take_outcome("attempt-1").await, + service.take_call_outcome("registration-1").await, Some(NetworkApprovalOutcome::DeniedByUser) ); } + + #[tokio::test] + async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() { + let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + service.register_call("registration-2".to_string()).await; + + service + .record_blocked_request(denied_blocked_request("example.com")) + .await; + + assert_eq!(service.take_call_outcome("registration-1").await, None); + assert_eq!(service.take_call_outcome("registration-2").await, None); + } } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index cdbaac0254..afb24d4b3c 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -69,9 +69,6 @@ impl ToolOrchestrator { turn: tool_ctx.turn, call_id: tool_ctx.call_id.clone(), tool_name: tool_ctx.tool_name.clone(), - network_attempt_id: network_approval.as_ref().and_then(|network_approval| { - network_approval.attempt_id().map(ToString::to_string) - }), }; let run_result = tool.run(req, attempt, &attempt_tool_ctx).await; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 4ffb7ca107..7a0be68df3 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -102,6 +102,21 @@ impl ToolRegistry { sandbox_policy_tag(&invocation.turn.sandbox_policy), ), ]; + let (mcp_server, mcp_server_origin) = match &invocation.payload { + ToolPayload::Mcp { server, .. } => { + let manager = invocation + .session + .services + .mcp_connection_manager + .read() + .await; + let origin = manager.server_origin(server).map(str::to_owned); + (Some(server.clone()), origin) + } + _ => (None, None), + }; + let mcp_server_ref = mcp_server.as_deref(); + let mcp_server_origin_ref = mcp_server_origin.as_deref(); let handler = match self.handler(tool_name.as_ref()) { Some(handler) => handler, @@ -116,6 +131,8 @@ impl ToolRegistry { false, &message, &metric_tags, + mcp_server_ref, + mcp_server_origin_ref, ); return Err(FunctionCallError::RespondToModel(message)); } @@ -131,6 +148,8 @@ impl ToolRegistry { false, &message, &metric_tags, + mcp_server_ref, + mcp_server_origin_ref, ); return Err(FunctionCallError::Fatal(message)); } @@ -146,6 +165,8 @@ impl ToolRegistry { &call_id_owned, log_payload.as_ref(), &metric_tags, + mcp_server_ref, + mcp_server_origin_ref, || { let handler = handler.clone(); let output_cell = &output_cell; diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6f1e4b44af..bcbeafadfe 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -154,8 +154,6 @@ impl ToolRuntime for ShellRuntime { ) -> Option { req.network.as_ref()?; Some(NetworkApprovalSpec { - command: req.command.clone(), - cwd: req.cwd.clone(), network: req.network.clone(), mode: NetworkApprovalMode::Immediate, }) @@ -221,10 +219,9 @@ impl ToolRuntime for ShellRuntime { req.sandbox_permissions, req.justification.clone(), )?; - let mut env = attempt + let env = attempt .env_for(spec, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; - env.network_attempt_id = ctx.network_attempt_id.clone(); let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index be049d0934..201de7ad84 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -157,8 +157,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) -> Option { req.network.as_ref()?; Some(NetworkApprovalSpec { - command: req.command.clone(), - cwd: req.cwd.clone(), network: req.network.clone(), mode: NetworkApprovalMode::Deferred, }) @@ -188,7 +186,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt let mut env = req.env.clone(); if let Some(network) = req.network.as_ref() { - network.apply_to_env_for_attempt(&mut env, ctx.network_attempt_id.as_deref()); + network.apply_to_env(&mut env); } let spec = build_command_spec( &command, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 89b4bc01ec..9721fed535 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -272,7 +272,6 @@ pub(crate) struct ToolCtx<'a> { pub turn: &'a TurnContext, pub call_id: String, pub tool_name: String, - pub network_attempt_id: Option, } #[derive(Debug)] diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index b17a3a2850..7bd1fc9788 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -35,6 +35,7 @@ const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = #[derive(Debug, Clone)] pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, + pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, pub agent_roles: BTreeMap, @@ -96,6 +97,7 @@ impl ToolsConfig { Self { shell_type, + allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, agent_roles: BTreeMap::new(), @@ -112,6 +114,11 @@ impl ToolsConfig { self.agent_roles = agent_roles; self } + + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { + self.allow_login_shell = allow_login_shell; + self + } } /// Generic JSON‑Schema subset needed for our tool definitions @@ -211,7 +218,7 @@ fn create_approval_parameters() -> BTreeMap { properties } -fn create_exec_command_tool() -> ToolSpec { +fn create_exec_command_tool(allow_login_shell: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "cmd".to_string(), @@ -234,14 +241,6 @@ fn create_exec_command_tool() -> ToolSpec { description: Some("Shell binary to launch. Defaults to the user's default shell.".to_string()), }, ), - ( - "login".to_string(), - JsonSchema::Boolean { - description: Some( - "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), - ), - }, - ), ( "tty".to_string(), JsonSchema::Boolean { @@ -269,6 +268,16 @@ fn create_exec_command_tool() -> ToolSpec { }, ), ]); + if allow_login_shell { + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), + ), + }, + ); + } properties.extend(create_approval_parameters()); ToolSpec::Function(ResponsesApiTool { @@ -385,7 +394,7 @@ Examples of valid command strings: }) } -fn create_shell_command_tool() -> ToolSpec { +fn create_shell_command_tool(allow_login_shell: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -402,6 +411,14 @@ fn create_shell_command_tool() -> ToolSpec { }, ), ( + "timeout_ms".to_string(), + JsonSchema::Number { + description: Some("The timeout for the command in milliseconds".to_string()), + }, + ), + ]); + if allow_login_shell { + properties.insert( "login".to_string(), JsonSchema::Boolean { description: Some( @@ -409,14 +426,8 @@ fn create_shell_command_tool() -> ToolSpec { .to_string(), ), }, - ), - ( - "timeout_ms".to_string(), - JsonSchema::Number { - description: Some("The timeout for the command in milliseconds".to_string()), - }, - ), - ]); + ); + } properties.extend(create_approval_parameters()); let description = if cfg!(windows) { @@ -1068,7 +1079,24 @@ fn create_list_dir_tool() -> ToolSpec { } fn create_js_repl_tool() -> ToolSpec { - const JS_REPL_FREEFORM_GRAMMAR: &str = r#"start: /[\s\S]*/"#; + // Keep JS input freeform, but block the most common malformed payload shapes + // (JSON wrappers, quoted strings, and markdown fences) before they reach the + // runtime `reject_json_or_quoted_source` validation. The API's regex engine + // does not support look-around, so this uses a "first significant token" + // pattern rather than negative lookaheads. + const JS_REPL_FREEFORM_GRAMMAR: &str = r#" +start: pragma_source | plain_source + +pragma_source: PRAGMA_LINE NEWLINE js_source +plain_source: PLAIN_JS_SOURCE + +js_source: JS_SOURCE + +PRAGMA_LINE: /[ \t]*\/\/ codex-js-repl:[^\r\n]*/ +NEWLINE: /\r?\n/ +PLAIN_JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/ +JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/ +"#; ToolSpec::Freeform(FreeformTool { name: "js_repl".to_string(), @@ -1445,7 +1473,10 @@ pub(crate) fn build_specs( builder.push_spec_with_parallel_support(ToolSpec::LocalShell {}, true); } ConfigShellToolType::UnifiedExec => { - builder.push_spec_with_parallel_support(create_exec_command_tool(), true); + builder.push_spec_with_parallel_support( + create_exec_command_tool(config.allow_login_shell), + true, + ); builder.push_spec(create_write_stdin_tool()); builder.register_handler("exec_command", unified_exec_handler.clone()); builder.register_handler("write_stdin", unified_exec_handler); @@ -1454,7 +1485,10 @@ pub(crate) fn build_specs( // Do nothing. } ConfigShellToolType::ShellCommand => { - builder.push_spec_with_parallel_support(create_shell_command_tool(), true); + builder.push_spec_with_parallel_support( + create_shell_command_tool(config.allow_login_shell), + true, + ); } } @@ -1799,7 +1833,7 @@ mod tests { // Build expected from the same helpers used by the builder. let mut expected: BTreeMap = BTreeMap::from([]); for spec in [ - create_exec_command_tool(), + create_exec_command_tool(true), create_write_stdin_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(), @@ -1922,6 +1956,21 @@ mod tests { assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]); } + #[test] + fn js_repl_freeform_grammar_blocks_common_non_js_prefixes() { + let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else { + panic!("js_repl should use a freeform tool spec"); + }; + + assert_eq!(format.syntax, "lark"); + assert!(format.definition.contains("PRAGMA_LINE")); + assert!(format.definition.contains("`[^`]")); + assert!(format.definition.contains("``[^`]")); + assert!(format.definition.contains("PLAIN_JS_SOURCE")); + assert!(format.definition.contains("codex-js-repl:")); + assert!(!format.definition.contains("(?!")); + } + fn assert_model_tools( model_slug: &str, features: &Features, @@ -2758,7 +2807,7 @@ Examples of valid command strings: #[test] fn test_shell_command_tool() { - let tool = super::create_shell_command_tool(); + let tool = super::create_shell_command_tool(true); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 772baab00b..1fbb1e8f6d 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -139,43 +139,6 @@ pub(crate) fn spawn_exit_watcher( }); } -pub(crate) fn spawn_network_denial_watcher( - process: Arc, - session: Arc, - process_id: String, - network_attempt_id: String, -) { - let exit_token = process.cancellation_token(); - tokio::spawn(async move { - let mut poll = tokio::time::interval(Duration::from_millis(100)); - poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - tokio::select! { - _ = exit_token.cancelled() => { - break; - } - _ = poll.tick() => { - if session - .services - .network_approval - .take_user_denial_outcome(&network_attempt_id) - .await - { - process.terminate(); - session - .services - .unified_exec_manager - .release_process_id(&process_id) - .await; - break; - } - } - } - } - }); -} - async fn process_chunk( pending: &mut Vec, transcript: &Arc>, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 4339d25ace..a586572dd1 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -155,7 +155,7 @@ struct ProcessEntry { process_id: String, command: Vec, tty: bool, - network_attempt_id: Option, + network_approval_id: Option, session: Weak, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index f2468583d2..6945316469 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -20,7 +20,6 @@ use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::events::ToolEventStage; use crate::tools::network_approval::DeferredNetworkApproval; -use crate::tools::network_approval::deferred_rejection_message; use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; @@ -44,7 +43,6 @@ use crate::unified_exec::WARNING_UNIFIED_EXEC_PROCESSES; use crate::unified_exec::WriteStdinRequest; use crate::unified_exec::async_watcher::emit_exec_end_for_unified_exec; use crate::unified_exec::async_watcher::spawn_exit_watcher; -use crate::unified_exec::async_watcher::spawn_network_denial_watcher; use crate::unified_exec::async_watcher::start_streaming_output; use crate::unified_exec::clamp_yield_time; use crate::unified_exec::generate_chunk_id; @@ -140,18 +138,18 @@ impl UnifiedExecProcessManager { store.remove(process_id) }; if let Some(entry) = removed { - Self::unregister_network_attempt_for_entry(&entry).await; + Self::unregister_network_approval_for_entry(&entry).await; } } - async fn unregister_network_attempt_for_entry(entry: &ProcessEntry) { - if let Some(attempt_id) = entry.network_attempt_id.as_deref() + async fn unregister_network_approval_for_entry(entry: &ProcessEntry) { + if let Some(network_approval_id) = entry.network_approval_id.as_deref() && let Some(session) = entry.session.upgrade() { session .services .network_approval - .unregister_attempt(attempt_id) + .unregister_call(network_approval_id) .await; } } @@ -248,17 +246,6 @@ impl UnifiedExecProcessManager { .await; self.release_process_id(&request.process_id).await; - if let Some(deferred) = deferred_network_approval.as_ref() - && let Some(message) = - deferred_rejection_message(context.session.as_ref(), deferred).await - { - finish_deferred_network_approval( - context.session.as_ref(), - deferred_network_approval.take(), - ) - .await; - return Err(UnifiedExecError::create_process(message)); - } finish_deferred_network_approval( context.session.as_ref(), deferred_network_approval.take(), @@ -266,27 +253,13 @@ impl UnifiedExecProcessManager { .await; process.check_for_sandbox_denial_with_text(&text).await?; } else { - if let Some(deferred) = deferred_network_approval.as_ref() - && let Some(message) = - deferred_rejection_message(context.session.as_ref(), deferred).await - { - process.terminate(); - finish_deferred_network_approval( - context.session.as_ref(), - deferred_network_approval.take(), - ) - .await; - self.release_process_id(&request.process_id).await; - return Err(UnifiedExecError::create_process(message)); - } - // Long‑lived command: persist the process so write_stdin can reuse // it, and register a background watcher that will emit // ExecCommandEnd when the PTY eventually exits (even if no further // tool calls are made). - let network_attempt_id = deferred_network_approval + let network_approval_id = deferred_network_approval .as_ref() - .map(|deferred| deferred.attempt_id().to_string()); + .map(|deferred| deferred.registration_id().to_string()); self.store_process( Arc::clone(&process), context, @@ -295,7 +268,7 @@ impl UnifiedExecProcessManager { start, process_id, request.tty, - network_attempt_id, + network_approval_id, Arc::clone(&transcript), ) .await; @@ -443,7 +416,7 @@ impl UnifiedExecProcessManager { } }; if let ProcessStatus::Exited { entry, .. } = &status { - Self::unregister_network_attempt_for_entry(entry).await; + Self::unregister_network_approval_for_entry(entry).await; } status } @@ -502,17 +475,16 @@ impl UnifiedExecProcessManager { started_at: Instant, process_id: String, tty: bool, - network_attempt_id: Option, + network_approval_id: Option, transcript: Arc>, ) { - let network_attempt_id_for_watcher = network_attempt_id.clone(); let entry = ProcessEntry { process: Arc::clone(&process), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), tty, - network_attempt_id, + network_approval_id, session: Arc::downgrade(&context.session), last_used: started_at, }; @@ -525,7 +497,7 @@ impl UnifiedExecProcessManager { // prune_processes_if_needed runs while holding process_store; do async // network-approval cleanup only after dropping that lock. if let Some(pruned_entry) = pruned_entry { - Self::unregister_network_attempt_for_entry(&pruned_entry).await; + Self::unregister_network_approval_for_entry(&pruned_entry).await; pruned_entry.process.terminate(); } @@ -550,17 +522,6 @@ impl UnifiedExecProcessManager { transcript, started_at, ); - - if context.turn.config.managed_network_requirements_enabled() - && let Some(network_attempt_id) = network_attempt_id_for_watcher - { - spawn_network_denial_watcher( - Arc::clone(&process), - Arc::clone(&context.session), - process_id, - network_attempt_id, - ); - } } pub(crate) async fn open_session_with_exec_env( @@ -637,7 +598,6 @@ impl UnifiedExecProcessManager { turn: context.turn.as_ref(), call_id: context.call_id.clone(), tool_name: "exec_command".to_string(), - network_attempt_id: None, }; orchestrator .run( @@ -792,7 +752,7 @@ impl UnifiedExecProcessManager { }; for entry in entries { - Self::unregister_network_attempt_for_entry(&entry).await; + Self::unregister_network_approval_for_entry(&entry).await; entry.process.terminate(); } } diff --git a/codex-rs/core/templates/memories/consolidation.md b/codex-rs/core/templates/memories/consolidation.md index de0965082b..91096c1dd3 100644 --- a/codex-rs/core/templates/memories/consolidation.md +++ b/codex-rs/core/templates/memories/consolidation.md @@ -17,7 +17,8 @@ CONTEXT: MEMORY FOLDER STRUCTURE Folder structure (under {{ memory_root }}/): - memory_summary.md - - Always loaded into the system prompt. Must remain tiny and highly navigational. + - Always loaded into the system prompt. Must remain informative and highly navigational, + but still discriminative enough to guide retrieval. - MEMORY.md - Handbook entries. Used to grep for keywords; aggregated insights from rollouts; pointers to rollout summaries if certain past rollouts are very relevant. @@ -40,8 +41,10 @@ GLOBAL SAFETY, HYGIENE, AND NO-FILLER RULES (STRICT) - Evidence-based only: do not invent facts or claim verification that did not happen. - Redact secrets: never store tokens/keys/passwords; replace with [REDACTED_SECRET]. - Avoid copying large tool outputs. Prefer compact summaries + exact error snippets + pointers. -- **No-op is allowed and preferred** when there is no meaningful, reusable learning worth saving. - - If nothing is worth saving, make NO file changes. +- No-op content updates are allowed and preferred when there is no meaningful, reusable + learning worth saving. + - INIT mode: still create minimal required files (`MEMORY.md` and `memory_summary.md`). + - INCREMENTAL UPDATE mode: if nothing is worth saving, make no file changes. ============================================================ WHAT COUNTS AS HIGH-SIGNAL MEMORY @@ -97,7 +100,10 @@ Primary inputs (always read these, if exists): Under `{{ memory_root }}/`: - `raw_memories.md` - mechanical merge of `raw_memories` from Phase 1; - - source of rollout-level metadata needed for MEMORY.md header annotations; + - ordered latest-first; use this recency ordering as a major heuristic when choosing + what to promote, expand, or deprecate; + - source of rollout-level metadata needed for MEMORY.md `### rollout_summary_files` + annotations; you should be able to find `cwd` and `updated_at` there. - `MEMORY.md` - merged memories; produce a lightly clustered version if applicable @@ -123,45 +129,148 @@ Rules: - If there is no meaningful signal to add beyond what already exists, keep outputs minimal. - You should always make sure `MEMORY.md` and `memory_summary.md` exist and are up to date. - Follow the format and schema of the artifacts below. +- Do not target fixed counts (memory blocks, task groups, topics, or bullets). Let the + signal determine the granularity and depth. +- Quality objective: for high-signal task families, `MEMORY.md` should be materially more + useful than `raw_memories.md` while remaining easy to navigate. ============================================================ 1) `MEMORY.md` FORMAT (STRICT) ============================================================ -Clustered schema: ---- -rollout_summary_files: - - () - - () -description: brief description of the shared tasks/outcomes -keywords: k1, k2, k3, ... ---- +`MEMORY.md` is the durable, retrieval-oriented handbook. Each block should be easy to grep +and rich enough to reuse without reopening raw rollout logs. + +Each memory block MUST start with: + +# Task Group: + +scope: + +- `Task Group` is for retrieval. Choose granularity based on memory density: + repo / project / workflow / detail-task family. +- `scope:` is for scanning. Keep it short and operational. + +Body format (strict): + +- Use the task-grouped markdown structure below (headings + bullets). Do not use a flat + bullet dump. +- The header (`# Task Group: ...` + `scope: ...`) is the index. The body contains + task-level detail. +- Every `## Task ` section MUST include task-local rollout files, task-local keywords, + and task-specific learnings. +- Use `-` bullets for lists and learnings. Do not use `*`. +- No bolding text in the memory body. + +Required task-oriented body shape (strict): + +## Task 1: + +task: + +### rollout_summary_files + +- (cwd=, updated_at=, ) + +### keywords + +- + +### learnings + +- +- +- +- cause -> fix> +- +- + +## Task 2: + +task: + +### rollout_summary_files -- - ... -Schema rules (strict): -- Keep entries compact and retrieval-friendly. -- A single note block may correspond to multiple related tasks; aggregate when tasks and lessons align. -- In `rollout_summary_files`, each parenthesized annotation must include - `cwd=` and `updated_at=` copied from that rollout summary metadata. - If missing from an individual rollout summary, recover them from `raw_memories.md`. -- If you need to reference skills, do it in the BODY as bullets, not in the header - (e.g., "- Related skill: skills//SKILL.md"). -- Use lowercase, hyphenated skill folder names. -- Preserve provenance: include the relevant rollout_summary_file(s) for the block. +### keywords -What to write in memory entries: Extract the highest-signal takeaways from the rollout -summaries, especially from "User preferences", "Reusable knowledge", "References", and -"Things that did not work / things that can be improved". -Write what would most help a future agent doing a similar (or adjacent) task: decision -triggers, key steps, proven commands/paths, and failure shields (symptom -> cause -> fix), -plus any stable user preferences. -If a rollout summary contains stable user profile details or preferences that generalize, -capture them here so they're easy to find and can be reflected in memory_summary.md. -The goal of MEMORY.md is to support related-but-not-identical future tasks, so keep -insights slightly more general; when a future task is very similar, expect the agent to -use the rollout summary for full detail. +- ... + +### learnings + +- + +... More `## Task ` sections if needed + +## General Tips + +- [Task 1] +- [Task 1][Task 2] +- + +Schema rules (strict): +- A) Structure and consistency + - Exact block shape: `# Task Group`, `scope:`, one or more `## Task `, and + `## General Tips`. + - Keep all tasks and tips inside the task family implied by the block header. + - Keep entries retrieval-friendly, but not shallow. + - Do not emit placeholder values (`task: task`, `# Task Group: misc`, `scope: general`, etc.). +- B) Task boundaries and clustering + - Primary organization unit is the task (`## Task `), not the rollout file. + - Default mapping: one coherent rollout summary -> one MEMORY block -> one `## Task 1`. + - If a rollout contains multiple distinct tasks, split them into multiple `## Task ` + sections. If those tasks belong to different task families, split into separate + MEMORY blocks (`# Task Group`). + - A MEMORY block may include multiple rollouts only when they belong to the same + task group and the task intent, technical context, and outcome pattern align. + - A single `## Task ` section may cite multiple rollout summaries when they are + iterative attempts or follow-up runs for the same task. + - Do not cluster on keyword overlap alone. + - When in doubt, preserve boundaries (separate tasks/blocks) rather than over-cluster. +- C) Provenance and metadata + - Every `## Task ` section must include `### rollout_summary_files`, `### keywords`, + and `### learnings`. + - `### rollout_summary_files` must be task-local (not a block-wide catch-all list). + - Each rollout annotation must include `cwd=` and `updated_at=`. + If missing from a rollout summary, recover them from `raw_memories.md`. + - Major learnings should be traceable to rollout summaries listed in the same task section. + - Order rollout references by freshness and practical usefulness. +- D) Retrieval and references + - `task:` lines must be specific and searchable. + - `### keywords` should be discriminative and task-local (tool names, error strings, + repo concepts, APIs/contracts). + - Put task-specific detail in `## Task ` and only deduplicated cross-task guidance in + `## General Tips`. + - If you reference skills, do it in body bullets only (for example: + `- Related skill: skills//SKILL.md`). + - Use lowercase, hyphenated skill folder names. +- E) Ordering and conflict handling + - For grouped blocks, order `## Task ` sections by practical usefulness, then recency. + - Treat `updated_at` as a first-class signal: fresher validated evidence usually wins. + - If evidence conflicts and validation is unclear, preserve the uncertainty explicitly. + - In `## General Tips`, cite task references (`[Task 1]`, `[Task 2]`, etc.) when + merging, deduplicating, or resolving evidence. + +What to write: +- Extract the takeaways from rollout summaries and raw_memories, especially sections like + "User preferences", "Reusable knowledge", "References", and "Things that did not work". +- Optimize for future related tasks: decision triggers, validated commands/paths, + verification steps, and failure shields (symptom -> cause -> fix). +- Capture stable user preferences/details that generalize so they can also inform + `memory_summary.md`. +- `MEMORY.md` should support related-but-not-identical tasks: slightly more general than a + rollout summary, but still operational and concrete. +- Use `raw_memories.md` as the routing layer; deep-dive into `rollout_summaries/*.md` when: + - the task is high-value and needs richer detail, + - multiple rollouts overlap and need conflict/staleness resolution, + - raw memory wording is too terse/ambiguous to consolidate confidently, + - you need stronger evidence, validation context, or user feedback. +- Each block should be useful on its own and materially richer than `memory_summary.md`: + - include concrete triggers, commands/paths, and failure shields, + - include outcome-specific notes (what worked, what failed, what remains uncertain), + - include scope boundaries / anti-drift notes when they affect future task success, + - include stale/conflict notes when newer evidence changes prior guidance. ============================================================ 2) `memory_summary.md` FORMAT (STRICT) @@ -210,17 +319,23 @@ For example, include (when known): ## What's in Memory This is a compact index to help future agents quickly find details in `MEMORY.md`, `skills/`, and `rollout_summaries/`. -Organize by topic. Each bullet should include: topic, keywords (used to search over -memory files), and a brief description. +Organize by topic. Each bullet must include: topic, keywords, and a clear description. Ordered by utility - which is the most likely to be useful for a future agent. +Do not target a fixed topic count. Cover the real high-signal areas and omit low-signal noise. +Prefer grouping by task family / workflow intent, not by incidental tools alone. Recommended format: - : , , , ... - - desc: + - desc: Notes: - Do not include large snippets; push details into MEMORY.md and rollout summaries. - Prefer topics/keywords that help a future agent search MEMORY.md efficiently. +- Prefer clear topic taxonomy over verbose drill-down pointers. +- Keep descriptions explicit enough that a future model can decide which keyword cluster + to search first for a new user query. +- Topic descriptions should mention what is inside, when to use it, and what kind of + outcome/procedure depth is available (for example: runbook, diagnostics, reporting, recovery). ============================================================ 3) `skills/` FORMAT (optional) @@ -303,29 +418,41 @@ WORKFLOW - create initial `skills/*` (optional but highly recommended) - write `memory_summary.md` last (highest-signal file) - Use your best efforts to get the most high-quality memory files - - Do not be lazy at browsing files at the INIT phase + - Do not be lazy at browsing files in INIT mode; deep-dive high-value rollouts and + conflicting task families until MEMORY blocks are richer and more useful than raw memories 3) INCREMENTAL UPDATE behavior: - Treat `raw_memories.md` as the primary source of NEW signal. - Read existing memory files first for continuity. - Integrate new signal into existing artifacts by: + - scanning new raw memories in recency order and identifying which existing blocks they should update - updating existing knowledge with better/newer evidence - updating stale or contradicting guidance + - expanding terse old blocks when new summaries/raw memories make the task family clearer - doing light clustering and merging if needed - updating existing skills or adding new skills only when there is clear new reusable procedure - update `memory_summary.md` last to reflect the final state of the memory folder -4) For both modes, update `MEMORY.md` after skill updates: - - add clear **Related skills** pointers in the BODY of corresponding note blocks (do - not change the YAML header schema) +4) Evidence deep-dive rule (both modes): + - `raw_memories.md` is the routing layer, not always the final authority for detail. + - When a task family is important, ambiguous, or duplicated across multiple rollouts, + open the relevant `rollout_summaries/*.md` files and extract richer procedural detail, + validation signals, and user feedback before finalizing `MEMORY.md`. + - Use `updated_at` and validation strength together to resolve stale/conflicting notes. -5) Housekeeping (optional): +5) For both modes, update `MEMORY.md` after skill updates: + - add clear related-skill pointers as plain bullets in the BODY of corresponding task + sections (do not change the `# Task Group` / `scope:` block header format) + +6) Housekeeping (optional): - remove clearly redundant/low-signal rollout summaries - if multiple summaries overlap for the same thread, keep the best one -6) Final pass: +7) Final pass: - remove duplication in memory_summary, skills/, and MEMORY.md - ensure any referenced skills/summaries actually exist + - ensure MEMORY blocks and "What's in Memory" use a consistent task-oriented taxonomy + - ensure recent important task families are easy to find (description + keywords + topic wording) - if there is no net-new or higher-quality signal to add, keep changes minimal (no churn for its own sake). @@ -341,6 +468,6 @@ Use `rg` for fast retrieval while consolidating: - Search durable notes: `rg -n -i "" "{{ memory_root }}/MEMORY.md"` - Search across memory tree: - `rg -n -i "" "{{ memory_root }}" | head -n 50` + `rg -n -i "" "{{ memory_root }}" | head -n 100` - Locate rollout summary files: - `rg --files "{{ memory_root }}/rollout_summaries" | head -n 200` + `rg --files "{{ memory_root }}/rollout_summaries" | head -n 400` diff --git a/codex-rs/core/templates/memories/read_path.md b/codex-rs/core/templates/memories/read_path.md index 6ef09247b2..ec123fb551 100644 --- a/codex-rs/core/templates/memories/read_path.md +++ b/codex-rs/core/templates/memories/read_path.md @@ -25,8 +25,8 @@ again) - {{ base_path }}/rollout_summaries/ (per-rollout recaps + evidence snippets) Quick memory pass (when applicable): -1) Skim the MEMORY_SUMMARY included below and extract a few task-relevant -keywords (for example repo/module names, error strings, etc.). +1) Skim the MEMORY_SUMMARY included below and extract task-relevant topics and +keywords (for example repo/module names, workflows, error strings, etc.). 2) Search {{ base_path }}/MEMORY.md for those keywords, and for any referenced rollout summary files and skills. 3) If relevant rollout summary files and skills exist, open matching files diff --git a/codex-rs/core/templates/memories/stage_one_system.md b/codex-rs/core/templates/memories/stage_one_system.md index 38031e8d2f..692243c900 100644 --- a/codex-rs/core/templates/memories/stage_one_system.md +++ b/codex-rs/core/templates/memories/stage_one_system.md @@ -109,6 +109,16 @@ Typical real-world signals (use as examples when analyzing the rollout): 3) User keeps iterating on the same task: - Requests for fixes/revisions on the same artifact usually mean partial, not success. - Requesting a restart or pointing out contradictions often indicates fail. +4) Last task in the rollout: + - Treat the final task more conservatively than earlier tasks. + - If there is no explicit user feedback or environment validation for the final task, + prefer `uncertain` (or `partial` if there was obvious progress but no confirmation). + - For non-final tasks, switching to another task without unresolved blockers is a stronger + positive signal. + +Signal priority: +- Explicit user feedback and explicit environment/test/tool validation outrank all heuristics. +- If heuristic signals conflict with explicit feedback, follow explicit feedback. Fallback heuristics: - Success: explicit "done/works", tests pass, correct artifact produced, user @@ -152,6 +162,8 @@ This summary should be very comprehensive and detailed, because it will be furth distilled into MEMORY.md and memory_summary.md. There is no strict size limit, and you should feel free to list a lot of points here as long as they are helpful. +Do not target fixed counts (tasks, bullets, references, or topics). Let the rollout's +signal density decide how much to write. Instructional notes in angle brackets are guidance only; do not include them verbatim in the rollout summary. Template (items are flexible; include only what is useful): @@ -170,7 +182,7 @@ User preferences: -## Task : +## Task : Outcome: Key steps: @@ -188,9 +200,9 @@ Things that did not work / things that can be improved: user approval."> - ... -Reusable knowledge: +Reusable knowledge: - - : - [3] final verification evidence or explicit user feedback -## Task (if there are multiple tasks): +## Task (if there are multiple tasks): ... +Task section quality bar (strict): +- Each task section should be detailed enough that other agent can understand it without + reopening the raw rollout. +- For each task, cover the following when evidence exists (and state uncertainty when it + does not): + - what the user wanted / expected, + - what was attempted and what actually worked, + - what failed or remained uncertain and why, + - how the outcome was validated (user feedback, tests, tool output, or explicit lack of validation), + - reusable procedure/checklist and failure shields, + - concrete artifacts/commands/paths/error signatures that future agents can reuse. +- Do not be terse in task sections. Rich, evidence-backed task summaries are preferred + over compact summaries. + ============================================================ `raw_memory` FORMAT (STRICT) ============================================================ The schema is below. --- -rollout_summary_file: -description: brief description of the task and outcome +description: concise but information-dense description of the primary task(s), outcome, and highest-value takeaway +task: +task_group: +task_outcome: keywords: k1, k2, k3, ... --- -- + +Then write task-grouped body content (required): +### Task 1: +task: +task_group: +task_outcome: +- - ... +### Task 2: (if needed) +task: ... +task_group: ... +task_outcome: ... +- ... + +Preferred task-block body shape (strongly recommended): +- `### Task ` blocks should preserve task-specific retrieval signal and consolidation-ready detail. +- Within each task block, include bullets that explicitly cover (when applicable): + - user goal / expected outcome, + - what worked (key steps, commands, code paths, artifacts), + - what did not work or drifted (and what pivot worked), + - validation state (user confirmation, tests, runtime checks, or missing validation), + - reusable procedure/checklist and failure shields, + - high-signal evidence pointers (error strings, commands, files, IDs, URLs, etc.). +- Prefer labeled bullets when useful (for example: `- User goal: ...`, `- Validation: ...`, + `- Failure shield: ...`) so Phase 2 can retrieve and consolidate faster. + +Task grouping rules (strict): +- Every distinct user task in the thread must appear as its own `### Task ` block. +- Do not merge unrelated tasks into one block just because they happen in the same thread. +- If a thread contains only one task, keep exactly one task block. +- For each task block, keep the outcome tied to evidence relevant to that task. +- If a thread has partially related tasks, prefer splitting into separate task blocks and + linking them through shared keywords rather than merging. + What to write in memory entries: Extract useful takeaways from the rollout summaries, especially from "User preferences", "Reusable knowledge", "References", and "Things that did not work / things that can be improved". @@ -249,10 +309,17 @@ Write what would help a future agent doing a similar (or adjacent) task: decisio triggers, key steps, proven commands/paths, and failure shields (symptom -> cause -> fix), plus any stable user preferences. If a rollout summary contains stable user profile details or preferences that generalize, -capture them here so they're easy to find and can be reflected in memory_summary.md. +capture them here so they're easy to find without checking rollout summary. The goal is to support related-but-not-identical future tasks, so keep insights slightly more general; when a future task is very similar, expect the agent to use the rollout summary for full detail. +For each task block, include enough detail to be useful for future agent reference: +- what the user wanted and expected, +- what was attempted and what actually worked, +- what failed or remained uncertain and why, +- what evidence validates the outcome (user feedback, environment/test feedback, or lack of both), +- reusable procedures/checklists and failure shields that should survive future similar tasks, +- artifacts and retrieval handles (commands, file paths, error strings, IDs) that make the task easy to rediscover. ============================================================ @@ -264,4 +331,6 @@ WORKFLOW 1) Triage outcome using the common rules. 2) Read the rollout carefully (do not miss user messages/tool calls/outputs). 3) Return `rollout_summary`, `rollout_slug`, and `raw_memory`, valid JSON only. - No markdown wrapper, no prose outside JSON. \ No newline at end of file + No markdown wrapper, no prose outside JSON. + +- Do not be terse in task sections. Include validation signal, failure mode, and reusable procedure per task when available. diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 1c7c578cae..736e5f50fe 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -118,6 +118,34 @@ async fn responses_websocket_preconnect_reuses_connection() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_reuses_connection_after_session_drop() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("resp-1"), ev_completed("resp-1")], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let harness = websocket_harness(&server).await; + let prompt_one = prompt_with_input(vec![message_item("hello")]); + let prompt_two = prompt_with_input(vec![message_item("again")]); + + { + let mut client_session = harness.client.new_session(); + stream_until_complete(&mut client_session, &harness, &prompt_one).await; + } + + let mut client_session = harness.client.new_session(); + stream_until_complete(&mut client_session, &harness, &prompt_two).await; + + assert_eq!(server.handshakes().len(), 1); + assert_eq!(server.single_connection().len(), 2); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index c857affd2b..c3cb15875b 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -37,7 +37,6 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result Option { + let quoted_prefix = format!("{key}=\""); + if let Some(start) = line.find("ed_prefix) { + let value_start = start + quoted_prefix.len(); + if let Some(end_rel) = line[value_start..].find('"') { + return Some(line[value_start..value_start + end_rel].to_string()); + } + } + + let bare_prefix = format!("{key}="); + for token in line.split_whitespace() { + let trimmed = token.trim_end_matches(','); + if let Some(value) = trimmed.strip_prefix(&bare_prefix) { + return Some(value.to_string()); + } + } + + None +} + +fn assert_empty_mcp_tool_fields(line: &str) -> Result<(), String> { + let mcp_server = extract_log_field(line, "mcp_server") + .ok_or_else(|| "missing mcp_server field".to_string())?; + if !mcp_server.is_empty() { + return Err(format!("expected empty mcp_server, got {mcp_server}")); + } + + let mcp_server_origin = extract_log_field(line, "mcp_server_origin") + .ok_or_else(|| "missing mcp_server_origin field".to_string())?; + if !mcp_server_origin.is_empty() { + return Err(format!( + "expected empty mcp_server_origin, got {mcp_server_origin}" + )); + } + + Ok(()) +} + +#[test] +fn extract_log_field_handles_empty_bare_values() { + let line = "event.name=\"codex.tool_result\" mcp_server= mcp_server_origin="; + assert_eq!(extract_log_field(line, "mcp_server"), Some(String::new())); + assert_eq!( + extract_log_field(line, "mcp_server_origin"), + Some(String::new()) + ); +} + +#[test] +fn extract_log_field_does_not_confuse_similar_keys() { + let line = "event.name=\"codex.tool_result\" mcp_server_origin=stdio"; + assert_eq!(extract_log_field(line, "mcp_server"), None); + assert_eq!( + extract_log_field(line, "mcp_server_origin"), + Some("stdio".to_string()) + ); +} + #[tokio::test] #[traced_test] async fn responses_api_emits_api_request_event() { @@ -687,6 +745,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { if !line.contains("success=false") { return Err("missing success field".to_string()); } + assert_empty_mcp_tool_fields(line)?; Ok(()) }); @@ -756,6 +815,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { if !line.contains("success=false") { return Err("missing success field".to_string()); } + assert_empty_mcp_tool_fields(line)?; Ok(()) }); @@ -828,6 +888,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() if !line.contains("success=false") { return Err("missing success field".to_string()); } + assert_empty_mcp_tool_fields(line)?; Ok(()) }); @@ -899,6 +960,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { if !line.contains("success=false") { return Err("missing success field".to_string()); } + assert_empty_mcp_tool_fields(line)?; Ok(()) }); diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 2ca7aeeefd..dfff16ea03 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -66,6 +66,8 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: None, @@ -107,6 +109,8 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 1c9c3adf7b..113776b81f 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -20,6 +20,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -872,7 +873,7 @@ async fn start_responses_server_with_sse( sse_raw: &str, expected_requests: usize, ) -> (MockServer, ResponseMock) { - let server = MockServer::start().await; + let server = start_mock_server().await; let sse = load_sse_fixture_with_id_from_str(sse_raw, &Uuid::new_v4().to_string()); let responses = vec![sse; expected_requests]; let request_log = mount_sse_sequence(&server, responses).await; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index b37683a430..8f0e5834e9 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -123,6 +123,8 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::default(), + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: Some(dynamic_tools_for_hook), diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index 6f9e4fbc69..f66e92dc8b 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -93,7 +93,6 @@ impl EscalateServer { expiration: ExecExpiration::Cancellation(cancel_rx), env, network: None, - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, justification: None, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index f77a4ec16a..6583c19f9e 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -632,6 +632,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { new_thread_id, prompt, status, + .. }) => { let success = new_thread_id.is_some() && !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; @@ -669,6 +670,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { receiver_thread_id, prompt, status, + .. }) => { let success = !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; @@ -687,6 +689,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, receiver_thread_ids, call_id, + .. }) => { ts_msg!( self, @@ -703,6 +706,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, call_id, statuses, + .. }) => { if statuses.is_empty() { ts_msg!( @@ -755,6 +759,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, receiver_thread_id, status, + .. }) => { let success = !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 35cb962ee7..c04fcdb532 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -572,6 +572,8 @@ fn collab_spawn_begin_and_end_emit_item_events() { call_id: "call-10".to_string(), sender_thread_id, new_thread_id: Some(new_thread_id), + new_agent_nickname: None, + new_agent_role: None, prompt: prompt.clone(), status: AgentStatus::Running, }), @@ -623,6 +625,7 @@ fn collab_wait_end_without_begin_synthesizes_failed_item() { EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { sender_thread_id, call_id: "call-11".to_string(), + agent_statuses: Vec::new(), statuses: statuses.clone(), }), ); diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index adda7173a1..a4fe6cc185 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -224,7 +224,7 @@ impl CodexLogSnapshot { classification: &str, reason: Option<&str>, include_logs: bool, - rollout_path: Option<&std::path::Path>, + extra_log_files: &[PathBuf], session_source: Option, ) -> Result<()> { use std::collections::BTreeMap; @@ -317,11 +317,22 @@ impl CodexLogSnapshot { })); } - if let Some((path, data)) = rollout_path.and_then(|p| fs::read(p).ok().map(|d| (p, d))) { + for path in extra_log_files { + let data = match fs::read(path) { + Ok(data) => data, + Err(err) => { + tracing::warn!( + path = %path.display(), + error = %err, + "failed to read log attachment; skipping" + ); + continue; + } + }; let fname = path .file_name() .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "rollout.jsonl".to_string()); + .unwrap_or_else(|| "extra-log.log".to_string()); let content_type = "text/plain".to_string(); envelope.add_item(EnvelopeItem::Attachment(Attachment { buffer: data, diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 668c65e9c9..9a1b82413e 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -77,7 +77,6 @@ async fn run_cmd_result_with_writable_roots( expiration: timeout_ms.into(), env: create_env_from_core_vars(), network: None, - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, justification: None, @@ -322,7 +321,6 @@ async fn assert_network_blocked(cmd: &[&str]) { expiration: NETWORK_TIMEOUT_MS.into(), env: create_env_from_core_vars(), network: None, - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, justification: None, diff --git a/codex-rs/login/BUILD.bazel b/codex-rs/login/BUILD.bazel index 127cd60fd9..fe5866577f 100644 --- a/codex-rs/login/BUILD.bazel +++ b/codex-rs/login/BUILD.bazel @@ -3,5 +3,8 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "login", crate_name = "codex_login", - compile_data = ["src/assets/success.html"], + compile_data = [ + "src/assets/error.html", + "src/assets/success.html", + ], ) diff --git a/codex-rs/login/src/assets/error.html b/codex-rs/login/src/assets/error.html new file mode 100644 index 0000000000..96c1eb303c --- /dev/null +++ b/codex-rs/login/src/assets/error.html @@ -0,0 +1,122 @@ + + + + + Codex Sign-in Error + + + + +
+
+
+ +
Codex login
+
+ +

__ERROR_TITLE__

+

__ERROR_MESSAGE__

+ +
+
+ Error code + __ERROR_CODE__ +
+
+ Details + __ERROR_DESCRIPTION__ +
+
+ +

__ERROR_HELP__

+
+
+ + diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index d97de008e6..53b4ad3601 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -1,3 +1,6 @@ +//! Local OAuth callback server for CLI login. +//! +//! This module runs the short-lived localhost server used by interactive sign-in. use std::io::Cursor; use std::io::Read; use std::io::Write; @@ -32,6 +35,7 @@ use tiny_http::StatusCode; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; +/// Options for launching the local login callback server. #[derive(Debug, Clone)] pub struct ServerOptions { pub codex_home: PathBuf, @@ -45,6 +49,7 @@ pub struct ServerOptions { } impl ServerOptions { + /// Creates a server configuration with the default issuer and port. pub fn new( codex_home: PathBuf, client_id: String, @@ -64,6 +69,7 @@ impl ServerOptions { } } +/// Handle for a running login callback server. pub struct LoginServer { pub auth_url: String, pub actual_port: u16, @@ -72,32 +78,38 @@ pub struct LoginServer { } impl LoginServer { + /// Waits for the login callback loop to finish. pub async fn block_until_done(self) -> io::Result<()> { self.server_handle .await .map_err(|err| io::Error::other(format!("login server thread panicked: {err:?}")))? } + /// Requests shutdown of the callback server. pub fn cancel(&self) { self.shutdown_handle.shutdown(); } + /// Returns a cloneable cancel handle for the running server. pub fn cancel_handle(&self) -> ShutdownHandle { self.shutdown_handle.clone() } } +/// Handle used to signal the login server loop to exit. #[derive(Clone, Debug)] pub struct ShutdownHandle { shutdown_notify: Arc, } impl ShutdownHandle { + /// Signals the login loop to terminate. pub fn shutdown(&self) { self.shutdown_notify.notify_waiters(); } } +/// Starts a local callback server and returns the browser auth URL. pub fn run_login_server(opts: ServerOptions) -> io::Result { let pkce = generate_pkce(); let state = opts.force_state.clone().unwrap_or_else(generate_state); @@ -207,6 +219,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { }) } +/// Internal callback handling outcome. enum HandledRequest { Response(Response>>), RedirectWithHeader(Header), @@ -245,11 +258,25 @@ async fn process_request( Response::from_string("State mismatch").with_status_code(400), ); } + if let Some(error_code) = params.get("error") { + let error_description = params.get("error_description").map(String::as_str); + let message = oauth_callback_error_message(error_code, error_description); + eprintln!("OAuth callback error: {message}"); + return login_error_response( + &message, + io::ErrorKind::PermissionDenied, + Some(error_code), + error_description, + ); + } let code = match params.get("code") { Some(c) if !c.is_empty() => c.clone(), _ => { - return HandledRequest::Response( - Response::from_string("Missing authorization code").with_status_code(400), + return login_error_response( + "Missing authorization code. Sign-in could not be completed.", + io::ErrorKind::InvalidData, + Some("missing_authorization_code"), + None, ); } }; @@ -263,7 +290,12 @@ async fn process_request( &tokens.id_token, ) { eprintln!("Workspace restriction error: {message}"); - return login_error_response(&message); + return login_error_response( + &message, + io::ErrorKind::PermissionDenied, + Some("workspace_restriction"), + None, + ); } // Obtain API key via token-exchange and persist let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) @@ -280,9 +312,11 @@ async fn process_request( .await { eprintln!("Persist error: {err}"); - return HandledRequest::Response( - Response::from_string(format!("Unable to persist auth file: {err}")) - .with_status_code(500), + return login_error_response( + "Sign-in completed but credentials could not be saved locally.", + io::ErrorKind::Other, + Some("persist_failed"), + Some(&err.to_string()), ); } @@ -294,16 +328,21 @@ async fn process_request( ); match tiny_http::Header::from_bytes(&b"Location"[..], success_url.as_bytes()) { Ok(header) => HandledRequest::RedirectWithHeader(header), - Err(_) => HandledRequest::Response( - Response::from_string("Internal Server Error").with_status_code(500), + Err(_) => login_error_response( + "Sign-in completed but redirecting back to Codex failed.", + io::ErrorKind::Other, + Some("redirect_failed"), + None, ), } } Err(err) => { eprintln!("Token exchange error: {err}"); - HandledRequest::Response( - Response::from_string(format!("Token exchange failed: {err}")) - .with_status_code(500), + login_error_response( + &format!("Token exchange failed: {err}"), + io::ErrorKind::Other, + Some("token_exchange_failed"), + None, ) } } @@ -486,12 +525,14 @@ fn bind_server(port: u16) -> io::Result { } } +/// Tokens returned by the OAuth authorization-code exchange. pub(crate) struct ExchangedTokens { pub id_token: String, pub access_token: String, pub refresh_token: String, } +/// Exchanges an authorization code for tokens. pub(crate) async fn exchange_code_for_tokens( issuer: &str, client_id: &str, @@ -521,10 +562,12 @@ pub(crate) async fn exchange_code_for_tokens( .await .map_err(io::Error::other)?; - if !resp.status().is_success() { + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.map_err(io::Error::other)?; + let detail = parse_token_endpoint_error(&body); return Err(io::Error::other(format!( - "token endpoint returned status {}", - resp.status() + "token endpoint returned status {status}: {detail}" ))); } @@ -536,6 +579,7 @@ pub(crate) async fn exchange_code_for_tokens( }) } +/// Persists exchanged credentials using the configured local auth store. pub(crate) async fn persist_tokens_async( codex_home: &Path, api_key: Option, @@ -650,6 +694,7 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { serde_json::Map::new() } +/// Validates the ID token against an optional workspace restriction. pub(crate) fn ensure_workspace_allowed( expected: Option<&str>, id_token: &str, @@ -670,23 +715,133 @@ pub(crate) fn ensure_workspace_allowed( } } -// Respond to the oauth server with an error so the code becomes unusable by anybody else. -fn login_error_response(message: &str) -> HandledRequest { +/// Builds a terminal callback response for login failures. +fn login_error_response( + message: &str, + kind: io::ErrorKind, + error_code: Option<&str>, + error_description: Option<&str>, +) -> HandledRequest { let mut headers = Vec::new(); - if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..]) - { + if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { headers.push(header); } + let body = render_login_error_page(message, error_code, error_description); HandledRequest::ResponseAndExit { headers, - body: message.as_bytes().to_vec(), - result: Err(io::Error::new( - io::ErrorKind::PermissionDenied, - message.to_string(), - )), + body, + result: Err(io::Error::new(kind, message.to_string())), } } +/// Returns true when the OAuth callback represents a missing Codex entitlement. +fn is_missing_codex_entitlement_error(error_code: &str, error_description: Option<&str>) -> bool { + error_code == "access_denied" + && error_description.is_some_and(|description| { + description + .to_ascii_lowercase() + .contains("missing_codex_entitlement") + }) +} + +/// Converts OAuth callback errors into a user-facing message. +fn oauth_callback_error_message(error_code: &str, error_description: Option<&str>) -> String { + if is_missing_codex_entitlement_error(error_code, error_description) { + return "Codex is not enabled for your workspace. Contact your workspace administrator to request access to Codex.".to_string(); + } + + if let Some(description) = error_description + && !description.trim().is_empty() + { + return format!("Sign-in failed: {description}"); + } + + format!("Sign-in failed: {error_code}") +} + +/// Extracts a readable error from token endpoint responses. +fn parse_token_endpoint_error(body: &str) -> String { + let trimmed = body.trim(); + if trimmed.is_empty() { + return "unknown error".to_string(); + } + + let parsed = serde_json::from_str::(trimmed).ok(); + if let Some(json) = parsed { + if let Some(description) = json.get("error_description").and_then(JsonValue::as_str) + && !description.trim().is_empty() + { + return description.to_string(); + } + if let Some(error_obj) = json.get("error") + && let Some(message) = error_obj.get("message").and_then(JsonValue::as_str) + && !message.trim().is_empty() + { + return message.to_string(); + } + if let Some(error_code) = json.get("error").and_then(JsonValue::as_str) + && !error_code.trim().is_empty() + { + return error_code.to_string(); + } + } + + trimmed.to_string() +} + +/// Renders the branded error page used by callback failures. +fn render_login_error_page( + message: &str, + error_code: Option<&str>, + error_description: Option<&str>, +) -> Vec { + let template = include_str!("assets/error.html"); + let code = error_code.unwrap_or("unknown_error"); + let (title, display_message, display_description, help_text) = + if is_missing_codex_entitlement_error(code, error_description) { + ( + "You do not have access to Codex".to_string(), + "This account is not currently authorized to use Codex in this workspace." + .to_string(), + "Contact your workspace administrator to request access to Codex.".to_string(), + "Contact your workspace administrator to get access to Codex, then return to Codex and try again." + .to_string(), + ) + } else { + ( + "Sign-in could not be completed".to_string(), + message.to_string(), + error_description.unwrap_or(message).to_string(), + "Return to Codex to retry, switch accounts, or contact your workspace admin if access is restricted." + .to_string(), + ) + }; + template + .replace("__ERROR_TITLE__", &html_escape(&title)) + .replace("__ERROR_MESSAGE__", &html_escape(&display_message)) + .replace("__ERROR_CODE__", &html_escape(code)) + .replace("__ERROR_DESCRIPTION__", &html_escape(&display_description)) + .replace("__ERROR_HELP__", &html_escape(&help_text)) + .into_bytes() +} + +/// Escapes error strings before inserting them into HTML. +fn html_escape(input: &str) -> String { + let mut escaped = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(ch), + } + } + escaped +} + +/// Exchanges an authenticated ID token for an API-key style access token. pub(crate) async fn obtain_api_key( issuer: &str, client_id: &str, diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 73cb8bd420..cdd4019f77 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -255,6 +255,152 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { Ok(()) } +#[tokio::test] +async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + + let tmp = tempdir()?; + let codex_home = tmp.path().to_path_buf(); + let state = "state-entitlement".to_string(); + + let opts = ServerOptions { + codex_home: codex_home.clone(), + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some(state.clone()), + forced_chatgpt_workspace_id: None, + }; + let server = run_login_server(opts)?; + let login_port = server.actual_port; + + let client = reqwest::Client::new(); + let url = format!( + "http://127.0.0.1:{login_port}/auth/callback?state={state}&error=access_denied&error_description=missing_codex_entitlement" + ); + let resp = client.get(&url).send().await?; + assert!(resp.status().is_success()); + let body = resp.text().await?; + assert!( + body.contains("You do not have access to Codex"), + "error body should clearly explain the Codex access denial" + ); + assert!( + body.contains("Contact your workspace administrator"), + "error body should tell the user how to get access" + ); + assert!( + body.contains("access_denied"), + "error body should still include the oauth error code" + ); + assert!( + !body.contains("missing_codex_entitlement"), + "known entitlement errors should be mapped to user-facing copy" + ); + + let result = server.block_until_done().await; + assert!(result.is_err(), "login should fail for access_denied"); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert!( + err.to_string() + .contains("Contact your workspace administrator"), + "terminal error should also tell the user what to do next" + ); + + let auth_path = codex_home.join("auth.json"); + assert!( + !auth_path.exists(), + "auth.json should not be written when oauth callback is denied" + ); + + Ok(()) +} + +#[tokio::test] +async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + + let tmp = tempdir()?; + let codex_home = tmp.path().to_path_buf(); + let state = "state-generic-denial".to_string(); + + let opts = ServerOptions { + codex_home: codex_home.clone(), + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some(state.clone()), + forced_chatgpt_workspace_id: None, + }; + let server = run_login_server(opts)?; + let login_port = server.actual_port; + + let client = reqwest::Client::new(); + let url = format!( + "http://127.0.0.1:{login_port}/auth/callback?state={state}&error=access_denied&error_description=some_other_reason" + ); + let resp = client.get(&url).send().await?; + assert!(resp.status().is_success()); + let body = resp.text().await?; + assert!( + body.contains("Sign-in could not be completed"), + "generic oauth denial should use the generic error page title" + ); + assert!( + body.contains("Sign-in failed: some_other_reason"), + "generic oauth denial should preserve the oauth error details" + ); + assert!( + body.contains("Return to Codex to retry"), + "generic oauth denial should keep the generic help text" + ); + assert!( + body.contains("access_denied"), + "generic oauth denial should include the oauth error code" + ); + assert!( + body.contains("some_other_reason"), + "generic oauth denial should include the oauth error description" + ); + assert!( + !body.contains("You do not have access to Codex"), + "generic oauth denial should not show the entitlement-specific title" + ); + assert!( + !body.contains("get access to Codex"), + "generic oauth denial should not show the entitlement-specific admin guidance" + ); + + let result = server.block_until_done().await; + assert!(result.is_err(), "login should fail for access_denied"); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert!( + err.to_string() + .contains("Sign-in failed: some_other_reason"), + "terminal error should preserve generic oauth details" + ); + + let auth_path = codex_home.join("auth.json"); + assert!( + !auth_path.exists(), + "auth.json should not be written when oauth callback is denied" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index cbcde383b2..48a75c288d 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -base64 = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-utils-absolute-path = { workspace = true } codex-utils-rustls-provider = { workspace = true } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 925d7147bc..198dbbd617 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -47,6 +47,9 @@ allow_local_binding = true # macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. allow_unix_sockets = ["/tmp/example.sock"] +# DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any +# absolute socket path from `x-unix-socket`. +dangerously_allow_all_unix_sockets = false ``` ### 2) Run the proxy @@ -116,8 +119,9 @@ let handle = proxy.run().await?; handle.shutdown().await?; ``` -When unix socket proxying is enabled, HTTP/admin bind overrides are still clamped to loopback -to avoid turning the proxy into a remote bridge to local daemons. +When unix socket proxying is enabled (`allow_unix_sockets` or +`dangerously_allow_all_unix_sockets`), HTTP/admin bind overrides are still clamped to loopback to +avoid turning the proxy into a remote bridge to local daemons. ### Policy hook (exec-policy mapping) @@ -176,6 +180,8 @@ what it can reasonably guarantee. `dangerously_allow_non_loopback_proxy` - when unix socket proxying is enabled, both listeners are forced to loopback to avoid turning the proxy into a remote bridge into local daemons. +- `dangerously_allow_all_unix_sockets = true` bypasses the unix socket allowlist entirely (still + macOS-only and absolute-path-only). Use only in tightly controlled environments. - `enabled` is enforced at runtime; when false the proxy no-ops and does not bind listeners. Limitations: diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 31197a1c96..3284822232 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -1,10 +1,12 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; use std::net::IpAddr; use std::net::SocketAddr; +use std::path::Path; use tracing::warn; use url::Url; @@ -33,6 +35,8 @@ pub struct NetworkProxySettings { #[serde(default)] pub dangerously_allow_non_loopback_admin: bool, #[serde(default)] + pub dangerously_allow_all_unix_sockets: bool, + #[serde(default)] pub mode: NetworkMode, #[serde(default)] pub allowed_domains: Vec, @@ -55,6 +59,7 @@ impl Default for NetworkProxySettings { allow_upstream_proxy: true, dangerously_allow_non_loopback_proxy: false, dangerously_allow_non_loopback_admin: false, + dangerously_allow_all_unix_sockets: false, mode: NetworkMode::default(), allowed_domains: Vec::new(), denied_domains: Vec::new(), @@ -136,7 +141,7 @@ pub(crate) fn clamp_bind_addrs( cfg.dangerously_allow_non_loopback_admin, "admin API", ); - if cfg.allow_unix_sockets.is_empty() { + if cfg.allow_unix_sockets.is_empty() && !cfg.dangerously_allow_all_unix_sockets { return (http_addr, socks_addr, admin_addr); } @@ -172,7 +177,49 @@ pub struct RuntimeConfig { pub admin_addr: SocketAddr, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct UnixStyleAbsolutePath(String); + +impl UnixStyleAbsolutePath { + fn parse(value: &str) -> Option { + value.starts_with('/').then(|| Self(value.to_string())) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ValidatedUnixSocketPath { + Native(AbsolutePathBuf), + UnixStyleAbsolute(UnixStyleAbsolutePath), +} + +impl ValidatedUnixSocketPath { + pub(crate) fn parse(socket_path: &str) -> Result { + let path = Path::new(socket_path); + if path.is_absolute() { + let path = AbsolutePathBuf::from_absolute_path(path) + .with_context(|| format!("failed to normalize unix socket path {socket_path:?}"))?; + return Ok(Self::Native(path)); + } + + if let Some(path) = UnixStyleAbsolutePath::parse(socket_path) { + return Ok(Self::UnixStyleAbsolute(path)); + } + + bail!("expected an absolute path, got {socket_path:?}"); + } +} + +pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> { + for (index, socket_path) in cfg.network.allow_unix_sockets.iter().enumerate() { + ValidatedUnixSocketPath::parse(socket_path) + .with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?; + } + Ok(()) +} + pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result { + validate_unix_socket_allowlist_paths(cfg)?; + let http_addr = resolve_addr(&cfg.network.proxy_url, 3128) .with_context(|| format!("invalid network.proxy_url: {}", cfg.network.proxy_url))?; let socks_addr = resolve_addr(&cfg.network.socks_url, 8081) @@ -340,6 +387,7 @@ mod tests { allow_upstream_proxy: true, dangerously_allow_non_loopback_proxy: false, dangerously_allow_non_loopback_admin: false, + dangerously_allow_all_unix_sockets: false, mode: NetworkMode::Full, allowed_domains: Vec::new(), denied_domains: Vec::new(), @@ -526,4 +574,61 @@ mod tests { assert_eq!(socks_addr, "127.0.0.1:8081".parse::().unwrap()); assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); } + + #[test] + fn clamp_bind_addrs_forces_loopback_when_all_unix_sockets_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + dangerously_allow_all_unix_sockets: true, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let socks_addr = "0.0.0.0:8081".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, socks_addr, admin_addr) = + clamp_bind_addrs(http_addr, socks_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "127.0.0.1:3128".parse::().unwrap()); + assert_eq!(socks_addr, "127.0.0.1:8081".parse::().unwrap()); + assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); + } + + #[test] + fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() { + let cfg = NetworkProxyConfig { + network: NetworkProxySettings { + allow_unix_sockets: vec!["relative.sock".to_string()], + ..NetworkProxySettings::default() + }, + }; + + let err = match resolve_runtime(&cfg) { + Ok(runtime) => panic!( + "relative allow_unix_sockets should fail, but resolve_runtime succeeded: {:?}", + runtime.http_addr + ), + Err(err) => err, + }; + assert!( + err.to_string().contains("network.allow_unix_sockets[0]"), + "error should point at the invalid allow_unix_sockets entry: {err:#}" + ); + } + + #[test] + fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() { + let cfg = NetworkProxyConfig { + network: NetworkProxySettings { + allow_unix_sockets: vec!["/private/tmp/example.sock".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!( + resolve_runtime(&cfg).is_ok(), + "unix-style absolute allow_unix_sockets entry should be accepted" + ); + } } diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index 7be1b81f47..7ea88ba34c 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -1,5 +1,4 @@ use crate::config::NetworkMode; -use crate::metadata::attempt_id_from_proxy_authorization; use crate::network_policy::NetworkDecision; use crate::network_policy::NetworkDecisionSource; use crate::network_policy::NetworkPolicyDecider; @@ -160,8 +159,6 @@ async fn http_connect_accept( } let client = client_addr(&req); - let network_attempt_id = request_network_attempt_id(&req); - let enabled = app_state .enabled() .await @@ -188,7 +185,6 @@ async fn http_connect_accept( method: Some("CONNECT".to_string()), command: None, exec_policy_hint: None, - attempt_id: network_attempt_id.clone(), }); match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { @@ -213,7 +209,6 @@ async fn http_connect_accept( method: Some("CONNECT".to_string()), mode: None, protocol: "http-connect".to_string(), - attempt_id: network_attempt_id.clone(), decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(authority.port), @@ -255,7 +250,6 @@ async fn http_connect_accept( method: Some("CONNECT".to_string()), mode: Some(NetworkMode::Limited), protocol: "http-connect".to_string(), - attempt_id: network_attempt_id, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(authority.port), @@ -374,8 +368,6 @@ async fn http_plain_proxy( } }; let client = client_addr(&req); - let network_attempt_id = request_network_attempt_id(&req); - let method_allowed = match app_state .method_allowed(req.method().as_str()) .await @@ -386,8 +378,8 @@ async fn http_plain_proxy( }; // `x-unix-socket` is an escape hatch for talking to local daemons. We keep it tightly scoped: - // macOS-only + explicit allowlist, to avoid turning the proxy into a general local capability - // escalation mechanism. + // macOS-only + explicit allowlist by default, to avoid turning the proxy into a general local + // capability escalation mechanism. if let Some(unix_socket_header) = req.headers().get("x-unix-socket") { let socket_path = match unix_socket_header.to_str() { Ok(value) => value.to_string(), @@ -504,7 +496,6 @@ async fn http_plain_proxy( method: Some(req.method().as_str().to_string()), command: None, exec_policy_hint: None, - attempt_id: network_attempt_id.clone(), }); match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { @@ -529,7 +520,6 @@ async fn http_plain_proxy( method: Some(req.method().as_str().to_string()), mode: None, protocol: "http".to_string(), - attempt_id: network_attempt_id.clone(), decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -563,7 +553,6 @@ async fn http_plain_proxy( method: Some(req.method().as_str().to_string()), mode: Some(NetworkMode::Limited), protocol: "http".to_string(), - attempt_id: network_attempt_id, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -645,12 +634,6 @@ fn client_addr(input: &T) -> Option { .map(|info| info.peer_addr().to_string()) } -fn request_network_attempt_id(req: &Request) -> Option { - // Some HTTP stacks normalize proxy credentials into `authorization`; accept both. - attempt_id_from_proxy_authorization(req.headers().get("proxy-authorization")) - .or_else(|| attempt_id_from_proxy_authorization(req.headers().get("authorization"))) -} - fn remove_hop_by_hop_request_headers(headers: &mut HeaderMap) { while let Some(raw_connection) = headers.get(header::CONNECTION).cloned() { headers.remove(header::CONNECTION); @@ -738,7 +721,6 @@ async fn proxy_disabled_response( method, mode: None, protocol: protocol.as_policy_protocol().to_string(), - attempt_id: None, decision: Some("deny".to_string()), source: Some("proxy_state".to_string()), port: Some(port), @@ -796,8 +778,6 @@ mod tests { use crate::config::NetworkMode; use crate::config::NetworkProxySettings; use crate::runtime::network_proxy_state_for_policy; - use base64::Engine; - use base64::engine::general_purpose::STANDARD; use pretty_assertions::assert_eq; use rama_http::Method; use rama_http::Request; @@ -873,36 +853,6 @@ mod tests { ); } - #[test] - fn request_network_attempt_id_reads_proxy_authorization_header() { - let encoded = STANDARD.encode("codex-net-attempt-attempt-1:"); - let req = Request::builder() - .method(Method::GET) - .uri("http://example.com") - .header("proxy-authorization", format!("Basic {encoded}")) - .body(Body::empty()) - .unwrap(); - assert_eq!( - request_network_attempt_id(&req), - Some("attempt-1".to_string()) - ); - } - - #[test] - fn request_network_attempt_id_reads_authorization_header_fallback() { - let encoded = STANDARD.encode("codex-net-attempt-attempt-2:"); - let req = Request::builder() - .method(Method::GET) - .uri("http://example.com") - .header("authorization", format!("Basic {encoded}")) - .body(Body::empty()) - .unwrap(); - assert_eq!( - request_network_attempt_id(&req), - Some("attempt-2".to_string()) - ); - } - #[test] fn remove_hop_by_hop_request_headers_keeps_forwarding_headers() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 536dd4bf6f..f0cb3ae471 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -3,7 +3,6 @@ mod admin; mod config; mod http_proxy; -mod metadata; mod network_policy; mod policy; mod proxy; diff --git a/codex-rs/network-proxy/src/metadata.rs b/codex-rs/network-proxy/src/metadata.rs deleted file mode 100644 index 2542125c35..0000000000 --- a/codex-rs/network-proxy/src/metadata.rs +++ /dev/null @@ -1,50 +0,0 @@ -use base64::Engine; -use base64::engine::general_purpose::STANDARD; -use rama_http::HeaderValue; - -pub const NETWORK_ATTEMPT_USERNAME_PREFIX: &str = "codex-net-attempt-"; - -pub fn proxy_username_for_attempt_id(attempt_id: &str) -> String { - format!("{NETWORK_ATTEMPT_USERNAME_PREFIX}{attempt_id}") -} - -pub fn attempt_id_from_proxy_authorization(header: Option<&HeaderValue>) -> Option { - let header = header?; - let raw = header.to_str().ok()?; - let encoded = raw.strip_prefix("Basic ")?; - let decoded = STANDARD.decode(encoded.trim()).ok()?; - let decoded = String::from_utf8(decoded).ok()?; - let username = decoded - .split_once(':') - .map(|(user, _)| user) - .unwrap_or(decoded.as_str()); - let attempt_id = username.strip_prefix(NETWORK_ATTEMPT_USERNAME_PREFIX)?; - if attempt_id.is_empty() { - None - } else { - Some(attempt_id.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD; - - #[test] - fn parses_attempt_id_from_proxy_authorization_header() { - let encoded = STANDARD.encode(format!("{NETWORK_ATTEMPT_USERNAME_PREFIX}abc123:")); - let header = HeaderValue::from_str(&format!("Basic {encoded}")).unwrap(); - assert_eq!( - attempt_id_from_proxy_authorization(Some(&header)), - Some("abc123".to_string()) - ); - } - - #[test] - fn ignores_non_attempt_proxy_authorization_header() { - let encoded = STANDARD.encode("normal-user:password"); - let header = HeaderValue::from_str(&format!("Basic {encoded}")).unwrap(); - assert_eq!(attempt_id_from_proxy_authorization(Some(&header)), None); - } -} diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index fdbbe92819..6d8ec48d91 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -71,7 +71,6 @@ pub struct NetworkPolicyRequest { pub method: Option, pub command: Option, pub exec_policy_hint: Option, - pub attempt_id: Option, } pub struct NetworkPolicyRequestArgs { @@ -82,7 +81,6 @@ pub struct NetworkPolicyRequestArgs { pub method: Option, pub command: Option, pub exec_policy_hint: Option, - pub attempt_id: Option, } impl NetworkPolicyRequest { @@ -95,7 +93,6 @@ impl NetworkPolicyRequest { method, command, exec_policy_hint, - attempt_id, } = args; Self { protocol, @@ -105,7 +102,6 @@ impl NetworkPolicyRequest { method, command, exec_policy_hint, - attempt_id, } } } @@ -258,7 +254,6 @@ mod tests { method: Some("GET".to_string()), command: None, exec_policy_hint: None, - attempt_id: None, }); let decision = evaluate_host_policy(&state, Some(&decider), &request) @@ -292,7 +287,6 @@ mod tests { method: Some("GET".to_string()), command: None, exec_policy_hint: None, - attempt_id: None, }); let decision = evaluate_host_policy(&state, Some(&decider), &request) @@ -333,7 +327,6 @@ mod tests { method: Some("GET".to_string()), command: None, exec_policy_hint: None, - attempt_id: None, }); let decision = evaluate_host_policy(&state, Some(&decider), &request) diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 2e93fb7a1b..f30006958f 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -1,7 +1,6 @@ use crate::admin; use crate::config; use crate::http_proxy; -use crate::metadata::proxy_username_for_attempt_id; use crate::network_policy::NetworkPolicyDecider; use crate::runtime::BlockedRequestObserver; use crate::runtime::unix_socket_permissions_supported; @@ -207,6 +206,10 @@ impl NetworkProxyBuilder { socks_addr, socks_enabled: current_cfg.network.enable_socks5, allow_local_binding: current_cfg.network.allow_local_binding, + allow_unix_sockets: current_cfg.network.allow_unix_sockets.clone(), + dangerously_allow_all_unix_sockets: current_cfg + .network + .dangerously_allow_all_unix_sockets, admin_addr, reserved_listeners, policy_decider: self.policy_decider, @@ -241,6 +244,8 @@ pub struct NetworkProxy { socks_addr: SocketAddr, socks_enabled: bool, allow_local_binding: bool, + allow_unix_sockets: Vec, + dangerously_allow_all_unix_sockets: bool, admin_addr: SocketAddr, reserved_listeners: Option>, policy_decider: Option>, @@ -338,12 +343,8 @@ fn apply_proxy_env_overrides( socks_addr: SocketAddr, socks_enabled: bool, allow_local_binding: bool, - network_attempt_id: Option<&str>, ) { - let http_proxy_url = network_attempt_id - .map(proxy_username_for_attempt_id) - .map(|username| format!("http://{username}@{http_addr}")) - .unwrap_or_else(|| format!("http://{http_addr}")); + let http_proxy_url = format!("http://{http_addr}"); let socks_proxy_url = format!("socks5h://{socks_addr}"); env.insert( ALLOW_LOCAL_BINDING_ENV_KEY.to_string(), @@ -390,9 +391,7 @@ fn apply_proxy_env_overrides( // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if // those vars contain SOCKS URLs. We only switch ALL_PROXY here. // - // For attempt-scoped runs, point ALL_PROXY at the HTTP proxy URL so the - // attempt metadata survives in proxy credentials for correlation. - if socks_enabled && network_attempt_id.is_none() { + if socks_enabled { set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url); set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url); } else { @@ -426,15 +425,19 @@ impl NetworkProxy { self.admin_addr } - pub fn apply_to_env(&self, env: &mut HashMap) { - self.apply_to_env_for_attempt(env, None); + pub fn allow_local_binding(&self) -> bool { + self.allow_local_binding } - pub fn apply_to_env_for_attempt( - &self, - env: &mut HashMap, - network_attempt_id: Option<&str>, - ) { + pub fn allow_unix_sockets(&self) -> &[String] { + &self.allow_unix_sockets + } + + pub fn dangerously_allow_all_unix_sockets(&self) -> bool { + self.dangerously_allow_all_unix_sockets + } + + pub fn apply_to_env(&self, env: &mut HashMap) { // Enforce proxying for child processes. We intentionally override existing values so // command-level environment cannot bypass the managed proxy endpoint. apply_proxy_env_overrides( @@ -443,7 +446,6 @@ impl NetworkProxy { self.socks_addr, self.socks_enabled, self.allow_local_binding, - network_attempt_id, ); } @@ -457,7 +459,9 @@ impl NetworkProxy { ensure_rustls_crypto_provider(); if !unix_socket_permissions_supported() { - warn!("allowUnixSockets is macOS-only; requests will be rejected on this platform"); + warn!( + "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform" + ); } let reserved_listeners = self.reserved_listeners.as_ref(); @@ -751,7 +755,6 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), true, false, - None, ); assert_eq!( @@ -802,7 +805,6 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), false, true, - None, ); assert_eq!( @@ -813,7 +815,7 @@ mod tests { } #[test] - fn apply_proxy_env_overrides_embeds_attempt_id_in_http_proxy_url() { + fn apply_proxy_env_overrides_uses_plain_http_proxy_url() { let mut env = HashMap::new(); apply_proxy_env_overrides( &mut env, @@ -821,28 +823,27 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), true, false, - Some("attempt-123"), ); assert_eq!( env.get("HTTP_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"http://127.0.0.1:3128".to_string()) ); assert_eq!( env.get("HTTPS_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"http://127.0.0.1:3128".to_string()) ); assert_eq!( env.get("WS_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"http://127.0.0.1:3128".to_string()) ); assert_eq!( env.get("WSS_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"http://127.0.0.1:3128".to_string()) ); assert_eq!( env.get("ALL_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"socks5h://127.0.0.1:8081".to_string()) ); #[cfg(target_os = "macos")] assert_eq!( @@ -867,7 +868,6 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), true, false, - None, ); assert_eq!( diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 7917747a8f..583ecd7eda 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -1,5 +1,6 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; +use crate::config::ValidatedUnixSocketPath; use crate::policy::Host; use crate::policy::is_loopback_host; use crate::policy::is_non_public_ip; @@ -75,8 +76,6 @@ pub struct BlockedRequest { pub mode: Option, pub protocol: String, #[serde(skip_serializing_if = "Option::is_none")] - pub attempt_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub decision: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, @@ -92,7 +91,6 @@ pub struct BlockedRequestArgs { pub method: Option, pub mode: Option, pub protocol: String, - pub attempt_id: Option, pub decision: Option, pub source: Option, pub port: Option, @@ -107,7 +105,6 @@ impl BlockedRequest { method, mode, protocol, - attempt_id, decision, source, port, @@ -119,7 +116,6 @@ impl BlockedRequest { method, mode, protocol, - attempt_id, decision, source, port, @@ -365,7 +361,6 @@ impl NetworkProxyState { let source = entry.source.clone(); let protocol = entry.protocol.clone(); let port = entry.port; - let attempt_id = entry.attempt_id.clone(); guard.blocked.push_back(entry); guard.blocked_total = guard.blocked_total.saturating_add(1); let total = guard.blocked_total; @@ -373,7 +368,7 @@ impl NetworkProxyState { guard.blocked.pop_front(); } debug!( - "recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, attempt_id={:?}, buffered={})", + "recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, buffered={})", total, host, reason, @@ -381,7 +376,6 @@ impl NetworkProxyState { source, protocol, port, - attempt_id, guard.blocked.len() ); debug!("{violation_line}"); @@ -425,6 +419,10 @@ impl NetworkProxyState { } let guard = self.state.read().await; + if guard.config.network.dangerously_allow_all_unix_sockets { + return Ok(true); + } + // Normalize the path while keeping the absolute-path requirement explicit. let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) { Ok(path) => path, @@ -432,7 +430,16 @@ impl NetworkProxyState { }; let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); for allowed in &guard.config.network.allow_unix_sockets { - if allowed == path { + let allowed_path = match ValidatedUnixSocketPath::parse(allowed) { + Ok(ValidatedUnixSocketPath::Native(path)) => path, + Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue, + Err(err) => { + warn!("ignoring invalid network.allow_unix_sockets entry at runtime: {err:#}"); + continue; + } + }; + + if allowed_path.as_path() == requested_abs.as_path() { return Ok(true); } @@ -441,7 +448,7 @@ impl NetworkProxyState { let Some(requested_canonical) = &requested_canonical else { continue; }; - if let Ok(allowed_canonical) = std::fs::canonicalize(allowed) + if let Ok(allowed_canonical) = std::fs::canonicalize(allowed_path.as_path()) && &allowed_canonical == requested_canonical { return Ok(true); @@ -700,7 +707,6 @@ mod tests { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), - attempt_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -741,7 +747,6 @@ mod tests { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), - attempt_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -764,7 +769,6 @@ mod tests { method: Some("GET".to_string()), mode: Some(NetworkMode::Full), protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -773,7 +777,7 @@ mod tests { assert_eq!( blocked_request_violation_log_line(&entry), - r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","attempt_id":"attempt-1","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"# + r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"# ); } @@ -1085,6 +1089,77 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); } + #[test] + fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_without_managed_opt_in() + { + let constraints = NetworkProxyConstraints { + dangerously_allow_all_unix_sockets: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_when_allowlist_is_managed() + { + let constraints = NetworkProxyConstraints { + allow_unix_sockets: Some(vec!["/tmp/allowed.sock".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_allow_all_unix_sockets_with_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_all_unix_sockets: Some(true), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn validate_policy_against_constraints_allows_allow_all_unix_sockets_when_unmanaged() { + let constraints = NetworkProxyConstraints::default(); + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + #[test] fn compile_globset_is_case_insensitive() { let patterns = vec!["ExAmPle.CoM".to_string()]; @@ -1182,6 +1257,19 @@ mod tests { assert!(state.is_unix_socket_allowed(&link_s).await.unwrap()); } + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allow_all_flag_bypasses_allowlist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["example.com".to_string()], + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }); + + assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap()); + assert!(!state.is_unix_socket_allowed("relative.sock").await.unwrap()); + } + #[cfg(not(target_os = "macos"))] #[tokio::test] async fn unix_socket_allowlist_is_rejected_on_non_macos() { @@ -1189,6 +1277,7 @@ mod tests { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], allow_unix_sockets: vec![socket_path.clone()], + dangerously_allow_all_unix_sockets: true, ..NetworkProxySettings::default() }); diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 176055a5d2..409bd82e37 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -168,7 +168,6 @@ async fn handle_socks5_tcp( method: None, mode: None, protocol: "socks5".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -202,7 +201,6 @@ async fn handle_socks5_tcp( method: None, mode: Some(NetworkMode::Limited), protocol: "socks5".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -229,7 +227,6 @@ async fn handle_socks5_tcp( method: None, command: None, exec_policy_hint: None, - attempt_id: None, }); match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { @@ -254,7 +251,6 @@ async fn handle_socks5_tcp( method: None, mode: None, protocol: "socks5".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -318,7 +314,6 @@ async fn inspect_socks5_udp( method: None, mode: None, protocol: "socks5-udp".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -352,7 +347,6 @@ async fn inspect_socks5_udp( method: None, mode: Some(NetworkMode::Limited), protocol: "socks5-udp".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -375,7 +369,6 @@ async fn inspect_socks5_udp( method: None, command: None, exec_policy_hint: None, - attempt_id: None, }); match evaluate_host_policy(&state, policy_decider.as_ref(), &request).await { @@ -400,7 +393,6 @@ async fn inspect_socks5_udp( method: None, mode: None, protocol: "socks5-udp".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 97dfcc4b5d..d7dc705ac4 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -19,6 +19,7 @@ pub struct NetworkProxyConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -38,6 +39,7 @@ pub struct PartialNetworkConfig { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, #[serde(default)] pub allowed_domains: Option>, #[serde(default)] @@ -52,6 +54,7 @@ pub fn build_config_state( config: NetworkProxyConfig, constraints: NetworkProxyConstraints, ) -> anyhow::Result { + crate::config::validate_unix_socket_allowlist_paths(&config)?; let deny_set = compile_globset(&config.network.denied_domains)?; let allow_set = compile_globset(&config.network.allowed_domains)?; Ok(ConfigState { @@ -173,6 +176,24 @@ pub fn validate_policy_against_constraints( }, )?; + let allow_all_unix_sockets = constraints + .dangerously_allow_all_unix_sockets + .unwrap_or(constraints.allow_unix_sockets.is_none()); + validate( + config.network.dangerously_allow_all_unix_sockets, + move |candidate| { + if *candidate && !allow_all_unix_sockets { + Err(invalid_value( + "network.dangerously_allow_all_unix_sockets", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + }, + )?; + if let Some(allow_local_binding) = constraints.allow_local_binding { validate(config.network.allow_local_binding, move |candidate| { if *candidate && !allow_local_binding { diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index e3ba85eab1..9331801f9d 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -588,12 +588,15 @@ impl OtelManager { ); } + #[allow(clippy::too_many_arguments)] pub async fn log_tool_result_with_tags( &self, tool_name: &str, call_id: &str, arguments: &str, extra_tags: &[(&str, &str)], + mcp_server: Option<&str>, + mcp_server_origin: Option<&str>, f: F, ) -> Result<(String, bool), E> where @@ -618,6 +621,8 @@ impl OtelManager { success, output.as_ref(), extra_tags, + mcp_server, + mcp_server_origin, ); result @@ -641,6 +646,8 @@ impl OtelManager { duration_ms = %Duration::ZERO.as_millis(), success = %false, output = %error, + mcp_server = "", + mcp_server_origin = "", ); } @@ -654,6 +661,8 @@ impl OtelManager { success: bool, output: &str, extra_tags: &[(&str, &str)], + mcp_server: Option<&str>, + mcp_server_origin: Option<&str>, ) { let success_str = if success { "true" } else { "false" }; let mut tags = Vec::with_capacity(2 + extra_tags.len()); @@ -662,6 +671,8 @@ impl OtelManager { tags.extend_from_slice(extra_tags); self.counter(TOOL_CALL_COUNT_METRIC, 1, &tags); self.record_duration(TOOL_CALL_DURATION_METRIC, duration, &tags); + let mcp_server = mcp_server.unwrap_or(""); + let mcp_server_origin = mcp_server_origin.unwrap_or(""); tracing::event!( tracing::Level::INFO, event.name = "codex.tool_result", @@ -681,6 +692,8 @@ impl OtelManager { duration_ms = %duration.as_millis(), success = %success_str, output = %output, + mcp_server = %mcp_server, + mcp_server_origin = %mcp_server_origin, ); } diff --git a/codex-rs/otel/tests/suite/runtime_summary.rs b/codex-rs/otel/tests/suite/runtime_summary.rs index 0737e0797f..4020e70b45 100644 --- a/codex-rs/otel/tests/suite/runtime_summary.rs +++ b/codex-rs/otel/tests/suite/runtime_summary.rs @@ -44,6 +44,8 @@ fn runtime_metrics_summary_collects_tool_api_and_streaming_metrics() -> Result<( true, "ok", &[], + None, + None, ); manager.record_api_request(1, Some(200), None, Duration::from_millis(300)); manager.record_websocket_request(Duration::from_millis(400), None); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 476f902085..5b3d41888c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1861,6 +1861,10 @@ pub enum SubAgentSource { ThreadSpawn { parent_thread_id: ThreadId, depth: i32, + #[serde(default)] + agent_nickname: Option, + #[serde(default, alias = "agent_type")] + agent_role: Option, }, MemoryConsolidation, Other(String), @@ -1879,6 +1883,26 @@ impl fmt::Display for SessionSource { } } +impl SessionSource { + pub fn get_nickname(&self) -> Option { + match self { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => { + agent_nickname.clone() + } + _ => None, + } + } + + pub fn get_agent_role(&self) -> Option { + match self { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => { + agent_role.clone() + } + _ => None, + } + } +} + impl fmt::Display for SubAgentSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -1888,6 +1912,7 @@ impl fmt::Display for SubAgentSource { SubAgentSource::ThreadSpawn { parent_thread_id, depth, + .. } => { write!(f, "thread_spawn_{parent_thread_id}_d{depth}") } @@ -1912,6 +1937,12 @@ pub struct SessionMeta { pub cli_version: String, #[serde(default)] pub source: SessionSource, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] + pub agent_role: Option, pub model_provider: Option, /// base_instructions for the session. This *should* always be present when creating a new session, /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions @@ -1931,6 +1962,8 @@ impl Default for SessionMeta { originator: String::new(), cli_version: String::new(), source: SessionSource::default(), + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: None, @@ -2714,6 +2747,32 @@ pub struct CollabAgentSpawnBeginEvent { pub prompt: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct CollabAgentRef { + /// Thread ID of the receiver/new agent. + pub thread_id: ThreadId, + /// Optional nickname assigned to an AgentControl-spawned sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] + pub agent_role: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct CollabAgentStatusEntry { + /// Thread ID of the receiver/new agent. + pub thread_id: ThreadId, + /// Optional nickname assigned to an AgentControl-spawned sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] + pub agent_role: Option, + /// Last known status of the agent. + pub status: AgentStatus, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct CollabAgentSpawnEndEvent { /// Identifier for the collab tool call. @@ -2722,6 +2781,12 @@ pub struct CollabAgentSpawnEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the newly spawned agent, if it was created. pub new_thread_id: Option, + /// Optional nickname assigned to the new agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_agent_nickname: Option, + /// Optional role assigned to the new agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_agent_role: Option, /// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the /// beginning. pub prompt: String, @@ -2750,6 +2815,12 @@ pub struct CollabAgentInteractionEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Prompt sent from the sender to the receiver. Can be empty to prevent CoT /// leaking at the beginning. pub prompt: String, @@ -2763,6 +2834,9 @@ pub struct CollabWaitingBeginEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receivers. pub receiver_thread_ids: Vec, + /// Optional nicknames/roles for receivers. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub receiver_agents: Vec, /// ID of the waiting call. pub call_id: String, } @@ -2773,6 +2847,9 @@ pub struct CollabWaitingEndEvent { pub sender_thread_id: ThreadId, /// ID of the waiting call. pub call_id: String, + /// Optional receiver metadata paired with final statuses. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_statuses: Vec, /// Last known status of the receiver agents reported to the sender agent. pub statuses: HashMap, } @@ -2795,6 +2872,12 @@ pub struct CollabCloseEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Last known status of the receiver agent reported to the sender agent before /// the close. pub status: AgentStatus, @@ -2808,6 +2891,12 @@ pub struct CollabResumeBeginEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -2818,6 +2907,12 @@ pub struct CollabResumeEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Last known status of the receiver agent reported to the sender agent after /// resume. pub status: AgentStatus, diff --git a/codex-rs/secrets/BUILD.bazel b/codex-rs/secrets/BUILD.bazel new file mode 100644 index 0000000000..1b69e7c7e0 --- /dev/null +++ b/codex-rs/secrets/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "secrets", + crate_name = "codex_secrets", +) diff --git a/codex-rs/secrets/Cargo.toml b/codex-rs/secrets/Cargo.toml index de45af50a1..eae8f9005e 100644 --- a/codex-rs/secrets/Cargo.toml +++ b/codex-rs/secrets/Cargo.toml @@ -13,6 +13,7 @@ anyhow = { workspace = true } base64 = { workspace = true } codex-keyring-store = { workspace = true } rand = { workspace = true } +regex = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs index a45860d8b5..1ec925fd4f 100644 --- a/codex-rs/secrets/src/lib.rs +++ b/codex-rs/secrets/src/lib.rs @@ -13,8 +13,10 @@ use sha2::Digest; use sha2::Sha256; mod local; +mod sanitizer; pub use local::LocalSecretsBackend; +pub use sanitizer::redact_secrets; const KEYRING_SERVICE: &str = "codex"; diff --git a/codex-rs/utils/sanitizer/src/lib.rs b/codex-rs/secrets/src/sanitizer.rs similarity index 100% rename from codex-rs/utils/sanitizer/src/lib.rs rename to codex-rs/secrets/src/sanitizer.rs diff --git a/codex-rs/state/migrations/0013_threads_agent_nickname.sql b/codex-rs/state/migrations/0013_threads_agent_nickname.sql new file mode 100644 index 0000000000..80394c88b5 --- /dev/null +++ b/codex-rs/state/migrations/0013_threads_agent_nickname.sql @@ -0,0 +1,2 @@ +ALTER TABLE threads ADD COLUMN agent_nickname TEXT; +ALTER TABLE threads ADD COLUMN agent_role TEXT; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index f8f9cb5251..0ba3df6790 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -37,6 +37,8 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi } metadata.id = meta_line.meta.id; metadata.source = enum_to_string(&meta_line.meta.source); + metadata.agent_nickname = meta_line.meta.agent_nickname.clone(); + metadata.agent_role = meta_line.meta.agent_role.clone(); if let Some(provider) = meta_line.meta.model_provider.as_deref() { metadata.model_provider = provider.to_string(); } @@ -216,6 +218,8 @@ mod tests { created_at, updated_at: created_at, source: "cli".to_string(), + agent_nickname: None, + agent_role: None, model_provider: "openai".to_string(), cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index d29bf07606..9533b8fede 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -62,6 +62,10 @@ pub struct ThreadMetadata { pub updated_at: DateTime, /// The session source (stringified enum). pub source: String, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, /// The model provider identifier. pub model_provider: String, /// The working directory for the thread. @@ -101,6 +105,10 @@ pub struct ThreadMetadataBuilder { pub updated_at: Option>, /// The session source. pub source: SessionSource, + /// Optional random unique nickname assigned to the session. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to the session. + pub agent_role: Option, /// The model provider identifier, if known. pub model_provider: Option, /// The working directory for the thread. @@ -135,6 +143,8 @@ impl ThreadMetadataBuilder { created_at, updated_at: None, source, + agent_nickname: None, + agent_role: None, model_provider: None, cwd: PathBuf::new(), cli_version: None, @@ -163,6 +173,8 @@ impl ThreadMetadataBuilder { created_at, updated_at, source, + agent_nickname: self.agent_nickname.clone(), + agent_role: self.agent_role.clone(), model_provider: self .model_provider .clone() @@ -201,6 +213,12 @@ impl ThreadMetadata { if self.source != other.source { diffs.push("source"); } + if self.agent_nickname != other.agent_nickname { + diffs.push("agent_nickname"); + } + if self.agent_role != other.agent_role { + diffs.push("agent_role"); + } if self.model_provider != other.model_provider { diffs.push("model_provider"); } @@ -252,6 +270,8 @@ pub(crate) struct ThreadRow { created_at: i64, updated_at: i64, source: String, + agent_nickname: Option, + agent_role: Option, model_provider: String, cwd: String, cli_version: String, @@ -274,6 +294,8 @@ impl ThreadRow { created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, source: row.try_get("source")?, + agent_nickname: row.try_get("agent_nickname")?, + agent_role: row.try_get("agent_role")?, model_provider: row.try_get("model_provider")?, cwd: row.try_get("cwd")?, cli_version: row.try_get("cli_version")?, @@ -300,6 +322,8 @@ impl TryFrom for ThreadMetadata { created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -319,6 +343,8 @@ impl TryFrom for ThreadMetadata { created_at: epoch_seconds_to_datetime(created_at)?, updated_at: epoch_seconds_to_datetime(updated_at)?, source, + agent_nickname, + agent_role, model_provider, cwd: PathBuf::from(cwd), cli_version, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 4570532e69..f027cac6e8 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -211,6 +211,8 @@ SELECT created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -310,6 +312,8 @@ SELECT created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -691,6 +695,8 @@ INSERT INTO threads ( created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -704,12 +710,14 @@ INSERT INTO threads ( git_sha, git_branch, git_origin_url -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, updated_at = excluded.updated_at, source = excluded.source, + agent_nickname = excluded.agent_nickname, + agent_role = excluded.agent_role, model_provider = excluded.model_provider, cwd = excluded.cwd, cli_version = excluded.cli_version, @@ -730,6 +738,8 @@ ON CONFLICT(id) DO UPDATE SET .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(metadata.updated_at)) .bind(metadata.source.as_str()) + .bind(metadata.agent_nickname.as_deref()) + .bind(metadata.agent_role.as_deref()) .bind(metadata.model_provider.as_str()) .bind(metadata.cwd.display().to_string()) .bind(metadata.cli_version.as_str()) @@ -2059,7 +2069,7 @@ WHERE kind = 'memory_stage1' } #[tokio::test] - async fn mark_stage1_job_succeeded_no_output_tracks_watermark_without_persisting_output() { + async fn mark_stage1_job_succeeded_no_output_skips_phase2_when_output_was_already_absent() { let codex_home = unique_temp_dir(); let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) .await @@ -2112,23 +2122,129 @@ WHERE kind = 'memory_stage1' .expect("claim stage1 up-to-date"); assert_eq!(up_to_date, Stage1JobClaimOutcome::SkippedUpToDate); + let global_job_row_count = sqlx::query("SELECT COUNT(*) AS count FROM jobs WHERE kind = ?") + .bind("memory_consolidate_global") + .fetch_one(runtime.pool.as_ref()) + .await + .expect("load phase2 job row count") + .try_get::("count") + .expect("phase2 job row count"); + assert_eq!( + global_job_row_count, 0, + "no-output without an existing stage1 output should not enqueue phase2" + ); + let claim_phase2 = runtime .try_claim_global_phase2_job(owner, 3600) .await .expect("claim phase2"); + assert_eq!( + claim_phase2, + Phase2JobClaimOutcome::SkippedNotDirty, + "phase2 should remain clean when no-output deleted nothing" + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn mark_stage1_job_succeeded_no_output_enqueues_phase2_when_deleting_output() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id"); + let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id"); + let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id"); + runtime + .upsert_thread(&test_thread_metadata( + &codex_home, + thread_id, + codex_home.join("workspace"), + )) + .await + .expect("upsert thread"); + + let first_claim = runtime + .try_claim_stage1_job(thread_id, owner, 100, 3600, 64) + .await + .expect("claim initial stage1"); + let first_token = match first_claim { + Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token, + other => panic!("unexpected initial stage1 claim outcome: {other:?}"), + }; + assert!( + runtime + .mark_stage1_job_succeeded(thread_id, first_token.as_str(), 100, "raw", "sum", None) + .await + .expect("mark initial stage1 succeeded"), + "initial stage1 success should create stage1 output" + ); + + let phase2_claim = runtime + .try_claim_global_phase2_job(owner, 3600) + .await + .expect("claim phase2 after initial output"); + let (phase2_token, phase2_input_watermark) = match phase2_claim { + Phase2JobClaimOutcome::Claimed { + ownership_token, + input_watermark, + } => (ownership_token, input_watermark), + other => panic!("unexpected phase2 claim after initial output: {other:?}"), + }; + assert_eq!(phase2_input_watermark, 100); + assert!( + runtime + .mark_global_phase2_job_succeeded(phase2_token.as_str(), phase2_input_watermark) + .await + .expect("mark initial phase2 succeeded"), + "initial phase2 success should clear global dirty state" + ); + + let no_output_claim = runtime + .try_claim_stage1_job(thread_id, owner_b, 101, 3600, 64) + .await + .expect("claim stage1 for no-output delete"); + let no_output_token = match no_output_claim { + Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token, + other => panic!("unexpected no-output stage1 claim outcome: {other:?}"), + }; + assert!( + runtime + .mark_stage1_job_succeeded_no_output(thread_id, no_output_token.as_str()) + .await + .expect("mark stage1 no-output after existing output"), + "no-output should succeed when deleting an existing stage1 output" + ); + + let output_row_count = + sqlx::query("SELECT COUNT(*) AS count FROM stage1_outputs WHERE thread_id = ?") + .bind(thread_id.to_string()) + .fetch_one(runtime.pool.as_ref()) + .await + .expect("load stage1 output count after delete") + .try_get::("count") + .expect("stage1 output count"); + assert_eq!(output_row_count, 0); + + let claim_phase2 = runtime + .try_claim_global_phase2_job(owner, 3600) + .await + .expect("claim phase2 after no-output deletion"); let (phase2_token, phase2_input_watermark) = match claim_phase2 { Phase2JobClaimOutcome::Claimed { ownership_token, input_watermark, } => (ownership_token, input_watermark), - other => panic!("unexpected phase2 claim outcome after no-output success: {other:?}"), + other => panic!("unexpected phase2 claim after no-output deletion: {other:?}"), }; - assert_eq!(phase2_input_watermark, 100); + assert_eq!(phase2_input_watermark, 101); assert!( runtime - .mark_global_phase2_job_succeeded(phase2_token.as_str(), phase2_input_watermark,) + .mark_global_phase2_job_succeeded(phase2_token.as_str(), phase2_input_watermark) .await - .expect("mark phase2 succeeded after no-output") + .expect("mark phase2 succeeded after no-output delete") ); let _ = tokio::fs::remove_dir_all(codex_home).await; @@ -3092,6 +3208,8 @@ VALUES (?, ?, ?, ?, ?) created_at: now, updated_at: now, source: "cli".to_string(), + agent_nickname: None, + agent_role: None, model_provider: "test-provider".to_string(), cwd, cli_version: "0.0.0".to_string(), diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index c2a7f1fe91..bad5679eac 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -95,6 +95,8 @@ SELECT created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -496,7 +498,7 @@ WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at /// - sets `status='done'` and `last_success_watermark = input_watermark` /// - deletes any existing `stage1_outputs` row for the thread /// - enqueues/advances the global phase-2 job watermark using the claimed - /// `input_watermark` + /// `input_watermark` only when deleting an existing `stage1_outputs` row pub async fn mark_stage1_job_succeeded_no_output( &self, thread_id: ThreadId, @@ -546,7 +548,7 @@ WHERE kind = ? AND job_key = ? AND ownership_token = ? .await? .try_get::("input_watermark")?; - sqlx::query( + let deleted_rows = sqlx::query( r#" DELETE FROM stage1_outputs WHERE thread_id = ? @@ -554,9 +556,12 @@ WHERE thread_id = ? ) .bind(thread_id.as_str()) .execute(&mut *tx) - .await?; + .await? + .rows_affected(); - enqueue_global_consolidation_with_executor(&mut *tx, source_updated_at).await?; + if deleted_rows > 0 { + enqueue_global_consolidation_with_executor(&mut *tx, source_updated_at).await?; + } tx.commit().await?; Ok(true) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a46a927fbc..5898059644 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -808,38 +808,57 @@ impl App { async fn open_agent_picker(&mut self) { let thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); + let mut agent_threads = Vec::new(); for thread_id in thread_ids { - if self.server.get_thread(thread_id).await.is_err() { - self.thread_event_channels.remove(&thread_id); + match self.server.get_thread(thread_id).await { + Ok(thread) => { + let session_source = thread.config_snapshot().await.session_source; + agent_threads.push(( + thread_id, + session_source.get_nickname(), + session_source.get_agent_role(), + )); + } + Err(_) => { + self.thread_event_channels.remove(&thread_id); + } } } - if self.thread_event_channels.is_empty() { + if agent_threads.is_empty() { self.chat_widget .add_info_message("No agents available yet.".to_string(), None); return; } - let mut thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); - thread_ids.sort_by_key(ToString::to_string); + agent_threads.sort_by(|(left, ..), (right, ..)| left.to_string().cmp(&right.to_string())); let mut initial_selected_idx = None; - let items: Vec = thread_ids + let items: Vec = agent_threads .iter() .enumerate() - .map(|(idx, thread_id)| { + .map(|(idx, (thread_id, agent_nickname, agent_role))| { if self.active_thread_id == Some(*thread_id) { initial_selected_idx = Some(idx); } let id = *thread_id; + let is_primary = self.primary_thread_id == Some(*thread_id); + let name = format_agent_picker_item_name( + *thread_id, + agent_nickname.as_deref(), + agent_role.as_deref(), + is_primary, + ); + let uuid = thread_id.to_string(); SelectionItem { - name: thread_id.to_string(), + name: name.clone(), + description: Some(uuid.clone()), is_current: self.active_thread_id == Some(*thread_id), actions: vec![Box::new(move |tx| { tx.send(AppEvent::SelectAgentThread(id)); })], dismiss_on_select: true, - search_value: Some(thread_id.to_string()), + search_value: Some(format!("{name} {uuid}")), ..Default::default() } }) @@ -2892,6 +2911,28 @@ impl App { } } +fn format_agent_picker_item_name( + _thread_id: ThreadId, + agent_nickname: Option<&str>, + agent_role: Option<&str>, + is_primary: bool, +) -> String { + if is_primary { + return "Main [default]".to_string(); + } + + let agent_nickname = agent_nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); + match (agent_nickname, agent_role) { + (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), + (Some(agent_nickname), None) => agent_nickname.to_string(), + (None, Some(agent_role)) => format!("[{agent_role}]"), + (None, None) => "Agent".to_string(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -3072,6 +3113,41 @@ mod tests { Ok(()) } + #[test] + fn agent_picker_item_name_snapshot() { + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); + let snapshot = [ + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), true), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), false), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, Some("Robie"), None, false), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, None, Some("explorer"), false), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, None, None, false), + thread_id + ), + ] + .join("\n"); + assert_snapshot!("agent_picker_item_name", snapshot); + } + #[tokio::test] async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()> { diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 567f18f332..169c8bdcc6 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -122,7 +122,7 @@ impl ApprovalOverlay { || "Would you like to run the following command?".to_string(), |network_approval_context| { format!( - "Do you want to approve access to \"{}\"?", + "Do you want to approve network access to \"{}\"?", network_approval_context.host ) }, @@ -364,12 +364,14 @@ impl From for ApprovalRequestState { header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); header.push(Line::from("")); } - let full_cmd = strip_bash_lc_and_escape(&command); - let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); - if let Some(first) = full_cmd_lines.first_mut() { - first.spans.insert(0, Span::from("$ ")); + if network_approval_context.is_none() { + let full_cmd = strip_bash_lc_and_escape(&command); + let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); + if let Some(first) = full_cmd_lines.first_mut() { + first.spans.insert(0, Span::from("$ ")); + } + header.extend(full_cmd_lines); } - header.extend(full_cmd_lines); Self { variant: ApprovalVariant::Exec { id, @@ -738,11 +740,15 @@ mod tests { .collect(); assert!( - rendered - .iter() - .any(|line| line.contains("Do you want to approve access to \"example.com\"?")), + rendered.iter().any(|line| { + line.contains("Do you want to approve network access to \"example.com\"?") + }), "expected network title to include host, got {rendered:?}" ); + assert!( + !rendered.iter().any(|line| line.contains("$ curl")), + "network prompt should not show command line, got {rendered:?}" + ); assert!( !rendered.iter().any(|line| line.contains("don't ask again")), "network prompt should not show execpolicy option, got {rendered:?}" diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 6cbfbdb9e5..eeca6aef18 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -87,7 +87,11 @@ impl FeedbackNoteView { } else { Some(note.as_str()) }; - let rollout_path_ref = self.rollout_path.as_deref(); + let log_file_paths = if self.include_logs { + self.rollout_path.iter().cloned().collect::>() + } else { + Vec::new() + }; let classification = feedback_classification(self.category); let mut thread_id = self.snapshot.thread_id.clone(); @@ -96,11 +100,7 @@ impl FeedbackNoteView { classification, reason_opt, self.include_logs, - if self.include_logs { - rollout_path_ref - } else { - None - }, + &log_file_paths, Some(SessionSource::Cli), ); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 43aecb4940..46917dd9fa 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -287,6 +287,13 @@ impl BottomPane { self.request_redraw(); } + /// Update the key hint shown next to queued messages so it matches the + /// binding that `ChatWidget` actually listens for. + pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) { + self.queued_user_messages.set_edit_binding(binding); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs index ae33aeada4..010b88111c 100644 --- a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs +++ b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs @@ -11,17 +11,33 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; /// Widget that displays a list of user messages queued while a turn is in progress. +/// +/// The widget shows a key hint at the bottom (e.g. "⌥ + ↑ edit") telling the +/// user how to pop the most recent queued message back into the composer. +/// Because some terminals intercept certain modifier-key combinations, the +/// displayed binding is configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct QueuedUserMessages { pub messages: Vec, + /// Key combination rendered in the hint line. Defaults to Alt+Up but may + /// be overridden for terminals where that chord is unavailable. + edit_binding: key_hint::KeyBinding, } impl QueuedUserMessages { pub(crate) fn new() -> Self { Self { messages: Vec::new(), + edit_binding: key_hint::alt(KeyCode::Up), } } + /// Replace the keybinding shown in the hint line at the bottom of the + /// queued-messages list. The caller is responsible for also wiring the + /// corresponding key event handler. + pub(crate) fn set_edit_binding(&mut self, binding: key_hint::KeyBinding) { + self.edit_binding = binding; + } + fn as_renderable(&self, width: u16) -> Box { if self.messages.is_empty() || width < 4 { return Box::new(()); @@ -48,7 +64,7 @@ impl QueuedUserMessages { lines.push( Line::from(vec![ " ".into(), - key_hint::alt(KeyCode::Up).into(), + self.edit_binding.into(), " edit".into(), ]) .dim(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9e746b4c57..afc894b22b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -110,6 +110,8 @@ use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillMetadata; +use codex_core::terminal::TerminalName; +use codex_core::terminal::terminal_info; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_otel::OtelManager; @@ -158,6 +160,34 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; +/// Choose the keybinding used to edit the most-recently queued message. +/// +/// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently +/// swallow Alt+Up, so users in those environments would never be able to trigger +/// the edit action. We fall back to Shift+Left for those terminals while +/// keeping the more discoverable Alt+Up everywhere else. +/// +/// The match is exhaustive so that adding a new `TerminalName` variant forces +/// an explicit decision about which binding that terminal should use. +fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { + match terminal_name { + TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { + key_hint::shift(KeyCode::Left) + } + TerminalName::Ghostty + | TerminalName::Iterm2 + | TerminalName::WezTerm + | TerminalName::Kitty + | TerminalName::Alacritty + | TerminalName::Konsole + | TerminalName::GnomeTerminal + | TerminalName::Vte + | TerminalName::WindowsTerminal + | TerminalName::Dumb + | TerminalName::Unknown => key_hint::alt(KeyCode::Up), + } +} + use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; @@ -539,6 +569,7 @@ pub(crate) struct ChatWidget { mcp_startup_status: Option>, connectors_cache: ConnectorsCacheState, connectors_prefetch_in_flight: bool, + connectors_force_refetch_pending: bool, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -562,6 +593,11 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + /// Terminal-appropriate keybinding for popping the most-recently queued + /// message back into the composer. Determined once at construction time via + /// [`queued_message_edit_binding_for_terminal`] and propagated to + /// `BottomPane` so the hint text matches the actual shortcut. + queued_message_edit_binding: KeyBinding, // Pending notification to show when unfocused on next Draw pending_notification: Option, /// When `Some`, the user has pressed a quit shortcut and the second press @@ -2604,6 +2640,8 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2650,6 +2688,7 @@ impl ChatWidget { mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2660,6 +2699,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, @@ -2700,6 +2740,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -2767,6 +2810,8 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2813,6 +2858,7 @@ impl ChatWidget { mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2827,6 +2873,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, @@ -2863,6 +2910,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); widget } @@ -2919,6 +2969,8 @@ impl ChatWidget { settings: fallback_default, }; + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2965,6 +3017,7 @@ impl ChatWidget { mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2975,6 +3028,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: false, suppress_session_configured_redraw: true, pending_notification: None, @@ -3015,6 +3069,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -3087,6 +3144,18 @@ impl ChatWidget { _ => {} } + if key_event.kind == KeyEventKind::Press + && self.queued_message_edit_binding.is_press(key_event) + && !self.queued_user_messages.is_empty() + { + if let Some(user_message) = self.queued_user_messages.pop_back() { + self.restore_user_message_to_composer(user_message); + self.refresh_queued_user_messages(); + self.request_redraw(); + } + return; + } + match key_event { KeyEvent { code: KeyCode::BackTab, @@ -3098,19 +3167,6 @@ impl ChatWidget { { self.cycle_collaboration_mode(); } - KeyEvent { - code: KeyCode::Up, - modifiers: KeyModifiers::ALT, - kind: KeyEventKind::Press, - .. - } if !self.queued_user_messages.is_empty() => { - // Prefer the most recently queued item. - if let Some(user_message) = self.queued_user_messages.pop_back() { - self.restore_user_message_to_composer(user_message); - self.refresh_queued_user_messages(); - self.request_redraw(); - } - } _ => match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted { text, @@ -4601,7 +4657,13 @@ impl ChatWidget { } fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { - if !self.connectors_enabled() || self.connectors_prefetch_in_flight { + if !self.connectors_enabled() { + return; + } + if self.connectors_prefetch_in_flight { + if force_refetch { + self.connectors_force_refetch_pending = true; + } return; } @@ -4613,8 +4675,8 @@ impl ChatWidget { let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { - let accessible_connectors = - match connectors::list_accessible_connectors_from_mcp_tools_with_options( + let accessible_result = + match connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status( &config, force_refetch, ) @@ -4629,6 +4691,9 @@ impl ChatWidget { return; } }; + let should_schedule_force_refetch = + !force_refetch && !accessible_result.codex_apps_ready; + let accessible_connectors = accessible_result.connectors; app_event_tx.send(AppEvent::ConnectorsLoaded { result: Ok(ConnectorsSnapshot { @@ -4638,7 +4703,8 @@ impl ChatWidget { }); let result: Result = async { - let all_connectors = connectors::list_all_connectors(&config).await?; + let all_connectors = + connectors::list_all_connectors_with_options(&config, force_refetch).await?; let connectors = connectors::merge_connectors_with_accessible( all_connectors, accessible_connectors, @@ -4653,6 +4719,12 @@ impl ChatWidget { result, is_final: true, }); + + if should_schedule_force_refetch { + app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } }); } @@ -6323,9 +6395,36 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } + let previous_mode = self.active_mode_kind(); + let previous_model = self.current_model().to_string(); + let previous_effort = self.effective_reasoning_effort(); self.active_collaboration_mask = Some(mask); self.update_collaboration_mode_indicator(); self.refresh_model_display(); + let next_mode = self.active_mode_kind(); + let next_model = self.current_model(); + let next_effort = self.effective_reasoning_effort(); + if previous_mode != next_mode + && (previous_model != next_model || previous_effort != next_effort) + { + let mut message = format!("Model changed to {next_model}"); + if !next_model.starts_with("codex-auto-") { + let reasoning_label = match next_effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + }; + message.push(' '); + message.push_str(reasoning_label); + } + message.push_str(" for "); + message.push_str(next_mode.display_name()); + message.push_str(" mode."); + self.add_info_message(message, None); + } self.request_redraw(); } @@ -6821,8 +6920,13 @@ impl ChatWidget { result: Result, is_final: bool, ) { + let mut trigger_pending_force_refetch = false; if is_final { self.connectors_prefetch_in_flight = false; + if self.connectors_force_refetch_pending { + self.connectors_force_refetch_pending = false; + trigger_pending_force_refetch = true; + } } match result { @@ -6857,13 +6961,16 @@ impl ChatWidget { Err(err) => { if matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) { warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); - return; + } else { + self.connectors_cache = ConnectorsCacheState::Failed(err); + self.bottom_pane.set_connectors_snapshot(None); } - - self.connectors_cache = ConnectorsCacheState::Failed(err); - self.bottom_pane.set_connectors_snapshot(None); } } + + if trigger_pending_force_refetch { + self.prefetch_connectors_with_options(true); + } } pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 829c39d0a0..8646033284 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -67,6 +67,7 @@ use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::skills::model::SkillMetadata; +use codex_core::terminal::TerminalName; use codex_otel::OtelManager; use codex_otel::RuntimeMetricsSummary; use codex_protocol::ThreadId; @@ -1627,6 +1628,7 @@ async fn make_chatwidget_manual( mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -1639,6 +1641,7 @@ async fn make_chatwidget_manual( frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), + queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), suppress_session_configured_redraw: false, pending_notification: None, quit_shortcut_expires_at: None, @@ -2780,6 +2783,9 @@ async fn empty_enter_during_task_does_not_queue() { #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.queued_message_edit_binding = crate::key_hint::alt(KeyCode::Up); + chat.bottom_pane + .set_queued_message_edit_binding(crate::key_hint::alt(KeyCode::Up)); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(true); @@ -2807,6 +2813,77 @@ async fn alt_up_edits_most_recent_queued_message() { ); } +async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( + terminal_name: TerminalName, +) { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.bottom_pane + .set_queued_message_edit_binding(chat.queued_message_edit_binding); + + // Simulate a running task so messages would normally be queued. + chat.bottom_pane.set_task_running(true); + + // Seed two queued messages. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + // Press Shift+Left to edit the most recent (last) queued message. + chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT)); + + // Composer should now contain the last queued message. + assert_eq!( + chat.bottom_pane.composer_text(), + "second queued".to_string() + ); + // And the queue should now contain only the remaining (older) item. + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "first queued" + ); +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; +} + +#[test] +fn queued_message_edit_binding_mapping_covers_special_terminals() { + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::VsCode), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + crate::key_hint::alt(KeyCode::Up) + ); +} + /// Pressing Up to recall the most recent history entry and immediately queuing /// it while a task is running should always enqueue the same text, even when it /// is queued repeatedly. @@ -3583,6 +3660,66 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { assert_eq!(chat.active_collaboration_mode_kind(), before); } +#[tokio::test] +async fn mode_switch_surfaces_model_change_notification_when_effective_model_changes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let default_model = chat.current_model().to_string(); + + let mut plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mode"); + plan_mask.model = Some("gpt-5.1-codex-mini".to_string()); + chat.set_collaboration_mask(plan_mask); + + let plan_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + plan_messages.contains("Model changed to gpt-5.1-codex-mini medium for Plan mode."), + "expected Plan-mode model switch notice, got: {plan_messages:?}" + ); + + let default_mask = collaboration_modes::default_mask(chat.models_manager.as_ref()) + .expect("expected default collaboration mode"); + chat.set_collaboration_mask(default_mask); + + let default_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + let expected_default_message = + format!("Model changed to {default_model} default for Default mode."); + assert!( + default_messages.contains(&expected_default_message), + "expected Default-mode model switch notice, got: {default_messages:?}" + ); +} + +#[tokio::test] +async fn mode_switch_surfaces_reasoning_change_notification_when_model_stays_same() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + + let plan_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + plan_messages.contains("Model changed to gpt-5.3-codex medium for Plan mode."), + "expected reasoning-change notice in Plan mode, got: {plan_messages:?}" + ); +} + #[tokio::test] async fn collab_slash_command_opens_picker_and_updates_mode() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -3648,7 +3785,12 @@ async fn plan_slash_command_switches_to_plan_mode() { chat.dispatch_command(SlashCommand::Plan); - assert!(rx.try_recv().is_err(), "plan should not emit an app event"); + while let Ok(event) = rx.try_recv() { + assert!( + matches!(event, AppEvent::InsertHistoryCell(_)), + "plan should not emit a non-history app event: {event:?}" + ); + } assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert_eq!(chat.current_collaboration_mode(), &initial); } @@ -4582,6 +4724,42 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { ); } +#[tokio::test] +async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetch() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.features.enable(Feature::Apps); + chat.bottom_pane.set_connectors_enabled(true); + chat.connectors_prefetch_in_flight = true; + chat.connectors_force_refetch_pending = true; + + let full_connectors = vec![codex_chatgpt::connectors::AppInfo { + id: "unit_test_apps_refresh_failure_pending_connector".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + }]; + chat.connectors_cache = ConnectorsCacheState::Ready(ConnectorsSnapshot { + connectors: full_connectors.clone(), + }); + + chat.on_connectors_loaded(Err("failed to load apps".to_string()), true); + + assert!(chat.connectors_prefetch_in_flight); + assert!(!chat.connectors_force_refetch_pending); + assert_matches!( + &chat.connectors_cache, + ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors + ); +} + #[tokio::test] async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index a3f32d298d..38faf2d35b 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -332,6 +332,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -360,6 +361,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_non_loopback_admin={dangerously_allow_non_loopback_admin}" )); } + if let Some(dangerously_allow_all_unix_sockets) = dangerously_allow_all_unix_sockets { + parts.push(format!( + "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" + )); + } if let Some(allowed_domains) = allowed_domains { parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); } diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index e26bb8785a..57df8b8891 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -3,7 +3,9 @@ use crate::render::line_utils::prefix_lines; use crate::text_formatting::truncate_text; use codex_core::protocol::AgentStatus; use codex_core::protocol::CollabAgentInteractionEndEvent; +use codex_core::protocol::CollabAgentRef; use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabAgentStatusEntry; use codex_core::protocol::CollabCloseEndEvent; use codex_core::protocol::CollabResumeBeginEvent; use codex_core::protocol::CollabResumeEndEvent; @@ -14,282 +16,517 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use std::collections::HashMap; +use std::collections::HashSet; const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; +#[derive(Clone, Copy)] +struct AgentLabel<'a> { + thread_id: Option, + nickname: Option<&'a str>, + role: Option<&'a str>, +} + pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { let CollabAgentSpawnEndEvent { - call_id, + call_id: _, sender_thread_id: _, new_thread_id, + new_agent_nickname, + new_agent_role, prompt, - status, + status: _, } = ev; - let new_agent = new_thread_id - .map(|id| Span::from(id.to_string())) - .unwrap_or_else(|| Span::from("not created").dim()); - let mut details = vec![ - detail_line("call", call_id), - detail_line("agent", new_agent), - status_line(&status), - ]; + + let title = match new_thread_id { + Some(thread_id) => title_with_agent( + "Spawned", + AgentLabel { + thread_id: Some(thread_id), + nickname: new_agent_nickname.as_deref(), + role: new_agent_role.as_deref(), + }, + ), + None => title_text("Agent spawn failed"), + }; + + let mut details = Vec::new(); if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Agent spawned", details) + collab_event(title, details) } pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { let CollabAgentInteractionEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, prompt, - status, + status: _, } = ev; - let mut details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; + + let title = title_with_agent( + "Sent input to", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ); + + let mut details = Vec::new(); if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Input sent", details) + collab_event(title, details) } pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { - call_id, sender_thread_id: _, receiver_thread_ids, + receiver_agents, + call_id: _, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receivers", format_thread_ids(&receiver_thread_ids)), - ]; - collab_event("Waiting for agents", details) + let receiver_agents = merge_wait_receivers(&receiver_thread_ids, receiver_agents); + + let title = match receiver_agents.as_slice() { + [receiver] => title_with_agent("Waiting for", agent_label_from_ref(receiver)), + [] => title_text("Waiting for agents"), + _ => title_text(format!("Waiting for {} agents", receiver_agents.len())), + }; + + let details = if receiver_agents.len() > 1 { + receiver_agents + .iter() + .map(|receiver| agent_label_line(agent_label_from_ref(receiver))) + .collect() + } else { + Vec::new() + }; + + collab_event(title, details) } pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { - call_id, + call_id: _, sender_thread_id: _, + agent_statuses, statuses, } = ev; - let mut details = vec![detail_line("call", call_id)]; - details.extend(wait_complete_lines(&statuses)); - collab_event("Wait complete", details) + let details = wait_complete_lines(&statuses, &agent_statuses); + collab_event(title_text("Finished waiting"), details) } pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { let CollabCloseEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, - status, + receiver_agent_nickname, + receiver_agent_role, + status: _, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; - collab_event("Agent closed", details) + + collab_event( + title_with_agent( + "Closed", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + Vec::new(), + ) } pub(crate) fn resume_begin(ev: CollabResumeBeginEvent) -> PlainHistoryCell { let CollabResumeBeginEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - ]; - collab_event("Resuming agent", details) + + collab_event( + title_with_agent( + "Resuming", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + Vec::new(), + ) } pub(crate) fn resume_end(ev: CollabResumeEndEvent) -> PlainHistoryCell { let CollabResumeEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, status, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; - collab_event("Agent resumed", details) + + collab_event( + title_with_agent( + "Resumed", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + vec![status_summary_line(&status)], + ) } -fn collab_event(title: impl Into, details: Vec>) -> PlainHistoryCell { - let title = title.into(); - let mut lines: Vec> = - vec![vec![Span::from("• ").dim(), Span::from(title).bold()].into()]; +fn collab_event(title: Line<'static>, details: Vec>) -> PlainHistoryCell { + let mut lines: Vec> = vec![title]; if !details.is_empty() { lines.extend(prefix_lines(details, " └ ".dim(), " ".into())); } PlainHistoryCell::new(lines) } -fn detail_line(label: &str, value: impl Into>) -> Line<'static> { - vec![Span::from(format!("{label}: ")).dim(), value.into()].into() +fn title_text(title: impl Into) -> Line<'static> { + title_spans_line(vec![Span::from(title.into()).bold()]) } -fn status_line(status: &AgentStatus) -> Line<'static> { - detail_line("status", status_span(status)) +fn title_with_agent(prefix: &str, agent: AgentLabel<'_>) -> Line<'static> { + let mut spans = vec![Span::from(format!("{prefix} ")).bold()]; + spans.extend(agent_label_spans(agent)); + title_spans_line(spans) } -fn status_span(status: &AgentStatus) -> Span<'static> { - match status { - AgentStatus::PendingInit => Span::from("pending init").dim(), - AgentStatus::Running => Span::from("running").cyan().bold(), - AgentStatus::Completed(_) => Span::from("completed").green(), - AgentStatus::Errored(_) => Span::from("errored").red(), - AgentStatus::Shutdown => Span::from("shutdown").dim(), - AgentStatus::NotFound => Span::from("not found").red(), +fn title_spans_line(mut spans: Vec>) -> Line<'static> { + let mut title = Vec::with_capacity(spans.len() + 1); + title.push(Span::from("• ").dim()); + title.append(&mut spans); + title.into() +} + +fn agent_label_from_ref(agent: &CollabAgentRef) -> AgentLabel<'_> { + AgentLabel { + thread_id: Some(agent.thread_id), + nickname: agent.agent_nickname.as_deref(), + role: agent.agent_role.as_deref(), } } +fn agent_label_line(agent: AgentLabel<'_>) -> Line<'static> { + agent_label_spans(agent).into() +} + +fn agent_label_spans(agent: AgentLabel<'_>) -> Vec> { + let mut spans = Vec::new(); + let nickname = agent + .nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let role = agent.role.map(str::trim).filter(|role| !role.is_empty()); + + if let Some(nickname) = nickname { + spans.push(Span::from(nickname.to_string()).light_blue().bold()); + } else if let Some(thread_id) = agent.thread_id { + spans.push(Span::from(thread_id.to_string()).dim()); + } else { + spans.push(Span::from("agent").dim()); + } + + if let Some(role) = role { + spans.push(Span::from(" ").dim()); + spans.push(Span::from(format!("[{role}]")).dim()); + } + + spans +} + fn prompt_line(prompt: &str) -> Option> { let trimmed = prompt.trim(); if trimmed.is_empty() { None } else { - Some(detail_line( - "prompt", - Span::from(truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES)).dim(), - )) + Some(Line::from(Span::from(truncate_text( + trimmed, + COLLAB_PROMPT_PREVIEW_GRAPHEMES, + )))) } } -fn format_thread_ids(ids: &[ThreadId]) -> Span<'static> { - if ids.is_empty() { - return Span::from("none").dim(); +fn merge_wait_receivers( + receiver_thread_ids: &[ThreadId], + mut receiver_agents: Vec, +) -> Vec { + if receiver_agents.is_empty() { + return receiver_thread_ids + .iter() + .map(|thread_id| CollabAgentRef { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + }) + .collect(); } - let joined = ids + + let mut seen = receiver_agents .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - Span::from(joined) -} - -fn wait_complete_lines(statuses: &HashMap) -> Vec> { - if statuses.is_empty() { - return vec![detail_line("agents", Span::from("none").dim())]; - } - - let mut pending_init = 0usize; - let mut running = 0usize; - let mut completed = 0usize; - let mut errored = 0usize; - let mut shutdown = 0usize; - let mut not_found = 0usize; - for status in statuses.values() { - match status { - AgentStatus::PendingInit => pending_init += 1, - AgentStatus::Running => running += 1, - AgentStatus::Completed(_) => completed += 1, - AgentStatus::Errored(_) => errored += 1, - AgentStatus::Shutdown => shutdown += 1, - AgentStatus::NotFound => not_found += 1, + .map(|agent| agent.thread_id) + .collect::>(); + for thread_id in receiver_thread_ids { + if seen.insert(*thread_id) { + receiver_agents.push(CollabAgentRef { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + }); } } + receiver_agents +} - let mut summary = vec![Span::from(format!("{} total", statuses.len())).dim()]; - push_status_count( - &mut summary, - pending_init, - "pending init", - ratatui::prelude::Stylize::dim, - ); - push_status_count(&mut summary, running, "running", |span| span.cyan().bold()); - push_status_count( - &mut summary, - completed, - "completed", - ratatui::prelude::Stylize::green, - ); - push_status_count( - &mut summary, - errored, - "errored", - ratatui::prelude::Stylize::red, - ); - push_status_count( - &mut summary, - shutdown, - "shutdown", - ratatui::prelude::Stylize::dim, - ); - push_status_count( - &mut summary, - not_found, - "not found", - ratatui::prelude::Stylize::red, - ); +fn wait_complete_lines( + statuses: &HashMap, + agent_statuses: &[CollabAgentStatusEntry], +) -> Vec> { + if statuses.is_empty() && agent_statuses.is_empty() { + return vec![Line::from(Span::from("No agents completed yet").dim())]; + } - let mut entries: Vec<(String, &AgentStatus)> = statuses - .iter() - .map(|(thread_id, status)| (thread_id.to_string(), status)) - .collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let entries = if agent_statuses.is_empty() { + let mut entries = statuses + .iter() + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + entries.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries + } else { + let mut entries = agent_statuses.to_vec(); + let seen = entries + .iter() + .map(|entry| entry.thread_id) + .collect::>(); + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries + }; - let mut lines = Vec::with_capacity(entries.len() + 1); - lines.push(detail_line_spans("agents", summary)); - lines.extend(entries.into_iter().map(|(thread_id, status)| { - let mut spans = vec![ - Span::from(thread_id).dim(), - Span::from(" ").dim(), - status_span(status), - ]; - match status { - AgentStatus::Completed(Some(message)) => { + entries + .into_iter() + .map(|entry| { + let CollabAgentStatusEntry { + thread_id, + agent_nickname, + agent_role, + status, + } = entry; + let mut spans = agent_label_spans(AgentLabel { + thread_id: Some(thread_id), + nickname: agent_nickname.as_deref(), + role: agent_role.as_deref(), + }); + spans.push(Span::from(": ").dim()); + spans.extend(status_summary_spans(&status)); + spans.into() + }) + .collect() +} + +fn status_summary_line(status: &AgentStatus) -> Line<'static> { + status_summary_spans(status).into() +} + +fn status_summary_spans(status: &AgentStatus) -> Vec> { + match status { + AgentStatus::PendingInit => vec![Span::from("Pending init").dim()], + AgentStatus::Running => vec![Span::from("Running").cyan().bold()], + AgentStatus::Completed(message) => { + let mut spans = vec![Span::from("Completed").green()]; + if let Some(message) = message.as_ref() { let message_preview = truncate_text( &message.split_whitespace().collect::>().join(" "), COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES, ); - spans.push(Span::from(": ").dim()); - spans.push(Span::from(message_preview)); + if !message_preview.is_empty() { + spans.push(Span::from(" - ").dim()); + spans.push(Span::from(message_preview)); + } } - AgentStatus::Errored(error) => { - let error_preview = truncate_text( - &error.split_whitespace().collect::>().join(" "), - COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, - ); - spans.push(Span::from(": ").dim()); + spans + } + AgentStatus::Errored(error) => { + let mut spans = vec![Span::from("Error").red()]; + let error_preview = truncate_text( + &error.split_whitespace().collect::>().join(" "), + COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, + ); + if !error_preview.is_empty() { + spans.push(Span::from(" - ").dim()); spans.push(Span::from(error_preview).dim()); } - _ => {} + spans } - spans.into() - })); - lines + AgentStatus::Shutdown => vec![Span::from("Shutdown").dim()], + AgentStatus::NotFound => vec![Span::from("Not found").red()], + } } -fn push_status_count( - spans: &mut Vec>, - count: usize, - label: &'static str, - style: impl FnOnce(Span<'static>) -> Span<'static>, -) { - if count == 0 { - return; +#[cfg(test)] +mod tests { + use super::*; + use crate::history_cell::HistoryCell; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::style::Color; + use ratatui::style::Modifier; + + #[test] + fn collab_events_snapshot() { + let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"); + let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") + .expect("valid robie thread id"); + let bob_id = ThreadId::from_string("00000000-0000-0000-0000-000000000003") + .expect("valid bob thread id"); + + let spawn = spawn_end(CollabAgentSpawnEndEvent { + call_id: "call-spawn".to_string(), + sender_thread_id, + new_thread_id: Some(robie_id), + new_agent_nickname: Some("Robie".to_string()), + new_agent_role: Some("explorer".to_string()), + prompt: "Compute 11! and reply with just the integer result.".to_string(), + status: AgentStatus::PendingInit, + }); + + let send = interaction_end(CollabAgentInteractionEndEvent { + call_id: "call-send".to_string(), + sender_thread_id, + receiver_thread_id: robie_id, + receiver_agent_nickname: Some("Robie".to_string()), + receiver_agent_role: Some("explorer".to_string()), + prompt: "Please continue and return the answer only.".to_string(), + status: AgentStatus::Running, + }); + + let waiting = waiting_begin(CollabWaitingBeginEvent { + sender_thread_id, + receiver_thread_ids: vec![robie_id], + receiver_agents: vec![CollabAgentRef { + thread_id: robie_id, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + }], + call_id: "call-wait".to_string(), + }); + + let mut statuses = HashMap::new(); + statuses.insert( + robie_id, + AgentStatus::Completed(Some("39916800".to_string())), + ); + statuses.insert(bob_id, AgentStatus::Errored("tool timeout".to_string())); + let finished = waiting_end(CollabWaitingEndEvent { + sender_thread_id, + call_id: "call-wait".to_string(), + agent_statuses: vec![ + CollabAgentStatusEntry { + thread_id: robie_id, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + status: AgentStatus::Completed(Some("39916800".to_string())), + }, + CollabAgentStatusEntry { + thread_id: bob_id, + agent_nickname: Some("Bob".to_string()), + agent_role: Some("worker".to_string()), + status: AgentStatus::Errored("tool timeout".to_string()), + }, + ], + statuses, + }); + + let close = close_end(CollabCloseEndEvent { + call_id: "call-close".to_string(), + sender_thread_id, + receiver_thread_id: robie_id, + receiver_agent_nickname: Some("Robie".to_string()), + receiver_agent_role: Some("explorer".to_string()), + status: AgentStatus::Completed(Some("39916800".to_string())), + }); + + let snapshot = [spawn, send, waiting, finished, close] + .iter() + .map(cell_to_text) + .collect::>() + .join("\n\n"); + assert_snapshot!("collab_agent_transcript", snapshot); } - spans.push(Span::from(" · ").dim()); - spans.push(style(Span::from(format!("{count} {label}")))); -} + #[test] + fn title_styles_nickname_and_role() { + let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"); + let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") + .expect("valid robie thread id"); + let cell = spawn_end(CollabAgentSpawnEndEvent { + call_id: "call-spawn".to_string(), + sender_thread_id, + new_thread_id: Some(robie_id), + new_agent_nickname: Some("Robie".to_string()), + new_agent_role: Some("explorer".to_string()), + prompt: String::new(), + status: AgentStatus::PendingInit, + }); -fn detail_line_spans(label: &str, mut value: Vec>) -> Line<'static> { - let mut spans = Vec::with_capacity(value.len() + 1); - spans.push(Span::from(format!("{label}: ")).dim()); - spans.append(&mut value); - spans.into() + let lines = cell.display_lines(200); + let title = &lines[0]; + assert_eq!(title.spans[2].content.as_ref(), "Robie"); + assert_eq!(title.spans[2].style.fg, Some(Color::LightBlue)); + assert!(title.spans[2].style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(title.spans[4].content.as_ref(), "[explorer]"); + assert!(title.spans[4].style.add_modifier.contains(Modifier::DIM)); + } + + fn cell_to_text(cell: &PlainHistoryCell) -> String { + cell.display_lines(200) + .iter() + .map(line_to_text) + .collect::>() + .join("\n") + } + + fn line_to_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>() + .join("") + } } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index c9aa9aeced..6cb1387a4e 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1327,17 +1327,10 @@ mod tests { fn make_item(path: &str, ts: &str, preview: &str) -> ThreadItem { ThreadItem { path: PathBuf::from(path), - thread_id: None, first_user_message: Some(preview.to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some(ts.to_string()), updated_at: Some(ts.to_string()), + ..Default::default() } } @@ -1462,17 +1455,10 @@ mod tests { fn head_to_row_uses_first_user_message() { let item = ThreadItem { path: PathBuf::from("/tmp/a.jsonl"), - thread_id: None, first_user_message: Some("real question".to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T00:00:00Z".into()), + ..Default::default() }; let row = head_to_row(&item); assert_eq!(row.preview, "real question"); @@ -1483,31 +1469,17 @@ mod tests { // Construct two items with different timestamps and real user text. let a = ThreadItem { path: PathBuf::from("/tmp/a.jsonl"), - thread_id: None, first_user_message: Some("A".to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T00:00:00Z".into()), + ..Default::default() }; let b = ThreadItem { path: PathBuf::from("/tmp/b.jsonl"), - thread_id: None, first_user_message: Some("B".to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some("2025-01-02T00:00:00Z".into()), updated_at: Some("2025-01-02T00:00:00Z".into()), + ..Default::default() }; let rows = rows_from_items(vec![a, b]); assert_eq!(rows.len(), 2); @@ -1520,17 +1492,10 @@ mod tests { fn row_uses_tail_timestamp_for_updated_at() { let item = ThreadItem { path: PathBuf::from("/tmp/a.jsonl"), - thread_id: None, first_user_message: Some("Hello".to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T01:00:00Z".into()), + ..Default::default() }; let row = head_to_row(&item); diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap new file mode 100644 index 0000000000..0337f424a8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/app.rs +expression: snapshot +--- +Main [default] | 00000000-0000-0000-0000-000000000123 +Robie [explorer] | 00000000-0000-0000-0000-000000000123 +Robie | 00000000-0000-0000-0000-000000000123 +[explorer] | 00000000-0000-0000-0000-000000000123 +Agent | 00000000-0000-0000-0000-000000000123 diff --git a/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap new file mode 100644 index 0000000000..19001a70df --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/multi_agents.rs +expression: snapshot +--- +• Spawned Robie [explorer] + └ Compute 11! and reply with just the integer result. + +• Sent input to Robie [explorer] + └ Please continue and return the answer only. + +• Waiting for Robie [explorer] + +• Finished waiting + └ Robie [explorer]: Completed - 39916800 + Bob [worker]: Error - tool timeout + +• Closed Robie [explorer] diff --git a/codex-rs/utils/sanitizer/BUILD.bazel b/codex-rs/utils/sanitizer/BUILD.bazel deleted file mode 100644 index 3cc7465bd7..0000000000 --- a/codex-rs/utils/sanitizer/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "sanitizer", - crate_name = "codex_utils_sanitizer", -) diff --git a/codex-rs/utils/sanitizer/Cargo.toml b/codex-rs/utils/sanitizer/Cargo.toml deleted file mode 100644 index 6f96cc891c..0000000000 --- a/codex-rs/utils/sanitizer/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "codex-utils-sanitizer" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -regex = "1.12.3" - -[lints] -workspace = true diff --git a/codex-rs/utils/sleep-inhibitor/Cargo.toml b/codex-rs/utils/sleep-inhibitor/Cargo.toml index 703433d819..f691b191e1 100644 --- a/codex-rs/utils/sleep-inhibitor/Cargo.toml +++ b/codex-rs/utils/sleep-inhibitor/Cargo.toml @@ -9,5 +9,4 @@ workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" -libc = { workspace = true } tracing = { workspace = true } diff --git a/codex-rs/utils/sleep-inhibitor/src/dummy.rs b/codex-rs/utils/sleep-inhibitor/src/dummy.rs index 767091ea93..a0be0dc650 100644 --- a/codex-rs/utils/sleep-inhibitor/src/dummy.rs +++ b/codex-rs/utils/sleep-inhibitor/src/dummy.rs @@ -1,16 +1,12 @@ -use crate::PlatformSleepInhibitor; - #[derive(Debug, Default)] -pub(crate) struct DummySleepInhibitor; +pub(crate) struct SleepInhibitor; -impl DummySleepInhibitor { +impl SleepInhibitor { pub(crate) fn new() -> Self { Self } -} -impl PlatformSleepInhibitor for DummySleepInhibitor { - fn acquire(&mut self) {} + pub(crate) fn acquire(&mut self) {} - fn release(&mut self) {} + pub(crate) fn release(&mut self) {} } diff --git a/codex-rs/utils/sleep-inhibitor/src/iokit_bindings.rs b/codex-rs/utils/sleep-inhibitor/src/iokit_bindings.rs new file mode 100644 index 0000000000..50fd7fe510 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/iokit_bindings.rs @@ -0,0 +1,27 @@ +/* automatically generated by rust-bindgen 0.71.1 */ + +pub const kIOReturnSuccess: u32 = 0; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct __CFString { + _unused: [u8; 0], +} +pub type CFStringRef = *const __CFString; +pub type kern_return_t = ::std::os::raw::c_int; +pub type IOReturn = kern_return_t; +pub type IOPMAssertionID = u32; +pub type IOPMAssertionLevel = u32; +pub const kIOPMAssertionLevelOff: _bindgen_ty_36 = 0; +pub const kIOPMAssertionLevelOn: _bindgen_ty_36 = 255; +pub type _bindgen_ty_36 = ::std::os::raw::c_uint; +unsafe extern "C" { + pub fn IOPMAssertionRelease(AssertionID: IOPMAssertionID) -> IOReturn; +} +unsafe extern "C" { + pub fn IOPMAssertionCreateWithName( + AssertionType: CFStringRef, + AssertionLevel: IOPMAssertionLevel, + AssertionName: CFStringRef, + AssertionID: *mut IOPMAssertionID, + ) -> IOReturn; +} diff --git a/codex-rs/utils/sleep-inhibitor/src/lib.rs b/codex-rs/utils/sleep-inhibitor/src/lib.rs index 0f2f32df64..810fb170a2 100644 --- a/codex-rs/utils/sleep-inhibitor/src/lib.rs +++ b/codex-rs/utils/sleep-inhibitor/src/lib.rs @@ -6,31 +6,26 @@ #[cfg(not(target_os = "macos"))] mod dummy; #[cfg(target_os = "macos")] -mod macos_inhibitor; +mod macos; -use std::fmt::Debug; +#[cfg(not(target_os = "macos"))] +use dummy as imp; +#[cfg(target_os = "macos")] +use macos as imp; /// Keeps the machine awake while a turn is in progress when enabled. #[derive(Debug)] pub struct SleepInhibitor { enabled: bool, - platform: Box, -} - -pub(crate) trait PlatformSleepInhibitor: Debug { - fn acquire(&mut self); - fn release(&mut self); + platform: imp::SleepInhibitor, } impl SleepInhibitor { pub fn new(enabled: bool) -> Self { - #[cfg(target_os = "macos")] - let platform: Box = - Box::new(macos_inhibitor::MacOsSleepInhibitor::new()); - #[cfg(not(target_os = "macos"))] - let platform: Box = Box::new(dummy::DummySleepInhibitor::new()); - - Self { enabled, platform } + Self { + enabled, + platform: imp::SleepInhibitor::new(), + } } /// Update the active turn state; turns sleep prevention on/off as needed. diff --git a/codex-rs/utils/sleep-inhibitor/src/macos.rs b/codex-rs/utils/sleep-inhibitor/src/macos.rs new file mode 100644 index 0000000000..b8aaf29ab3 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/macos.rs @@ -0,0 +1,107 @@ +use core_foundation::base::TCFType; +use core_foundation::string::CFString; +use tracing::warn; + +#[allow( + dead_code, + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + clippy::all +)] +mod iokit { + #[link(name = "IOKit", kind = "framework")] + unsafe extern "C" {} + + include!("iokit_bindings.rs"); +} + +type IOPMAssertionID = iokit::IOPMAssertionID; +type IOPMAssertionLevel = iokit::IOPMAssertionLevel; +type IOReturn = iokit::IOReturn; + +const ASSERTION_REASON: &str = "Codex is running an active turn"; +// Apple exposes this assertion type as a `CFSTR(...)` macro, so bindgen cannot generate a +// reusable `CFStringRef` constant for it. +const ASSERTION_TYPE_PREVENT_USER_IDLE_SYSTEM_SLEEP: &str = "PreventUserIdleSystemSleep"; + +#[derive(Debug, Default)] +pub(crate) struct SleepInhibitor { + assertion: Option, +} + +impl SleepInhibitor { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn acquire(&mut self) { + if self.assertion.is_some() { + return; + } + + match MacSleepAssertion::create(ASSERTION_REASON) { + Ok(assertion) => { + self.assertion = Some(assertion); + } + Err(error) => { + warn!( + iokit_error = error, + "Failed to create macOS sleep-prevention assertion" + ); + } + } + } + + pub(crate) fn release(&mut self) { + self.assertion = None; + } +} + +#[derive(Debug)] +struct MacSleepAssertion { + id: IOPMAssertionID, +} + +impl MacSleepAssertion { + fn create(name: &str) -> Result { + let assertion_type = CFString::new(ASSERTION_TYPE_PREVENT_USER_IDLE_SYSTEM_SLEEP); + let assertion_name = CFString::new(name); + let mut id: IOPMAssertionID = 0; + // `core-foundation` and the generated bindings each declare an opaque `__CFString` type, + // so we cast to the bindgen pointer aliases before crossing the FFI boundary. + let assertion_type_ref: iokit::CFStringRef = assertion_type.as_concrete_TypeRef().cast(); + let assertion_name_ref: iokit::CFStringRef = assertion_name.as_concrete_TypeRef().cast(); + let result = unsafe { + // SAFETY: `assertion_type_ref` and `assertion_name_ref` are valid `CFStringRef`s and + // `&mut id` is a valid out-pointer for `IOPMAssertionCreateWithName` to initialize. + iokit::IOPMAssertionCreateWithName( + assertion_type_ref, + iokit::kIOPMAssertionLevelOn as IOPMAssertionLevel, + assertion_name_ref, + &mut id, + ) + }; + if result == iokit::kIOReturnSuccess as IOReturn { + Ok(Self { id }) + } else { + Err(result) + } + } +} + +impl Drop for MacSleepAssertion { + fn drop(&mut self) { + let result = unsafe { + // SAFETY: `self.id` was returned by `IOPMAssertionCreateWithName` and this `Drop` + // implementation releases it exactly once when the owning assertion is dropped. + iokit::IOPMAssertionRelease(self.id) + }; + if result != iokit::kIOReturnSuccess as IOReturn { + warn!( + iokit_error = result, + "Failed to release macOS sleep-prevention assertion" + ); + } + } +} diff --git a/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs b/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs deleted file mode 100644 index e788705e1c..0000000000 --- a/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::PlatformSleepInhibitor; -use core_foundation::base::TCFType; -use core_foundation::string::CFString; -use core_foundation::string::CFStringRef; -use std::fmt::Debug; -use std::sync::OnceLock; -use tracing::warn; - -const ASSERTION_REASON: &str = "Codex is running an active turn"; -const MACOS_IDLE_SLEEP_ASSERTION_TYPE: &str = "PreventUserIdleSystemSleep"; -const IOKIT_FRAMEWORK_BINARY: &[u8] = b"/System/Library/Frameworks/IOKit.framework/IOKit\0"; -const IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL: &[u8] = b"IOPMAssertionCreateWithName\0"; -const IOPM_ASSERTION_RELEASE_SYMBOL: &[u8] = b"IOPMAssertionRelease\0"; -const IOKIT_ASSERTION_API_UNAVAILABLE: &str = "IOKit power assertion APIs are unavailable"; - -type IOPMAssertionReleaseFn = unsafe extern "C" fn(assertion_id: IOPMAssertionID) -> IOReturn; -type IOPMAssertionID = u32; -type IOPMAssertionLevel = u32; -type IOReturn = i32; - -const K_IOPM_ASSERTION_LEVEL_ON: IOPMAssertionLevel = 255; -const K_IORETURN_SUCCESS: IOReturn = 0; - -#[derive(Debug, Default)] -pub(crate) struct MacOsSleepInhibitor { - assertion: Option, -} - -impl MacOsSleepInhibitor { - pub(crate) fn new() -> Self { - Self { - ..Default::default() - } - } -} - -impl PlatformSleepInhibitor for MacOsSleepInhibitor { - fn acquire(&mut self) { - if self.assertion.is_some() { - return; - } - - match MacSleepAssertion::create(ASSERTION_REASON) { - Ok(assertion) => { - self.assertion = Some(assertion); - } - Err(error) => match error { - MacSleepAssertionError::ApiUnavailable(reason) => { - warn!(reason, "Failed to create macOS sleep-prevention assertion"); - } - MacSleepAssertionError::Iokit(code) => { - warn!( - iokit_error = code, - "Failed to create macOS sleep-prevention assertion" - ); - } - }, - } - } - - fn release(&mut self) { - self.assertion = None; - } -} - -#[derive(Debug)] -struct MacSleepAssertion { - id: IOPMAssertionID, -} - -impl MacSleepAssertion { - fn create(name: &str) -> Result { - let Some(api) = MacSleepApi::get() else { - return Err(MacSleepAssertionError::ApiUnavailable( - IOKIT_ASSERTION_API_UNAVAILABLE, - )); - }; - - let assertion_type = CFString::new(MACOS_IDLE_SLEEP_ASSERTION_TYPE); - let assertion_name = CFString::new(name); - let mut id: IOPMAssertionID = 0; - let result = unsafe { - (api.create_with_name)( - assertion_type.as_concrete_TypeRef(), - K_IOPM_ASSERTION_LEVEL_ON, - assertion_name.as_concrete_TypeRef(), - &mut id, - ) - }; - if result == K_IORETURN_SUCCESS { - Ok(Self { id }) - } else { - Err(MacSleepAssertionError::Iokit(result)) - } - } -} - -impl Drop for MacSleepAssertion { - fn drop(&mut self) { - let Some(api) = MacSleepApi::get() else { - warn!( - reason = IOKIT_ASSERTION_API_UNAVAILABLE, - "Failed to release macOS sleep-prevention assertion" - ); - return; - }; - - let result = unsafe { (api.release)(self.id) }; - if result != K_IORETURN_SUCCESS { - warn!( - iokit_error = result, - "Failed to release macOS sleep-prevention assertion" - ); - } - } -} - -#[derive(Debug, Clone, Copy)] -enum MacSleepAssertionError { - ApiUnavailable(&'static str), - Iokit(IOReturn), -} - -type IOPMAssertionCreateWithNameFn = unsafe extern "C" fn( - assertion_type: CFStringRef, - assertion_level: IOPMAssertionLevel, - assertion_name: CFStringRef, - assertion_id: *mut IOPMAssertionID, -) -> IOReturn; - -struct MacSleepApi { - // Keep the dlopen handle alive for the lifetime of the loaded symbols. - // This prevents accidental dlclose while function pointers are in use. - _iokit_handle: usize, - create_with_name: IOPMAssertionCreateWithNameFn, - release: IOPMAssertionReleaseFn, -} - -impl MacSleepApi { - fn get() -> Option<&'static Self> { - static API: OnceLock> = OnceLock::new(); - API.get_or_init(Self::load).as_ref() - } - - fn load() -> Option { - let handle = unsafe { - libc::dlopen( - IOKIT_FRAMEWORK_BINARY.as_ptr().cast(), - libc::RTLD_LOCAL | libc::RTLD_LAZY, - ) - }; - if handle.is_null() { - warn!(framework = "IOKit", "Failed to open IOKit framework"); - return None; - } - - let create_with_name = unsafe { - libc::dlsym( - handle, - IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL.as_ptr().cast(), - ) - }; - if create_with_name.is_null() { - warn!( - symbol = "IOPMAssertionCreateWithName", - "Failed to load IOKit symbol" - ); - let _ = unsafe { libc::dlclose(handle) }; - return None; - } - - let release = unsafe { libc::dlsym(handle, IOPM_ASSERTION_RELEASE_SYMBOL.as_ptr().cast()) }; - if release.is_null() { - warn!( - symbol = "IOPMAssertionRelease", - "Failed to load IOKit symbol" - ); - let _ = unsafe { libc::dlclose(handle) }; - return None; - } - - let create_with_name: IOPMAssertionCreateWithNameFn = - unsafe { std::mem::transmute(create_with_name) }; - let release: IOPMAssertionReleaseFn = unsafe { std::mem::transmute(release) }; - - Some(Self { - _iokit_handle: handle as usize, - create_with_name, - release, - }) - } -} diff --git a/docs/install.md b/docs/install.md index ef14f5d840..16892c82fe 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,7 +27,7 @@ rustup component add clippy # Install helper tools used by the workspace justfile: cargo install just # Optional: install nextest for the `just test` helper -cargo install cargo-nextest +cargo install --locked cargo-nextest # Build Codex. cargo build diff --git a/docs/js_repl.md b/docs/js_repl.md index a9b8b7228e..eb0ea84ffd 100644 --- a/docs/js_repl.md +++ b/docs/js_repl.md @@ -63,7 +63,6 @@ For `CODEX_JS_REPL_NODE_MODULE_DIRS` and `js_repl_node_module_dirs`, module reso `js_repl` exposes these globals: -- `codex.state`: mutable object persisted for the current kernel session. - `codex.tmpDir`: per-session scratch directory path. - `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl` (including shell tools like `shell` / `shell_command` when available). - To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool("view_image", { path: "/absolute/path" })`, then delete the file.