Compare commits

...

2 Commits

Author SHA1 Message Date
viyatb-oai
f4ea4a96c8 refactor(app-server): simplify thread start task inputs 2026-03-12 11:51:04 -07:00
viyatb-oai
1cd2873f86 feat(app-server): support thread start sandbox policy 2026-03-12 11:14:08 -07:00
12 changed files with 417 additions and 46 deletions

View File

@@ -2829,6 +2829,17 @@
}
]
},
"sandboxPolicy": {
"anyOf": [
{
"$ref": "#/definitions/SandboxPolicy"
},
{
"type": "null"
}
],
"description": "Optional full sandbox policy override for the new thread.\n\nUses the same shape as `turn/start.sandboxPolicy` and persists to subsequent turns. When both `sandboxPolicy` and `sandbox` are provided, `sandboxPolicy` takes precedence."
},
"serviceName": {
"type": [
"string",

View File

@@ -16724,6 +16724,17 @@
}
]
},
"sandboxPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/SandboxPolicy"
},
{
"type": "null"
}
],
"description": "Optional full sandbox policy override for the new thread.\n\nUses the same shape as `turn/start.sandboxPolicy` and persists to subsequent turns. When both `sandboxPolicy` and `sandbox` are provided, `sandboxPolicy` takes precedence."
},
"serviceName": {
"type": [
"string",

View File

@@ -14491,6 +14491,17 @@
}
]
},
"sandboxPolicy": {
"anyOf": [
{
"$ref": "#/definitions/SandboxPolicy"
},
{
"type": "null"
}
],
"description": "Optional full sandbox policy override for the new thread.\n\nUses the same shape as `turn/start.sandboxPolicy` and persists to subsequent turns. When both `sandboxPolicy` and `sandbox` are provided, `sandboxPolicy` takes precedence."
},
"serviceName": {
"type": [
"string",

View File

@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AskForApproval": {
"oneOf": [
{
@@ -68,6 +72,13 @@
],
"type": "object"
},
"NetworkAccess": {
"enum": [
"restricted",
"enabled"
],
"type": "string"
},
"Personality": {
"enum": [
"none",
@@ -76,6 +87,53 @@
],
"type": "string"
},
"ReadOnlyAccess": {
"oneOf": [
{
"properties": {
"includePlatformDefaults": {
"default": true,
"type": "boolean"
},
"readableRoots": {
"default": [],
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"fullAccess"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"SandboxMode": {
"enum": [
"read-only",
@@ -84,6 +142,125 @@
],
"type": "string"
},
"SandboxPolicy": {
"oneOf": [
{
"properties": {
"type": {
"enum": [
"dangerFullAccess"
],
"title": "DangerFullAccessSandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "DangerFullAccessSandboxPolicy",
"type": "object"
},
{
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"default": {
"type": "fullAccess"
}
},
"networkAccess": {
"default": false,
"type": "boolean"
},
"type": {
"enum": [
"readOnly"
],
"title": "ReadOnlySandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ReadOnlySandboxPolicy",
"type": "object"
},
{
"properties": {
"networkAccess": {
"allOf": [
{
"$ref": "#/definitions/NetworkAccess"
}
],
"default": "restricted"
},
"type": {
"enum": [
"externalSandbox"
],
"title": "ExternalSandboxSandboxPolicyType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ExternalSandboxSandboxPolicy",
"type": "object"
},
{
"properties": {
"excludeSlashTmp": {
"default": false,
"type": "boolean"
},
"excludeTmpdirEnvVar": {
"default": false,
"type": "boolean"
},
"networkAccess": {
"default": false,
"type": "boolean"
},
"readOnlyAccess": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"default": {
"type": "fullAccess"
}
},
"type": {
"enum": [
"workspaceWrite"
],
"title": "WorkspaceWriteSandboxPolicyType",
"type": "string"
},
"writableRoots": {
"default": [],
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"type"
],
"title": "WorkspaceWriteSandboxPolicy",
"type": "object"
}
]
},
"ServiceTier": {
"enum": [
"fast",
@@ -166,6 +343,17 @@
}
]
},
"sandboxPolicy": {
"anyOf": [
{
"$ref": "#/definitions/SandboxPolicy"
},
{
"type": "null"
}
],
"description": "Optional full sandbox policy override for the new thread.\n\nUses the same shape as `turn/start.sandboxPolicy` and persists to subsequent turns. When both `sandboxPolicy` and `sandbox` are provided, `sandboxPolicy` takes precedence."
},
"serviceName": {
"type": [
"string",

View File

@@ -6,10 +6,16 @@ import type { ServiceTier } from "../ServiceTier";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
import type { SandboxPolicy } from "./SandboxPolicy";
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /**
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, /**
* Optional full sandbox policy override for the new thread.
*
* Uses the same shape as `turn/start.sandboxPolicy` and persists to
* subsequent turns. When both `sandboxPolicy` and `sandbox` are provided, * `sandboxPolicy` takes precedence.
*/
sandboxPolicy?: SandboxPolicy | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /**
* If true, opt into emitting raw Responses API items on the event stream.
* This is for internal use only (e.g. Codex Cloud).
*/
experimentalRawEvents: boolean, /**
* If true, persist additional rollout EventMsg variants required to

View File

@@ -2273,6 +2273,13 @@ pub struct ThreadStartParams {
pub approval_policy: Option<AskForApproval>,
#[ts(optional = nullable)]
pub sandbox: Option<SandboxMode>,
/// Optional full sandbox policy override for the new thread.
///
/// Uses the same shape as `turn/start.sandboxPolicy` and persists to
/// subsequent turns. When both `sandboxPolicy` and `sandbox` are provided,
/// `sandboxPolicy` takes precedence.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, JsonValue>>,
#[ts(optional = nullable)]
@@ -2294,7 +2301,6 @@ pub struct ThreadStartParams {
#[ts(optional = nullable)]
pub mock_experimental_field: Option<String>,
/// If true, opt into emitting raw Responses API items on the event stream.
/// This is for internal use only (e.g. Codex Cloud).
#[experimental("thread/start.experimentalRawEvents")]
#[serde(default)]
pub experimental_raw_events: bool,

View File

@@ -66,7 +66,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
## Lifecycle Overview
- Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected.
- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and youll also get a `thread/started` notification. If youre continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread.
- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and youll also get a `thread/started` notification. If youre continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. `thread/start` accepts the legacy coarse `sandbox` override as well as the richer `sandboxPolicy` shape used by `turn/start`; when both are provided, `sandboxPolicy` takes precedence. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread.
The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`.
- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running.
- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. Youll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes).
@@ -218,6 +218,8 @@ Start a fresh thread when you need a new Codex conversation.
{ "method": "thread/started", "params": { "thread": { } } }
```
Legacy callers can continue sending `"sandbox": "readOnly" | "workspaceWrite" | "dangerFullAccess"` on `thread/start`. For the richer readable-root / writable-root shape, use `sandboxPolicy` with the same schema as `turn/start`. If both `sandbox` and `sandboxPolicy` are present, `sandboxPolicy` wins.
Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted. You can also pass the same configuration overrides supported by `thread/start`, such as `personality`:

View File

@@ -1824,38 +1824,9 @@ impl CodexMessageProcessor {
params: ThreadStartParams,
request_context: RequestContext,
) {
let ThreadStartParams {
model,
model_provider,
service_tier,
cwd,
approval_policy,
sandbox,
config,
service_name,
base_instructions,
developer_instructions,
dynamic_tools,
mock_experimental_field: _mock_experimental_field,
experimental_raw_events,
personality,
ephemeral,
persist_extended_history,
} = params;
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
service_tier,
cwd,
approval_policy,
sandbox,
base_instructions,
developer_instructions,
personality,
);
typesafe_overrides.ephemeral = ephemeral;
let cli_overrides = self.cli_overrides.clone();
let cloud_requirements = self.current_cloud_requirements();
let arg0_paths = self.arg0_paths.clone();
let listener_task_context = ListenerTaskContext {
thread_manager: Arc::clone(&self.thread_manager),
thread_state_manager: self.thread_state_manager.clone(),
@@ -1871,12 +1842,8 @@ impl CodexMessageProcessor {
cli_overrides,
cloud_requirements,
request_id,
config,
typesafe_overrides,
dynamic_tools,
persist_extended_history,
service_name,
experimental_raw_events,
arg0_paths,
params,
request_trace,
)
.await;
@@ -1932,14 +1899,54 @@ impl CodexMessageProcessor {
cli_overrides: Vec<(String, TomlValue)>,
cloud_requirements: CloudRequirementsLoader,
request_id: ConnectionRequestId,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
persist_extended_history: bool,
service_name: Option<String>,
experimental_raw_events: bool,
arg0_paths: Arg0DispatchPaths,
params: ThreadStartParams,
request_trace: Option<W3cTraceContext>,
) {
let ThreadStartParams {
model,
model_provider,
service_tier,
cwd,
approval_policy,
sandbox,
sandbox_policy,
config: config_overrides,
service_name,
base_instructions,
developer_instructions,
dynamic_tools,
mock_experimental_field: _mock_experimental_field,
experimental_raw_events,
personality,
ephemeral,
persist_extended_history,
} = params;
let sandbox = if sandbox_policy.is_some() {
None
} else {
sandbox
};
let Arg0DispatchPaths {
codex_linux_sandbox_exe,
main_execve_wrapper_exe,
} = arg0_paths;
let mut typesafe_overrides = ConfigOverrides {
model,
model_provider,
service_tier,
cwd: cwd.map(PathBuf::from),
approval_policy: approval_policy
.map(codex_app_server_protocol::AskForApproval::to_core),
sandbox_mode: sandbox.map(SandboxMode::to_core),
codex_linux_sandbox_exe,
main_execve_wrapper_exe,
base_instructions,
developer_instructions,
personality,
..Default::default()
};
typesafe_overrides.ephemeral = ephemeral;
let config = match derive_config_from_params(
&cli_overrides,
config_overrides,
@@ -1962,6 +1969,25 @@ impl CodexMessageProcessor {
return;
}
};
let requested_sandbox_policy = match sandbox_policy {
Some(policy) => {
let policy = policy.to_core();
if let Err(err) = config.permissions.sandbox_policy.can_set(&policy) {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid sandbox policy: {err}"),
data: None,
};
listener_task_context
.outgoing
.send_error(request_id, error)
.await;
return;
}
Some(policy)
}
None => None,
};
let dynamic_tools = dynamic_tools.unwrap_or_default();
let core_dynamic_tools = if dynamic_tools.is_empty() {
@@ -2007,6 +2033,20 @@ impl CodexMessageProcessor {
session_configured,
..
} = new_conv;
if let Some(policy) = requested_sandbox_policy
&& let Err(err) = thread.set_sandbox_policy(policy).await
{
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("error applying sandbox policy: {err}"),
data: None,
};
listener_task_context
.outgoing
.send_error(request_id, error)
.await;
return;
}
let config_snapshot = thread.config_snapshot().await;
let mut thread = build_thread_from_snapshot(
thread_id,

View File

@@ -234,6 +234,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
cwd: None,
approval_policy: None,
sandbox: None,
sandbox_policy: None,
config: None,
service_name: None,
base_instructions: None,

View File

@@ -237,6 +237,85 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_full_sandbox_policy_override() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let readable_root = TempDir::new()?;
let writable_root = TempDir::new()?;
let sandbox_policy = codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root.path().to_path_buf().try_into()?],
read_only_access: codex_app_server_protocol::ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![readable_root.path().to_path_buf().try_into()?],
},
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
sandbox_policy: Some(sandbox_policy.clone()),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { sandbox, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(sandbox, sandbox_policy);
Ok(())
}
#[tokio::test]
async fn thread_start_prefers_full_sandbox_policy_over_legacy_sandbox() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let writable_root = TempDir::new()?;
let sandbox_policy = codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root.path().to_path_buf().try_into()?],
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
sandbox: Some(codex_app_server_protocol::SandboxMode::ReadOnly),
sandbox_policy: Some(sandbox_policy.clone()),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { sandbox, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(sandbox, sandbox_policy);
Ok(())
}
#[tokio::test]
async fn thread_start_ephemeral_remains_pathless() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;

View File

@@ -704,6 +704,18 @@ impl Codex {
.await
}
pub(crate) async fn set_sandbox_policy(
&self,
sandbox_policy: SandboxPolicy,
) -> ConstraintResult<()> {
self.session
.update_settings(SessionSettingsUpdate {
sandbox_policy: Some(sandbox_policy),
..Default::default()
})
.await
}
pub(crate) async fn agent_status(&self) -> AgentStatus {
self.agent_status.borrow().clone()
}

View File

@@ -97,6 +97,10 @@ impl CodexThread {
.await
}
pub async fn set_sandbox_policy(&self, sandbox_policy: SandboxPolicy) -> ConstraintResult<()> {
self.codex.set_sandbox_policy(sandbox_policy).await
}
/// Use sparingly: this is intended to be removed soon.
pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> {
self.codex.submit_with_id(sub).await