Fix turn context update ordering

This commit is contained in:
Eric Traut
2026-05-16 17:07:08 -07:00
parent 9af52a593b
commit 579abe122b
16 changed files with 863 additions and 271 deletions

View File

@@ -77,14 +77,6 @@
"id": {
"description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.<id>]` profile.",
"type": "string"
},
"modifications": {
"default": [],
"description": "Bounded user-requested modifications applied on top of the named profile, if any.",
"items": {
"$ref": "#/definitions/ActivePermissionProfileModification"
},
"type": "array"
}
},
"required": [
@@ -92,31 +84,6 @@
],
"type": "object"
},
"ActivePermissionProfileModification": {
"oneOf": [
{
"description": "Additional concrete directory that should be writable.",
"properties": {
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"additionalWritableRoot"
],
"title": "AdditionalWritableRootActivePermissionProfileModificationType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "AdditionalWritableRootActivePermissionProfileModification",
"type": "object"
}
]
},
"AdditionalFileSystemPermissions": {
"properties": {
"entries": {
@@ -2211,6 +2178,57 @@
],
"type": "object"
},
"ManagedFileSystemPermissions": {
"oneOf": [
{
"properties": {
"entries": {
"items": {
"$ref": "#/definitions/FileSystemSandboxEntry"
},
"type": "array"
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 1.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedManagedFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"entries",
"type"
],
"title": "RestrictedManagedFileSystemPermissions",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"unrestricted"
],
"title": "UnrestrictedManagedFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UnrestrictedManagedFileSystemPermissions",
"type": "object"
}
]
},
"McpServerOauthLoginCompletedNotification": {
"properties": {
"error": {
@@ -2470,6 +2488,13 @@
],
"type": "string"
},
"NetworkSandboxPolicy": {
"enum": [
"enabled",
"restricted"
],
"type": "string"
},
"NonSteerableTurnKind": {
"enum": [
"review",
@@ -2547,13 +2572,12 @@
"PermissionProfile": {
"oneOf": [
{
"description": "Codex owns sandbox construction for this profile.",
"properties": {
"fileSystem": {
"$ref": "#/definitions/PermissionProfileFileSystemPermissions"
"$ref": "#/definitions/ManagedFileSystemPermissions"
},
"network": {
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
"$ref": "#/definitions/NetworkSandboxPolicy"
},
"type": {
"enum": [
@@ -2572,7 +2596,6 @@
"type": "object"
},
{
"description": "Do not apply an outer sandbox.",
"properties": {
"type": {
"enum": [
@@ -2589,10 +2612,9 @@
"type": "object"
},
{
"description": "Filesystem isolation is enforced by an external caller.",
"properties": {
"network": {
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
"$ref": "#/definitions/NetworkSandboxPolicy"
},
"type": {
"enum": [
@@ -2611,68 +2633,6 @@
}
]
},
"PermissionProfileFileSystemPermissions": {
"oneOf": [
{
"properties": {
"entries": {
"items": {
"$ref": "#/definitions/FileSystemSandboxEntry"
},
"type": "array"
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 1.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedPermissionProfileFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"entries",
"type"
],
"title": "RestrictedPermissionProfileFileSystemPermissions",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"unrestricted"
],
"title": "UnrestrictedPermissionProfileFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UnrestrictedPermissionProfileFileSystemPermissions",
"type": "object"
}
]
},
"PermissionProfileNetworkPermissions": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"Personality": {
"enum": [
"none",

View File

@@ -10430,6 +10430,57 @@
"title": "LogoutAccountResponse",
"type": "object"
},
"ManagedFileSystemPermissions": {
"oneOf": [
{
"properties": {
"entries": {
"items": {
"$ref": "#/definitions/v2/FileSystemSandboxEntry"
},
"type": "array"
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 1.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedManagedFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"entries",
"type"
],
"title": "RestrictedManagedFileSystemPermissions",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"unrestricted"
],
"title": "UnrestrictedManagedFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UnrestrictedManagedFileSystemPermissions",
"type": "object"
}
]
},
"ManagedHooksRequirements": {
"properties": {
"PermissionRequest": {
@@ -11541,6 +11592,13 @@
},
"type": "object"
},
"NetworkSandboxPolicy": {
"enum": [
"enabled",
"restricted"
],
"type": "string"
},
"NetworkUnixSocketPermission": {
"enum": [
"allow",
@@ -11639,6 +11697,70 @@
}
]
},
"PermissionProfile": {
"oneOf": [
{
"properties": {
"fileSystem": {
"$ref": "#/definitions/v2/ManagedFileSystemPermissions"
},
"network": {
"$ref": "#/definitions/v2/NetworkSandboxPolicy"
},
"type": {
"enum": [
"managed"
],
"title": "ManagedPermissionProfileType",
"type": "string"
}
},
"required": [
"fileSystem",
"network",
"type"
],
"title": "ManagedPermissionProfile",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"disabled"
],
"title": "DisabledPermissionProfileType",
"type": "string"
}
},
"required": [
"type"
],
"title": "DisabledPermissionProfile",
"type": "object"
},
{
"properties": {
"network": {
"$ref": "#/definitions/v2/NetworkSandboxPolicy"
},
"type": {
"enum": [
"external"
],
"title": "ExternalPermissionProfileType",
"type": "string"
}
},
"required": [
"network",
"type"
],
"title": "ExternalPermissionProfile",
"type": "object"
}
]
},
"Personality": {
"enum": [
"none",

View File

@@ -6959,6 +6959,57 @@
"title": "LogoutAccountResponse",
"type": "object"
},
"ManagedFileSystemPermissions": {
"oneOf": [
{
"properties": {
"entries": {
"items": {
"$ref": "#/definitions/FileSystemSandboxEntry"
},
"type": "array"
},
"globScanMaxDepth": {
"format": "uint",
"minimum": 1.0,
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedManagedFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"entries",
"type"
],
"title": "RestrictedManagedFileSystemPermissions",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"unrestricted"
],
"title": "UnrestrictedManagedFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UnrestrictedManagedFileSystemPermissions",
"type": "object"
}
]
},
"ManagedHooksRequirements": {
"properties": {
"PermissionRequest": {
@@ -8070,6 +8121,13 @@
},
"type": "object"
},
"NetworkSandboxPolicy": {
"enum": [
"enabled",
"restricted"
],
"type": "string"
},
"NetworkUnixSocketPermission": {
"enum": [
"allow",
@@ -8168,6 +8226,70 @@
}
]
},
"PermissionProfile": {
"oneOf": [
{
"properties": {
"fileSystem": {
"$ref": "#/definitions/ManagedFileSystemPermissions"
},
"network": {
"$ref": "#/definitions/NetworkSandboxPolicy"
},
"type": {
"enum": [
"managed"
],
"title": "ManagedPermissionProfileType",
"type": "string"
}
},
"required": [
"fileSystem",
"network",
"type"
],
"title": "ManagedPermissionProfile",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"disabled"
],
"title": "DisabledPermissionProfileType",
"type": "string"
}
},
"required": [
"type"
],
"title": "DisabledPermissionProfile",
"type": "object"
},
{
"properties": {
"network": {
"$ref": "#/definitions/NetworkSandboxPolicy"
},
"type": {
"enum": [
"external"
],
"title": "ExternalPermissionProfileType",
"type": "string"
}
},
"required": [
"network",
"type"
],
"title": "ExternalPermissionProfile",
"type": "object"
}
]
},
"Personality": {
"enum": [
"none",

View File

@@ -18,14 +18,6 @@
"id": {
"description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.<id>]` profile.",
"type": "string"
},
"modifications": {
"default": [],
"description": "Bounded user-requested modifications applied on top of the named profile, if any.",
"items": {
"$ref": "#/definitions/ActivePermissionProfileModification"
},
"type": "array"
}
},
"required": [
@@ -33,31 +25,6 @@
],
"type": "object"
},
"ActivePermissionProfileModification": {
"oneOf": [
{
"description": "Additional concrete directory that should be writable.",
"properties": {
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"enum": [
"additionalWritableRoot"
],
"title": "AdditionalWritableRootActivePermissionProfileModificationType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "AdditionalWritableRootActivePermissionProfileModification",
"type": "object"
}
]
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -329,89 +296,7 @@
}
]
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
"plan",
"default"
],
"type": "string"
},
"NetworkAccess": {
"enum": [
"restricted",
"enabled"
],
"type": "string"
},
"PermissionProfile": {
"oneOf": [
{
"description": "Codex owns sandbox construction for this profile.",
"properties": {
"fileSystem": {
"$ref": "#/definitions/PermissionProfileFileSystemPermissions"
},
"network": {
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
},
"type": {
"enum": [
"managed"
],
"title": "ManagedPermissionProfileType",
"type": "string"
}
},
"required": [
"fileSystem",
"network",
"type"
],
"title": "ManagedPermissionProfile",
"type": "object"
},
{
"description": "Do not apply an outer sandbox.",
"properties": {
"type": {
"enum": [
"disabled"
],
"title": "DisabledPermissionProfileType",
"type": "string"
}
},
"required": [
"type"
],
"title": "DisabledPermissionProfile",
"type": "object"
},
{
"description": "Filesystem isolation is enforced by an external caller.",
"properties": {
"network": {
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
},
"type": {
"enum": [
"external"
],
"title": "ExternalPermissionProfileType",
"type": "string"
}
},
"required": [
"network",
"type"
],
"title": "ExternalPermissionProfile",
"type": "object"
}
]
},
"PermissionProfileFileSystemPermissions": {
"ManagedFileSystemPermissions": {
"oneOf": [
{
"properties": {
@@ -433,7 +318,7 @@
"enum": [
"restricted"
],
"title": "RestrictedPermissionProfileFileSystemPermissionsType",
"title": "RestrictedManagedFileSystemPermissionsType",
"type": "string"
}
},
@@ -441,7 +326,7 @@
"entries",
"type"
],
"title": "RestrictedPermissionProfileFileSystemPermissions",
"title": "RestrictedManagedFileSystemPermissions",
"type": "object"
},
{
@@ -450,28 +335,103 @@
"enum": [
"unrestricted"
],
"title": "UnrestrictedPermissionProfileFileSystemPermissionsType",
"title": "UnrestrictedManagedFileSystemPermissionsType",
"type": "string"
}
},
"required": [
"type"
],
"title": "UnrestrictedPermissionProfileFileSystemPermissions",
"title": "UnrestrictedManagedFileSystemPermissions",
"type": "object"
}
]
},
"PermissionProfileNetworkPermissions": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
"plan",
"default"
],
"type": "string"
},
"NetworkAccess": {
"enum": [
"restricted",
"enabled"
],
"type": "object"
"type": "string"
},
"NetworkSandboxPolicy": {
"enum": [
"enabled",
"restricted"
],
"type": "string"
},
"PermissionProfile": {
"oneOf": [
{
"properties": {
"fileSystem": {
"$ref": "#/definitions/ManagedFileSystemPermissions"
},
"network": {
"$ref": "#/definitions/NetworkSandboxPolicy"
},
"type": {
"enum": [
"managed"
],
"title": "ManagedPermissionProfileType",
"type": "string"
}
},
"required": [
"fileSystem",
"network",
"type"
],
"title": "ManagedPermissionProfile",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"disabled"
],
"title": "DisabledPermissionProfileType",
"type": "string"
}
},
"required": [
"type"
],
"title": "DisabledPermissionProfile",
"type": "object"
},
{
"properties": {
"network": {
"$ref": "#/definitions/NetworkSandboxPolicy"
},
"type": {
"enum": [
"external"
],
"title": "ExternalPermissionProfileType",
"type": "string"
}
},
"required": [
"network",
"type"
],
"title": "ExternalPermissionProfile",
"type": "object"
}
]
},
"Personality": {
"enum": [

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry";
export type ManagedFileSystemPermissions = { "type": "restricted", entries: Array<FileSystemSandboxEntry>, globScanMaxDepth?: number, } | { "type": "unrestricted" };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NetworkSandboxPolicy = "enabled" | "restricted";

View File

@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ManagedFileSystemPermissions } from "./ManagedFileSystemPermissions";
import type { NetworkSandboxPolicy } from "./NetworkSandboxPolicy";
export type PermissionProfile = { "type": "managed", fileSystem: ManagedFileSystemPermissions, network: NetworkSandboxPolicy, } | { "type": "disabled" } | { "type": "external", network: NetworkSandboxPolicy, };

View File

@@ -173,6 +173,7 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse"
export type { LoginAccountParams } from "./LoginAccountParams";
export type { LoginAccountResponse } from "./LoginAccountResponse";
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
export type { ManagedFileSystemPermissions } from "./ManagedFileSystemPermissions";
export type { ManagedHooksRequirements } from "./ManagedHooksRequirements";
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
export type { MarketplaceAddResponse } from "./MarketplaceAddResponse";
@@ -249,12 +250,14 @@ export type { NetworkDomainPermission } from "./NetworkDomainPermission";
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
export type { NetworkRequirements } from "./NetworkRequirements";
export type { NetworkSandboxPolicy } from "./NetworkSandboxPolicy";
export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission";
export type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
export type { OverriddenMetadata } from "./OverriddenMetadata";
export type { PatchApplyStatus } from "./PatchApplyStatus";
export type { PatchChangeKind } from "./PatchChangeKind";
export type { PermissionGrantScope } from "./PermissionGrantScope";
export type { PermissionProfile } from "./PermissionProfile";
export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams";
export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse";
export type { PlanDeltaNotification } from "./PlanDeltaNotification";

View File

@@ -1458,6 +1458,7 @@ server_notification_definitions! {
Error => "error" (v2::ErrorNotification),
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification),
#[experimental("thread/turnContext/updated")]
ThreadTurnContextUpdated => "thread/turnContext/updated" (v2::ThreadTurnContextUpdatedNotification),
ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification),
ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification),

View File

@@ -7,11 +7,14 @@ use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleA
use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
@@ -184,6 +187,13 @@ v2_enum_from_core!(
}
);
v2_enum_from_core!(
pub enum NetworkSandboxPolicy from CoreNetworkSandboxPolicy {
Enabled,
Restricted
}
);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[ts(tag = "kind")]
@@ -289,6 +299,113 @@ impl From<FileSystemSandboxEntry> for CoreFileSystemSandboxEntry {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum ManagedFileSystemPermissions {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Restricted {
entries: Vec<FileSystemSandboxEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
glob_scan_max_depth: Option<NonZeroUsize>,
},
Unrestricted,
}
impl From<CoreManagedFileSystemPermissions> for ManagedFileSystemPermissions {
fn from(value: CoreManagedFileSystemPermissions) -> Self {
match value {
CoreManagedFileSystemPermissions::Restricted {
entries,
glob_scan_max_depth,
} => Self::Restricted {
entries: entries
.into_iter()
.map(FileSystemSandboxEntry::from)
.collect(),
glob_scan_max_depth,
},
CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted,
}
}
}
impl From<ManagedFileSystemPermissions> for CoreManagedFileSystemPermissions {
fn from(value: ManagedFileSystemPermissions) -> Self {
match value {
ManagedFileSystemPermissions::Restricted {
entries,
glob_scan_max_depth,
} => Self::Restricted {
entries: entries
.into_iter()
.map(CoreFileSystemSandboxEntry::from)
.collect(),
glob_scan_max_depth,
},
ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum PermissionProfile {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Managed {
file_system: ManagedFileSystemPermissions,
network: NetworkSandboxPolicy,
},
Disabled,
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
External {
network: NetworkSandboxPolicy,
},
}
impl From<CorePermissionProfile> for PermissionProfile {
fn from(value: CorePermissionProfile) -> Self {
match value {
CorePermissionProfile::Managed {
file_system,
network,
} => Self::Managed {
file_system: file_system.into(),
network: network.into(),
},
CorePermissionProfile::Disabled => Self::Disabled,
CorePermissionProfile::External { network } => Self::External {
network: network.into(),
},
}
}
}
impl From<PermissionProfile> for CorePermissionProfile {
fn from(value: PermissionProfile) -> Self {
match value {
PermissionProfile::Managed {
file_system,
network,
} => Self::Managed {
file_system: file_system.into(),
network: network.to_core(),
},
PermissionProfile::Disabled => Self::Disabled,
PermissionProfile::External { network } => Self::External {
network: network.to_core(),
},
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View File

@@ -1,6 +1,7 @@
use super::ActivePermissionProfile;
use super::ApprovalsReviewer;
use super::AskForApproval;
use super::PermissionProfile;
use super::PermissionProfileSelectionParams;
use super::SandboxMode;
use super::SandboxPolicy;
@@ -15,7 +16,6 @@ use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus;

View File

@@ -2691,8 +2691,17 @@ impl ThreadRequestProcessor {
let thread_state = self
.thread_state_manager
.thread_state(existing_thread_id)
.await;
.try_ensure_connection_subscribed(
existing_thread_id,
request_id.connection_id,
/*experimental_raw_events*/ false,
)
.await
.ok_or_else(|| {
internal_error(format!(
"failed to subscribe connection for running thread {existing_thread_id}"
))
})?;
self.ensure_listener_task_running(
existing_thread_id,
existing_thread.clone(),

View File

@@ -85,6 +85,35 @@ fn resolve_runtime_workspace_roots(
resolved_roots
}
fn effective_workspace_roots(
base_snapshot: &ThreadConfigSnapshot,
effective_cwd: &AbsolutePathBuf,
runtime_workspace_roots: Option<&[AbsolutePathBuf]>,
) -> Vec<AbsolutePathBuf> {
if let Some(workspace_roots) = runtime_workspace_roots {
return workspace_roots.to_vec();
}
if effective_cwd != &base_snapshot.cwd
&& base_snapshot.workspace_roots.contains(&base_snapshot.cwd)
{
let mut retargeted_roots = Vec::with_capacity(base_snapshot.workspace_roots.len());
for root in &base_snapshot.workspace_roots {
let root = if root == &base_snapshot.cwd {
effective_cwd.clone()
} else {
root.clone()
};
if !retargeted_roots.contains(&root) {
retargeted_roots.push(root);
}
}
retargeted_roots
} else {
base_snapshot.workspace_roots.clone()
}
}
impl TurnRequestProcessor {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
@@ -411,22 +440,22 @@ impl TurnRequestProcessor {
}
let cwd = request.cwd;
let effective_cwd = cwd
.as_ref()
.map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, base_snapshot.cwd.as_path()))
.unwrap_or_else(|| base_snapshot.cwd.clone());
let runtime_workspace_roots =
request
.runtime_workspace_roots
.clone()
.map(|workspace_roots| {
let base_cwd = cwd
.as_ref()
.map(|cwd| {
AbsolutePathBuf::resolve_path_against_base(
cwd,
base_snapshot.cwd.as_path(),
)
})
.unwrap_or_else(|| base_snapshot.cwd.clone());
resolve_runtime_workspace_roots(workspace_roots, &base_cwd)
resolve_runtime_workspace_roots(workspace_roots, &effective_cwd)
});
let effective_workspace_roots = effective_workspace_roots(
base_snapshot,
&effective_cwd,
runtime_workspace_roots.as_deref(),
);
let approval_policy = request.approval_policy.map(AskForApproval::to_core);
let approvals_reviewer = request
.approvals_reviewer
@@ -436,15 +465,12 @@ impl TurnRequestProcessor {
if let Some(permissions) = request.permissions {
let mut overrides = ConfigOverrides {
cwd: cwd.clone(),
workspace_roots: Some(request.runtime_workspace_roots.clone().unwrap_or_else(
|| {
base_snapshot
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect()
},
)),
workspace_roots: Some(
effective_workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect(),
),
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
..Default::default()
@@ -507,7 +533,8 @@ impl TurnRequestProcessor {
async fn maybe_emit_turn_context_updated(
&self,
thread_id: &str,
thread_id: ThreadId,
api_thread_id: &str,
before: &ThreadTurnContext,
after: ThreadTurnContext,
) {
@@ -515,14 +542,60 @@ impl TurnRequestProcessor {
return;
}
self.outgoing
.send_server_notification(ServerNotification::ThreadTurnContextUpdated(
ThreadTurnContextUpdatedNotification {
thread_id: thread_id.to_string(),
turn_context: after,
},
))
let connection_ids = self
.thread_state_manager
.subscribed_connection_ids(thread_id)
.await;
if connection_ids.is_empty() {
return;
}
self.outgoing
.send_server_notification_to_connections(
&connection_ids,
ServerNotification::ThreadTurnContextUpdated(
ThreadTurnContextUpdatedNotification {
thread_id: api_thread_id.to_string(),
turn_context: after,
},
),
)
.await;
}
async fn wait_for_pending_turn_contexts(
&self,
thread_id: ThreadId,
) -> Result<(), JSONRPCErrorError> {
let pending = {
let thread_state = self.thread_state_manager.thread_state(thread_id).await;
let mut thread_state = thread_state.lock().await;
thread_state.track_current_pending_turn_contexts()
};
for turn_context_applied in pending {
match tokio::time::timeout(TURN_CONTEXT_OVERRIDE_ACK_TIMEOUT, turn_context_applied)
.await
{
Ok(Ok(Ok(_))) => {}
Ok(Ok(Err(err))) => {
return Err(internal_error(format!(
"failed to apply pending turn context override: {err}"
)));
}
Ok(Err(_)) => {
return Err(internal_error(
"pending turn context override waiter was cancelled".to_string(),
));
}
Err(_) => {
return Err(internal_error(
"timed out waiting for pending turn context overrides to apply".to_string(),
));
}
}
}
Ok(())
}
async fn turn_start_inner(
@@ -648,6 +721,7 @@ impl TurnRequestProcessor {
let after_turn_context = thread_turn_context_from_applied_event(&payload);
processor
.maybe_emit_turn_context_updated(
thread_id,
&api_thread_id,
&before_turn_context,
after_turn_context,
@@ -713,6 +787,7 @@ impl TurnRequestProcessor {
.inspect_err(|error| {
self.track_error_response(request_id, error, /*error_type*/ None);
})?;
self.wait_for_pending_turn_contexts(thread_id).await?;
let before_snapshot = thread.config_snapshot().await;
let before_turn_context = thread_turn_context_from_snapshot(&before_snapshot);
let resolved_overrides = self
@@ -783,6 +858,7 @@ impl TurnRequestProcessor {
before_turn_context.clone()
};
self.maybe_emit_turn_context_updated(
thread_id,
&params.thread_id,
&before_turn_context,
after_turn_context.clone(),

View File

@@ -147,6 +147,18 @@ impl ThreadState {
rx
}
pub(crate) fn track_current_pending_turn_contexts(
&mut self,
) -> Vec<oneshot::Receiver<TurnContextAck>> {
let mut receivers = Vec::with_capacity(self.pending_turn_context_waiters.len());
for waiters in self.pending_turn_context_waiters.values_mut() {
let (tx, rx) = oneshot::channel();
waiters.push(tx);
receivers.push(rx);
}
receivers
}
pub(crate) fn cancel_pending_turn_context(&mut self, submission_id: &str) {
self.pending_turn_context_waiters.remove(submission_id);
}

View File

@@ -7,6 +7,7 @@ use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
@@ -107,7 +108,7 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() ->
}
#[tokio::test]
async fn websocket_turn_context_updates_broadcast_to_other_connections() -> Result<()> {
async fn websocket_turn_context_updates_stay_on_subscribed_connections() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
@@ -117,9 +118,9 @@ async fn websocket_turn_context_updates_broadcast_to_other_connections() -> Resu
let mut ws1 = connect_websocket(bind_addr).await?;
let mut ws2 = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws1, /*id*/ 1, "ws_context_owner").await?;
send_initialize_experimental_request(&mut ws1, /*id*/ 1, "ws_context_owner").await?;
read_response_for_id(&mut ws1, /*id*/ 1).await?;
send_initialize_request(&mut ws2, /*id*/ 2, "ws_context_peer").await?;
send_initialize_experimental_request(&mut ws2, /*id*/ 2, "ws_context_peer").await?;
read_response_for_id(&mut ws2, /*id*/ 2).await?;
let thread_id = start_thread(&mut ws1, /*id*/ 3).await?;
@@ -141,21 +142,19 @@ async fn websocket_turn_context_updates_broadcast_to_other_connections() -> Resu
"thread/turnContext/updated",
)
.await?;
let peer_notification =
read_notification_for_method(&mut ws2, "thread/turnContext/updated").await?;
let ServerNotification::ThreadTurnContextUpdated(caller) =
ServerNotification::try_from(caller_notification)?
else {
bail!("expected caller thread/turnContext/updated notification");
};
let ServerNotification::ThreadTurnContextUpdated(peer) =
ServerNotification::try_from(peer_notification)?
else {
bail!("expected peer thread/turnContext/updated notification");
};
assert_eq!(caller.thread_id, thread_id);
assert_eq!(peer, caller);
assert_no_notification_for_method(
&mut ws2,
"thread/turnContext/updated",
Duration::from_millis(250),
)
.await?;
process
.kill()
@@ -651,6 +650,32 @@ pub(super) async fn send_initialize_request(
stream: &mut WsClient,
id: i64,
client_name: &str,
) -> Result<()> {
send_initialize_request_with_capabilities(stream, id, client_name, None).await
}
async fn send_initialize_experimental_request(
stream: &mut WsClient,
id: i64,
client_name: &str,
) -> Result<()> {
send_initialize_request_with_capabilities(
stream,
id,
client_name,
Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
)
.await
}
async fn send_initialize_request_with_capabilities(
stream: &mut WsClient,
id: i64,
client_name: &str,
capabilities: Option<InitializeCapabilities>,
) -> Result<()> {
let params = InitializeParams {
client_info: ClientInfo {
@@ -658,7 +683,7 @@ pub(super) async fn send_initialize_request(
title: Some("WebSocket Test Client".to_string()),
version: "0.1.0".to_string(),
},
capabilities: None,
capabilities,
};
send_request(
stream,
@@ -883,6 +908,44 @@ pub(super) async fn assert_no_message(stream: &mut WsClient, wait_for: Duration)
}
}
async fn assert_no_notification_for_method(
stream: &mut WsClient,
method: &str,
wait_for: Duration,
) -> Result<()> {
let deadline = Instant::now() + wait_for;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(());
}
let frame = match timeout(remaining, stream.next()).await {
Ok(Some(Ok(frame))) => frame,
Ok(Some(Err(err))) => bail!("unexpected websocket read error: {err}"),
Ok(None) => bail!("websocket closed unexpectedly while waiting for notification"),
Err(_) => return Ok(()),
};
match frame {
WebSocketMessage::Text(text) => {
let message: JSONRPCMessage = serde_json::from_str(text.as_ref())?;
if let JSONRPCMessage::Notification(notification) = message
&& notification.method == method
{
bail!("unexpected notification for method `{method}`");
}
}
WebSocketMessage::Ping(payload) => {
stream.send(WebSocketMessage::Pong(payload)).await?;
}
WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
WebSocketMessage::Close(frame) => bail!("websocket closed unexpectedly: {frame:?}"),
WebSocketMessage::Binary(_) => bail!("unexpected binary websocket frame"),
}
}
}
pub(super) fn create_config_toml(
codex_home: &Path,
server_uri: &str,

View File

@@ -7,6 +7,7 @@ use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PermissionProfile;
use codex_app_server_protocol::PermissionProfileSelectionParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
@@ -20,7 +21,11 @@ use codex_app_server_protocol::ThreadTurnContextUpdatedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_features::Feature;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use tempfile::TempDir;
@@ -74,6 +79,31 @@ async fn read_turn_context_updated(
Ok(notification)
}
fn assert_permission_profile_write_root(
permission_profile: &PermissionProfile,
expected_root: &AbsolutePathBuf,
unexpected_root: &AbsolutePathBuf,
) {
let permission_profile: CorePermissionProfile = permission_profile.clone().into();
let sandbox_policy = permission_profile.file_system_sandbox_policy();
assert!(
sandbox_policy.entries.iter().any(|entry| {
entry.access == FileSystemAccessMode::Write
&& matches!(&entry.path, FileSystemPath::Path { path } if path == expected_root)
}),
"expected permission profile write entries to contain {expected_root:?}; got {:?}",
sandbox_policy.entries
);
assert!(
!sandbox_policy.entries.iter().any(|entry| {
entry.access == FileSystemAccessMode::Write
&& matches!(&entry.path, FileSystemPath::Path { path } if path == unexpected_root)
}),
"did not expect permission profile write entries to contain {unexpected_root:?}; got {:?}",
sandbox_policy.entries
);
}
#[tokio::test]
async fn thread_turn_context_update_applies_partial_patch_and_emits_full_state() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -133,6 +163,43 @@ async fn thread_turn_context_update_applies_partial_patch_and_emits_full_state()
Ok(())
}
#[tokio::test]
async fn thread_turn_context_update_retargets_permissions_when_cwd_changes() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
write_config(&codex_home, &server.uri())?;
let next_cwd = TempDir::new()?;
let next_cwd_abs = AbsolutePathBuf::try_from(next_cwd.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?;
let request_id = mcp
.send_thread_turn_context_update_request(ThreadTurnContextUpdateParams {
thread_id: thread.id,
cwd: Some(next_cwd.path().to_path_buf()),
permissions: Some(PermissionProfileSelectionParams::new(":workspace")),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response = to_response::<ThreadTurnContextUpdateResponse>(response)?;
assert_eq!(response.turn_context.cwd, next_cwd_abs);
assert_permission_profile_write_root(
&response.turn_context.permission_profile,
&next_cwd_abs,
&thread.cwd,
);
Ok(())
}
#[tokio::test]
async fn thread_turn_context_update_clears_service_tier_with_explicit_null() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -200,6 +267,68 @@ async fn thread_turn_context_update_rejects_sandbox_policy_with_permissions() ->
Ok(())
}
#[tokio::test]
async fn thread_turn_context_update_waits_for_pending_cwd_before_permissions() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("Done")?,
])
.await;
let codex_home = TempDir::new()?;
write_config(&codex_home, &server.uri())?;
let next_cwd = TempDir::new()?;
let next_cwd_abs = AbsolutePathBuf::try_from(next_cwd.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?;
let turn_request_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(next_cwd.path().to_path_buf()),
..Default::default()
})
.await?;
let update_request_id = mcp
.send_thread_turn_context_update_request(ThreadTurnContextUpdateParams {
thread_id: thread.id,
permissions: Some(PermissionProfileSelectionParams::new(":workspace")),
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
)
.await??;
let update_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(update_request_id)),
)
.await??;
let update_response = to_response::<ThreadTurnContextUpdateResponse>(update_response)?;
assert_eq!(update_response.turn_context.cwd, next_cwd_abs);
assert_permission_profile_write_root(
&update_response.turn_context.permission_profile,
&next_cwd_abs,
&thread.cwd,
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
#[tokio::test]
async fn turn_start_emits_turn_context_updated_when_overrides_change_defaults() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![