Merge 9fe4ec9567 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-02-10 17:46:02 -08:00
committed by GitHub
97 changed files with 3027 additions and 1473 deletions
@@ -1243,6 +1243,57 @@
],
"type": "string"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -2018,8 +2069,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -2078,6 +2137,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -3518,6 +3518,57 @@
],
"type": "object"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -4355,8 +4406,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -4415,6 +4474,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -4544,6 +4544,57 @@
],
"type": "object"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -5461,8 +5512,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -5521,6 +5580,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -6603,6 +6603,57 @@
],
"type": "object"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -7616,8 +7667,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -7676,6 +7735,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -13,6 +13,57 @@
],
"type": "string"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"SandboxPolicy": {
"description": "Determines execution restrictions for model shell commands.",
"oneOf": [
@@ -34,8 +85,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -94,6 +153,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -3518,6 +3518,57 @@
],
"type": "object"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -4355,8 +4406,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -4415,6 +4474,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -3518,6 +3518,57 @@
],
"type": "object"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -4355,8 +4406,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -4415,6 +4474,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -142,6 +142,57 @@
],
"type": "string"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -195,8 +246,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -255,6 +314,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -3518,6 +3518,57 @@
],
"type": "object"
},
"ReadOnlyAccess": {
"description": "Determines how read-only file access is granted inside a restricted sandbox.",
"oneOf": [
{
"description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.",
"properties": {
"include_platform_defaults": {
"default": true,
"description": "Include built-in platform read roots required for basic process execution.",
"type": "boolean"
},
"readable_roots": {
"description": "Additional absolute roots that should be readable.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"description": "Allow unrestricted file reads.",
"properties": {
"type": {
"enum": [
"full-access"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -4355,8 +4406,16 @@
"type": "object"
},
{
"description": "Read-only access to the entire file-system.",
"description": "Read-only access configuration.",
"properties": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"read-only"
@@ -4415,6 +4474,14 @@
"description": "When set to `true`, outbound network access is allowed. `false` by default.",
"type": "boolean"
},
"read_only_access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"description": "Read access granted while running under this policy."
},
"type": {
"enum": [
"workspace-write"
@@ -0,0 +1,19 @@
// 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 { AbsolutePathBuf } from "./AbsolutePathBuf";
/**
* Determines how read-only file access is granted inside a restricted
* sandbox.
*/
export type ReadOnlyAccess = { "type": "restricted",
/**
* Include built-in platform read roots required for basic process
* execution.
*/
include_platform_defaults: boolean,
/**
* Additional absolute roots that should be readable.
*/
readable_roots?: Array<AbsolutePathBuf>, } | { "type": "full-access" };
@@ -3,11 +3,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "./AbsolutePathBuf";
import type { NetworkAccess } from "./NetworkAccess";
import type { ReadOnlyAccess } from "./ReadOnlyAccess";
/**
* Determines execution restrictions for model shell commands.
*/
export type SandboxPolicy = { "type": "danger-full-access" } | { "type": "read-only" } | { "type": "external-sandbox",
export type SandboxPolicy = { "type": "danger-full-access" } | { "type": "read-only",
/**
* Read access granted while running under this policy.
*/
access?: ReadOnlyAccess, } | { "type": "external-sandbox",
/**
* Whether the external sandbox permits outbound network traffic.
*/
@@ -17,6 +22,10 @@ network_access: NetworkAccess, } | { "type": "workspace-write",
* writable from within the sandbox.
*/
writable_roots?: Array<AbsolutePathBuf>,
/**
* Read access granted while running under this policy.
*/
read_only_access?: ReadOnlyAccess,
/**
* When set to `true`, outbound network access is allowed. `false` by
* default.
@@ -137,6 +137,7 @@ export type { Profile } from "./Profile";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
export type { RawResponseItemEvent } from "./RawResponseItemEvent";
export type { ReadOnlyAccess } from "./ReadOnlyAccess";
export type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent";
export type { ReasoningEffort } from "./ReasoningEffort";
export type { ReasoningItem } from "./ReasoningItem";
@@ -32,6 +32,7 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies;
use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo;
@@ -680,7 +681,9 @@ impl SandboxPolicy {
SandboxPolicy::DangerFullAccess => {
codex_protocol::protocol::SandboxPolicy::DangerFullAccess
}
SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly,
SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly {
access: CoreReadOnlyAccess::FullAccess,
},
SandboxPolicy::ExternalSandbox { network_access } => {
codex_protocol::protocol::SandboxPolicy::ExternalSandbox {
network_access: match network_access {
@@ -696,6 +699,7 @@ impl SandboxPolicy {
exclude_slash_tmp,
} => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
read_only_access: CoreReadOnlyAccess::FullAccess,
network_access: *network_access,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
@@ -710,7 +714,7 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
codex_protocol::protocol::SandboxPolicy::DangerFullAccess => {
SandboxPolicy::DangerFullAccess
}
codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly,
codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } => SandboxPolicy::ReadOnly,
codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => {
SandboxPolicy::ExternalSandbox {
network_access: match network_access {
@@ -721,6 +725,7 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
}
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access: _,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
@@ -444,6 +444,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![first_cwd.try_into()?],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+118
View File
@@ -67,11 +67,22 @@ struct SummarizeResponse {
#[cfg(test)]
mod tests {
use super::*;
use crate::common::RawMemory;
use crate::common::RawMemoryMetadata;
use crate::provider::RetryConfig;
use async_trait::async_trait;
use codex_client::Request;
use codex_client::Response;
use codex_client::StreamResponse;
use codex_client::TransportError;
use http::HeaderMap;
use http::Method;
use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
#[derive(Clone, Default)]
struct DummyTransport;
@@ -96,6 +107,54 @@ mod tests {
}
}
#[derive(Clone)]
struct CapturingTransport {
last_request: Arc<Mutex<Option<Request>>>,
response_body: Arc<Vec<u8>>,
}
impl CapturingTransport {
fn new(response_body: Vec<u8>) -> Self {
Self {
last_request: Arc::new(Mutex::new(None)),
response_body: Arc::new(response_body),
}
}
}
#[async_trait]
impl HttpTransport for CapturingTransport {
async fn execute(&self, req: Request) -> Result<Response, TransportError> {
*self.last_request.lock().expect("lock request store") = Some(req);
Ok(Response {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: self.response_body.as_ref().clone().into(),
})
}
async fn stream(&self, _req: Request) -> Result<StreamResponse, TransportError> {
Err(TransportError::Build("stream should not run".to_string()))
}
}
fn provider(base_url: &str) -> Provider {
Provider {
name: "test".to_string(),
base_url: base_url.to_string(),
query_params: None,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
base_delay: Duration::from_millis(1),
retry_429: false,
retry_5xx: true,
retry_transport: true,
},
stream_idle_timeout: Duration::from_secs(1),
}
}
#[test]
fn path_is_memories_trace_summarize_for_wire_compatibility() {
assert_eq!(
@@ -103,4 +162,63 @@ mod tests {
"memories/trace_summarize"
);
}
#[tokio::test]
async fn summarize_input_posts_expected_payload_and_parses_output() {
let transport = CapturingTransport::new(
serde_json::to_vec(&json!({
"output": [
{
"trace_summary": "raw summary",
"memory_summary": "memory summary"
}
]
}))
.expect("serialize response"),
);
let client = MemoriesClient::new(
transport.clone(),
provider("https://example.com/api/codex"),
DummyAuth,
);
let input = MemorySummarizeInput {
model: "gpt-test".to_string(),
raw_memories: vec![RawMemory {
id: "trace-1".to_string(),
metadata: RawMemoryMetadata {
source_path: "/tmp/trace.json".to_string(),
},
items: vec![json!({"type": "message", "role": "user", "content": []})],
}],
reasoning: None,
};
let output = client
.summarize_input(&input, HeaderMap::new())
.await
.expect("summarize input request should succeed");
assert_eq!(output.len(), 1);
assert_eq!(output[0].raw_memory, "raw summary");
assert_eq!(output[0].memory_summary, "memory summary");
let request = transport
.last_request
.lock()
.expect("lock request store")
.clone()
.expect("request should be captured");
assert_eq!(request.method, Method::POST);
assert_eq!(
request.url,
"https://example.com/api/codex/memories/trace_summarize"
);
let body = request.body.expect("request body should be present");
assert_eq!(body["model"], "gpt-test");
assert_eq!(body["traces"][0]["id"], "trace-1");
assert_eq!(
body["traces"][0]["metadata"]["source_path"],
"/tmp/trace.json"
);
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn builtin_approval_presets() -> Vec<ApprovalPreset> {
label: "Read Only",
description: "Codex can read files in the current workspace. Approval is required to edit files or access the internet.",
approval: AskForApproval::OnRequest,
sandbox: SandboxPolicy::ReadOnly,
sandbox: SandboxPolicy::new_read_only_policy(),
},
ApprovalPreset {
id: "auto",
+3 -1
View File
@@ -4,7 +4,7 @@ use codex_core::protocol::SandboxPolicy;
pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
match sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::ReadOnly { .. } => "read-only".to_string(),
SandboxPolicy::ExternalSandbox { network_access } => {
let mut summary = "external-sandbox".to_string();
if matches!(network_access, NetworkAccess::Enabled) {
@@ -17,6 +17,7 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
read_only_access: _,
} => {
let mut summary = "workspace-write".to_string();
@@ -71,6 +72,7 @@ mod tests {
let writable_root = AbsolutePathBuf::try_from(root).unwrap();
let summary = summarize_sandbox_policy(&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root.clone()],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+100
View File
@@ -1116,3 +1116,103 @@ impl WebsocketTelemetry for ApiTelemetry {
self.otel_manager.record_websocket_event(result, duration);
}
}
#[cfg(test)]
mod tests {
use super::ModelClient;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use serde_json::json;
fn test_model_client(session_source: SessionSource) -> ModelClient {
let provider = crate::model_provider_info::create_oss_provider_with_base_url(
"https://example.com/v1",
crate::model_provider_info::WireApi::Responses,
);
ModelClient::new(
None,
ThreadId::new(),
provider,
session_source,
None,
false,
false,
false,
false,
None,
)
}
fn test_model_info() -> ModelInfo {
serde_json::from_value(json!({
"slug": "gpt-test",
"display_name": "gpt-test",
"description": "desc",
"default_reasoning_level": "medium",
"supported_reasoning_levels": [
{"effort": "medium", "description": "medium"}
],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 1,
"upgrade": null,
"base_instructions": "base instructions",
"model_messages": null,
"supports_reasoning_summaries": false,
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {"mode": "bytes", "limit": 10000},
"supports_parallel_tool_calls": false,
"context_window": 272000,
"auto_compact_token_limit": null,
"experimental_supported_tools": []
}))
.expect("deserialize test model info")
}
fn test_otel_manager() -> OtelManager {
OtelManager::new(
ThreadId::new(),
"gpt-test",
"gpt-test",
None,
None,
None,
"test-originator".to_string(),
false,
"test-terminal".to_string(),
SessionSource::Cli,
)
}
#[test]
fn build_subagent_headers_sets_other_subagent_label() {
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other(
"memory_consolidation".to_string(),
)));
let headers = client.build_subagent_headers();
let value = headers
.get("x-openai-subagent")
.and_then(|value| value.to_str().ok());
assert_eq!(value, Some("memory_consolidation"));
}
#[tokio::test]
async fn summarize_memories_returns_empty_for_empty_input() {
let client = test_model_client(SessionSource::Cli);
let model_info = test_model_info();
let otel_manager = test_otel_manager();
let output = client
.summarize_memories(Vec::new(), &model_info, None, &otel_manager)
.await
.expect("empty summarize request should succeed");
assert_eq!(output.len(), 0);
}
}
+24 -11
View File
@@ -43,6 +43,7 @@ use crate::model_provider_info::built_in_model_providers;
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
use crate::protocol::AskForApproval;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_app_server_protocol::Tools;
@@ -1183,6 +1184,7 @@ impl ConfigToml {
exclude_slash_tmp,
}) => SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
read_only_access: ReadOnlyAccess::FullAccess,
network_access: *network_access,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
@@ -2105,7 +2107,7 @@ network_access = true # This should be ignored.
assert_eq!(
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::ReadOnly,
policy: SandboxPolicy::new_read_only_policy(),
forced_auto_mode_downgraded_on_windows: false,
}
);
@@ -2139,7 +2141,7 @@ exclude_slash_tmp = true
assert_eq!(
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::ReadOnly,
policy: SandboxPolicy::new_read_only_policy(),
forced_auto_mode_downgraded_on_windows: true,
}
);
@@ -2149,6 +2151,7 @@ exclude_slash_tmp = true
SandboxPolicyResolution {
policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root.clone()],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -2189,7 +2192,7 @@ trust_level = "trusted"
assert_eq!(
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::ReadOnly,
policy: SandboxPolicy::new_read_only_policy(),
forced_auto_mode_downgraded_on_windows: true,
}
);
@@ -2199,6 +2202,7 @@ trust_level = "trusted"
SandboxPolicyResolution {
policy: SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -2371,7 +2375,7 @@ trust_level = "trusted"
"expected workspace-write request to be downgraded on Windows"
);
match config.sandbox_policy.get() {
&SandboxPolicy::ReadOnly => {}
SandboxPolicy::ReadOnly { .. } => {}
other => panic!("expected read-only policy on Windows, got {other:?}"),
}
} else {
@@ -2517,7 +2521,10 @@ trust_level = "trusted"
#[test]
fn web_search_mode_for_turn_uses_preference_for_read_only() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::ReadOnly);
let mode = resolve_web_search_mode_for_turn(
&web_search_mode,
&SandboxPolicy::new_read_only_policy(),
);
assert_eq!(mode, WebSearchMode::Cached);
}
@@ -2700,7 +2707,7 @@ profile = "project"
if cfg!(target_os = "windows") {
assert!(matches!(
config.sandbox_policy.get(),
SandboxPolicy::ReadOnly
SandboxPolicy::ReadOnly { .. }
));
assert!(config.forced_auto_mode_downgraded_on_windows);
} else {
@@ -4672,7 +4679,7 @@ trust_level = "untrusted"
// Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade)
if cfg!(target_os = "windows") {
assert!(
matches!(resolution.policy, SandboxPolicy::ReadOnly),
matches!(resolution.policy, SandboxPolicy::ReadOnly { .. }),
"Expected ReadOnly on Windows, got {:?}",
resolution.policy
);
@@ -4768,7 +4775,7 @@ trust_level = "untrusted"
assert_eq!(
resolution,
SandboxPolicyResolution {
policy: SandboxPolicy::ReadOnly,
policy: SandboxPolicy::new_read_only_policy(),
forced_auto_mode_downgraded_on_windows: true,
}
);
@@ -4924,7 +4931,7 @@ mcp_oauth_callback_port = 5678
// Verify that untrusted projects still get WorkspaceWrite sandbox (or ReadOnly on Windows)
if cfg!(target_os = "windows") {
assert!(
matches!(config.sandbox_policy.get(), SandboxPolicy::ReadOnly),
matches!(config.sandbox_policy.get(), SandboxPolicy::ReadOnly { .. }),
"Expected ReadOnly on Windows"
);
} else {
@@ -4958,7 +4965,10 @@ mcp_oauth_callback_port = 5678
.build()
.await?;
assert_eq!(*config.sandbox_policy.get(), SandboxPolicy::ReadOnly);
assert_eq!(
*config.sandbox_policy.get(),
SandboxPolicy::new_read_only_policy()
);
Ok(())
}
@@ -4992,7 +5002,10 @@ mcp_oauth_callback_port = 5678
))
.build()
.await?;
assert_eq!(*config.sandbox_policy.get(), SandboxPolicy::ReadOnly);
assert_eq!(
*config.sandbox_policy.get(),
SandboxPolicy::new_read_only_policy()
);
Ok(())
}
@@ -68,6 +68,10 @@ impl NetworkProxySpec {
host_and_port_from_network_addr(&self.config.network.proxy_url, 3128)
}
pub fn socks_enabled(&self) -> bool {
self.config.network.enable_socks5
}
pub(crate) fn from_constraints(
_config_layer_stack: &config::ConfigLayerStack,
requirements: NetworkConstraints,
@@ -94,7 +94,7 @@ impl Default for ConfigRequirements {
None,
),
sandbox_policy: ConstrainedWithSource::new(
Constrained::allow_any(SandboxPolicy::ReadOnly),
Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
None,
),
web_search_mode: ConstrainedWithSource::new(
@@ -421,7 +421,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
// the other variants (WorkspaceWrite, ExternalSandbox) require
// additional parameters. Ultimately, we should expand the config
// format to allow specifying those parameters.
let default_sandbox_policy = SandboxPolicy::ReadOnly;
let default_sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy = match allowed_sandbox_modes {
Some(Sourced {
value: modes,
@@ -439,7 +439,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
let requirement_source_for_error = requirement_source.clone();
let constrained = Constrained::new(default_sandbox_policy, move |candidate| {
let mode = match candidate {
SandboxPolicy::ReadOnly => SandboxModeRequirement::ReadOnly,
SandboxPolicy::ReadOnly { .. } => SandboxModeRequirement::ReadOnly,
SandboxPolicy::WorkspaceWrite { .. } => {
SandboxModeRequirement::WorkspaceWrite
}
@@ -873,7 +873,7 @@ mod tests {
assert!(
requirements
.sandbox_policy
.can_set(&SandboxPolicy::ReadOnly)
.can_set(&SandboxPolicy::new_read_only_policy())
.is_ok()
);
@@ -892,7 +892,7 @@ mod tests {
assert!(
requirements
.sandbox_policy
.can_set(&SandboxPolicy::ReadOnly)
.can_set(&SandboxPolicy::new_read_only_policy())
.is_ok()
);
assert!(
@@ -900,6 +900,7 @@ mod tests {
.sandbox_policy
.can_set(&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+2 -1
View File
@@ -397,7 +397,7 @@ allowed_sandbox_modes = ["read-only"]
);
assert_eq!(
*state.requirements().sandbox_policy.get(),
SandboxPolicy::ReadOnly
SandboxPolicy::new_read_only_policy()
);
assert!(
state
@@ -412,6 +412,7 @@ allowed_sandbox_modes = ["read-only"]
.sandbox_policy
.can_set(&SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+1 -1
View File
@@ -80,7 +80,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options(
let cancel_token = CancellationToken::new();
let sandbox_state = SandboxState {
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap),
+7 -1
View File
@@ -1094,7 +1094,13 @@ mod tests {
arg0: None,
};
let output = exec(params, SandboxType::None, &SandboxPolicy::ReadOnly, None).await?;
let output = exec(
params,
SandboxType::None,
&SandboxPolicy::new_read_only_policy(),
None,
)
.await?;
assert!(output.timed_out);
let stdout = output.stdout.from_utf8_lossy().text;
+15 -15
View File
@@ -290,7 +290,7 @@ pub fn render_decision_for_unmatched_command(
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
// here.
let runtime_sandbox_provides_safety =
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly);
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly { .. });
// If the command is flagged as dangerous or we have no sandbox protection,
// we should never allow it to run without user approval.
@@ -325,7 +325,7 @@ pub fn render_decision_for_unmatched_command(
// command has not been flagged as dangerous.
Decision::Allow
}
SandboxPolicy::ReadOnly | SandboxPolicy::WorkspaceWrite { .. } => {
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => {
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
// non‑escalated, non‑dangerous commands — let the sandbox enforce
// restrictions (e.g., block network/write) without a user prompt.
@@ -832,7 +832,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -859,7 +859,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -887,7 +887,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(requested_prefix.clone()),
})
@@ -1008,7 +1008,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1038,7 +1038,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
})
@@ -1149,7 +1149,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1206,7 +1206,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1244,7 +1244,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1267,7 +1267,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1297,7 +1297,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
@@ -1386,7 +1386,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &sneaky_command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: permissions,
prefix_rule: None,
})
@@ -1409,7 +1409,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &dangerous_command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: permissions,
prefix_rule: None,
})
@@ -1428,7 +1428,7 @@ prefix_rule(
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &dangerous_command,
approval_policy: AskForApproval::Never,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
sandbox_permissions: permissions,
prefix_rule: None,
})
+2 -2
View File
@@ -112,7 +112,7 @@ mod tests {
fn bwrap_flags_are_feature_gated() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let policy = SandboxPolicy::ReadOnly;
let policy = SandboxPolicy::new_read_only_policy();
let with_bwrap =
create_linux_sandbox_command_args(command.clone(), &policy, cwd, true, false);
@@ -132,7 +132,7 @@ mod tests {
fn proxy_flag_is_included_when_requested() {
let command = vec!["/bin/true".to_string()];
let cwd = Path::new("/tmp");
let policy = SandboxPolicy::ReadOnly;
let policy = SandboxPolicy::new_read_only_policy();
let args = create_linux_sandbox_command_args(command, &policy, cwd, true, true);
assert_eq!(
+1 -1
View File
@@ -165,7 +165,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
// Use ReadOnly sandbox policy for MCP snapshot collection (safest default)
let sandbox_state = SandboxState {
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap),
-84
View File
@@ -1,84 +0,0 @@
use std::path::Path;
use std::path::PathBuf;
use super::scope::MEMORY_SCOPE_KEY_USER;
pub(super) const ROLLOUT_SUMMARIES_SUBDIR: &str = "rollout_summaries";
pub(super) const RAW_MEMORIES_FILENAME: &str = "raw_memories.md";
pub(super) const MEMORY_REGISTRY_FILENAME: &str = "MEMORY.md";
pub(super) const LEGACY_CONSOLIDATED_FILENAME: &str = "consolidated.md";
pub(super) const SKILLS_SUBDIR: &str = "skills";
const LEGACY_MEMORY_SUBDIR: &str = "memory";
/// Returns the shared on-disk memory root directory.
pub(super) fn memory_root(codex_home: &Path) -> PathBuf {
codex_home.join("memories")
}
pub(super) fn rollout_summaries_dir(root: &Path) -> PathBuf {
root.join(ROLLOUT_SUMMARIES_SUBDIR)
}
pub(super) fn raw_memories_file(root: &Path) -> PathBuf {
root.join(RAW_MEMORIES_FILENAME)
}
/// Migrates legacy user memory contents into the shared root when no shared-root
/// phase artifacts exist yet.
pub(super) async fn migrate_legacy_user_memory_root_if_needed(
codex_home: &Path,
) -> std::io::Result<()> {
let root = memory_root(codex_home);
let legacy = legacy_user_memory_root(codex_home);
if !tokio::fs::try_exists(&legacy).await? || global_root_has_phase_artifacts(&root).await? {
return Ok(());
}
copy_dir_contents_if_missing(&legacy, &root).await
}
/// Ensures the phase-1 memory directory layout exists for the given root.
pub(super) async fn ensure_layout(root: &Path) -> std::io::Result<()> {
tokio::fs::create_dir_all(rollout_summaries_dir(root)).await
}
fn legacy_user_memory_root(codex_home: &Path) -> PathBuf {
codex_home
.join("memories")
.join(MEMORY_SCOPE_KEY_USER)
.join(LEGACY_MEMORY_SUBDIR)
}
async fn global_root_has_phase_artifacts(root: &Path) -> std::io::Result<bool> {
if tokio::fs::try_exists(&rollout_summaries_dir(root)).await?
|| tokio::fs::try_exists(&raw_memories_file(root)).await?
|| tokio::fs::try_exists(&root.join(MEMORY_REGISTRY_FILENAME)).await?
|| tokio::fs::try_exists(&root.join(LEGACY_CONSOLIDATED_FILENAME)).await?
|| tokio::fs::try_exists(&root.join(SKILLS_SUBDIR)).await?
{
return Ok(true);
}
Ok(false)
}
fn copy_dir_contents_if_missing<'a>(
src_dir: &'a Path,
dst_dir: &'a Path,
) -> futures::future::BoxFuture<'a, std::io::Result<()>> {
Box::pin(async move {
tokio::fs::create_dir_all(dst_dir).await?;
let mut dir = tokio::fs::read_dir(src_dir).await?;
while let Some(entry) = dir.next_entry().await? {
let src_path = entry.path();
let dst_path = dst_dir.join(entry.file_name());
let metadata = entry.metadata().await?;
if metadata.is_dir() {
copy_dir_contents_if_missing(&src_path, &dst_path).await?;
} else if metadata.is_file() && !tokio::fs::try_exists(&dst_path).await? {
tokio::fs::copy(&src_path, &dst_path).await?;
}
}
Ok(())
})
}
+39 -8
View File
@@ -2,31 +2,34 @@
//!
//! The startup memory pipeline is split into two phases:
//! - Phase 1: select rollouts, extract stage-1 raw memories, persist stage-1 outputs, and enqueue consolidation.
//! - Phase 2: claim scopes, materialize consolidation inputs, and dispatch consolidation agents.
//! - Phase 2: claim a global consolidation lock, materialize consolidation inputs, and dispatch one consolidation agent.
mod layout;
mod prompts;
mod rollout;
mod scope;
mod stage_one;
mod startup;
mod storage;
mod text;
mod types;
#[cfg(test)]
mod tests;
use serde::Deserialize;
use std::path::Path;
use std::path::PathBuf;
/// Subagent source label used to identify consolidation tasks.
const MEMORY_CONSOLIDATION_SUBAGENT_LABEL: &str = "memory_consolidation";
const ROLLOUT_SUMMARIES_SUBDIR: &str = "rollout_summaries";
const RAW_MEMORIES_FILENAME: &str = "raw_memories.md";
const MEMORY_REGISTRY_FILENAME: &str = "MEMORY.md";
const SKILLS_SUBDIR: &str = "skills";
/// Maximum number of rollout candidates processed per startup pass.
const MAX_ROLLOUTS_PER_STARTUP: usize = 64;
/// Concurrency cap for startup memory extraction and consolidation scheduling.
const PHASE_ONE_CONCURRENCY_LIMIT: usize = MAX_ROLLOUTS_PER_STARTUP;
/// Concurrency cap for phase-2 consolidation dispatch.
const PHASE_TWO_CONCURRENCY_LIMIT: usize = MAX_ROLLOUTS_PER_STARTUP;
/// Maximum number of recent raw memories retained per scope.
const MAX_RAW_MEMORIES_PER_SCOPE: usize = 64;
/// Maximum number of recent raw memories retained for global consolidation.
const MAX_RAW_MEMORIES_FOR_GLOBAL: usize = 64;
/// Maximum rollout age considered for phase-1 extraction.
const PHASE_ONE_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
/// Minimum rollout idle time required before phase-1 extraction.
@@ -42,6 +45,34 @@ const PHASE_TWO_JOB_RETRY_DELAY_SECONDS: i64 = 3_600;
/// Heartbeat interval (seconds) for phase-2 running jobs.
const PHASE_TWO_JOB_HEARTBEAT_SECONDS: u64 = 30;
/// Parsed stage-1 model output payload.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct StageOneOutput {
/// Detailed markdown raw memory for a single rollout.
#[serde(rename = "raw_memory")]
raw_memory: String,
/// Compact summary line used for routing and indexing.
#[serde(rename = "rollout_summary")]
rollout_summary: String,
}
fn memory_root(codex_home: &Path) -> PathBuf {
codex_home.join("memories")
}
fn rollout_summaries_dir(root: &Path) -> PathBuf {
root.join(ROLLOUT_SUMMARIES_SUBDIR)
}
fn raw_memories_file(root: &Path) -> PathBuf {
root.join(RAW_MEMORIES_FILENAME)
}
async fn ensure_layout(root: &Path) -> std::io::Result<()> {
tokio::fs::create_dir_all(rollout_summaries_dir(root)).await
}
/// Starts the memory startup pipeline for eligible root sessions.
///
/// This is the single entrypoint that `codex` uses to trigger memory startup.
-2
View File
@@ -1,2 +0,0 @@
pub(super) const MEMORY_SCOPE_KIND_USER: &str = "user";
pub(super) const MEMORY_SCOPE_KEY_USER: &str = "user";
+1 -9
View File
@@ -5,9 +5,9 @@ use regex::Regex;
use serde_json::Value;
use serde_json::json;
use super::StageOneOutput;
use super::text::compact_whitespace;
use super::text::truncate_text_for_storage;
use super::types::StageOneOutput;
/// System prompt for stage-1 raw memory extraction.
pub(super) const RAW_MEMORY_PROMPT: &str =
@@ -28,7 +28,6 @@ pub(super) fn stage_one_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"rollout_slug": { "type": "string" },
"rollout_summary": { "type": "string" },
"raw_memory": { "type": "string" }
},
@@ -97,12 +96,6 @@ fn parse_json_object_loose(raw: &str) -> Result<Value> {
fn normalize_stage_one_output(mut output: StageOneOutput) -> Result<StageOneOutput> {
output.raw_memory = output.raw_memory.trim().to_string();
output.rollout_summary = output.rollout_summary.trim().to_string();
if let Some(slug) = output.rollout_slug.take() {
let slug = slug.trim();
if !slug.is_empty() {
output.rollout_slug = Some(slug.to_string());
}
}
if output.raw_memory.is_empty() {
return Err(CodexErr::InvalidRequest(
@@ -195,7 +188,6 @@ mod tests {
fn normalize_stage_one_output_redacts_and_compacts_summary() {
let output = StageOneOutput {
raw_memory: "Token: sk-abcdefghijklmnopqrstuvwxyz123456\nBearer abcdefghijklmnopqrstuvwxyz012345".to_string(),
rollout_slug: None,
rollout_summary: "password = mysecret123456\n\nsmall".to_string(),
};
+334 -101
View File
@@ -1,5 +1,6 @@
use crate::codex::Session;
use crate::config::Config;
use crate::memories::memory_root;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
@@ -8,7 +9,7 @@ use tracing::debug;
use tracing::info;
use tracing::warn;
use super::super::MAX_RAW_MEMORIES_PER_SCOPE;
use super::super::MAX_RAW_MEMORIES_FOR_GLOBAL;
use super::super::MEMORY_CONSOLIDATION_SUBAGENT_LABEL;
use super::super::PHASE_TWO_JOB_LEASE_SECONDS;
use super::super::PHASE_TWO_JOB_RETRY_DELAY_SECONDS;
@@ -16,38 +17,25 @@ use super::super::prompts::build_consolidation_prompt;
use super::super::storage::rebuild_raw_memories_file_from_memories;
use super::super::storage::sync_rollout_summaries_from_memories;
use super::super::storage::wipe_consolidation_outputs;
use super::MemoryScopeTarget;
use super::watch::spawn_phase2_completion_task;
use super::phase2::spawn_phase2_completion_task;
pub(super) async fn run_memory_consolidation_for_scope(
session: Arc<Session>,
pub(super) async fn run_global_memory_consolidation(
session: &Arc<Session>,
config: Arc<Config>,
scope: MemoryScopeTarget,
) {
) -> bool {
let Some(state_db) = session.services.state_db.as_deref() else {
warn!(
"state db unavailable for scope {}:{}; skipping consolidation",
scope.scope_kind, scope.scope_key
);
return;
warn!("state db unavailable; skipping global memory consolidation");
return false;
};
let claim = match state_db
.try_claim_phase2_job(
scope.scope_kind,
&scope.scope_key,
session.conversation_id,
PHASE_TWO_JOB_LEASE_SECONDS,
)
.try_claim_global_phase2_job(session.conversation_id, PHASE_TWO_JOB_LEASE_SECONDS)
.await
{
Ok(claim) => claim,
Err(err) => {
warn!(
"state db try_claim_phase2_job failed for scope {}:{}: {err}",
scope.scope_kind, scope.scope_key
);
return;
warn!("state db try_claim_global_phase2_job failed during memories startup: {err}");
return false;
}
};
let (ownership_token, claimed_watermark) = match claim {
@@ -56,131 +44,90 @@ pub(super) async fn run_memory_consolidation_for_scope(
input_watermark,
} => (ownership_token, input_watermark),
codex_state::Phase2JobClaimOutcome::SkippedNotDirty => {
debug!(
"memory phase-2 scope not pending (or already up to date); skipping consolidation: {}:{}",
scope.scope_kind, scope.scope_key
);
return;
debug!("memory phase-2 global lock is up-to-date; skipping consolidation");
return false;
}
codex_state::Phase2JobClaimOutcome::SkippedRunning => {
debug!(
"memory phase-2 job already running for scope {}:{}; skipping",
scope.scope_kind, scope.scope_key
);
return;
debug!("memory phase-2 global consolidation already running; skipping");
return false;
}
};
let latest_memories = match state_db
.list_stage1_outputs_for_scope(
scope.scope_kind,
&scope.scope_key,
MAX_RAW_MEMORIES_PER_SCOPE,
)
.list_stage1_outputs_for_global(MAX_RAW_MEMORIES_FOR_GLOBAL)
.await
{
Ok(memories) => memories,
Err(err) => {
warn!(
"state db list_stage1_outputs_for_scope failed during consolidation for scope {}:{}: {err}",
scope.scope_kind, scope.scope_key
);
warn!("state db list_stage1_outputs_for_global failed during consolidation: {err}");
let _ = state_db
.mark_phase2_job_failed(
scope.scope_kind,
&scope.scope_key,
.mark_global_phase2_job_failed(
&ownership_token,
"failed to read scope stage-1 outputs before consolidation",
"failed to read stage-1 outputs before global consolidation",
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await;
return;
return false;
}
};
if latest_memories.is_empty() {
debug!(
"memory phase-2 scope has no stage-1 outputs; skipping consolidation: {}:{}",
scope.scope_kind, scope.scope_key
);
debug!("memory phase-2 has no stage-1 outputs; skipping global consolidation");
let _ = state_db
.mark_phase2_job_succeeded(
scope.scope_kind,
&scope.scope_key,
&ownership_token,
claimed_watermark,
)
.mark_global_phase2_job_succeeded(&ownership_token, claimed_watermark)
.await;
return;
return false;
};
let root = memory_root(&config.codex_home);
let materialized_watermark = latest_memories
.iter()
.map(|memory| memory.source_updated_at.timestamp())
.max()
.unwrap_or(claimed_watermark);
if let Err(err) =
sync_rollout_summaries_from_memories(&scope.memory_root, &latest_memories).await
{
warn!(
"failed syncing phase-1 rollout summaries for scope {}:{}: {err}",
scope.scope_kind, scope.scope_key
);
if let Err(err) = sync_rollout_summaries_from_memories(&root, &latest_memories).await {
warn!("failed syncing phase-1 rollout summaries for global consolidation: {err}");
let _ = state_db
.mark_phase2_job_failed(
scope.scope_kind,
&scope.scope_key,
.mark_global_phase2_job_failed(
&ownership_token,
"failed syncing phase-1 rollout summaries",
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await;
return;
return false;
}
if let Err(err) =
rebuild_raw_memories_file_from_memories(&scope.memory_root, &latest_memories).await
{
warn!(
"failed rebuilding raw memories aggregate for scope {}:{}: {err}",
scope.scope_kind, scope.scope_key
);
if let Err(err) = rebuild_raw_memories_file_from_memories(&root, &latest_memories).await {
warn!("failed rebuilding raw memories aggregate for global consolidation: {err}");
let _ = state_db
.mark_phase2_job_failed(
scope.scope_kind,
&scope.scope_key,
.mark_global_phase2_job_failed(
&ownership_token,
"failed rebuilding raw memories aggregate",
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await;
return;
return false;
}
if let Err(err) = wipe_consolidation_outputs(&scope.memory_root).await {
warn!(
"failed to wipe previous consolidation outputs for scope {}:{}: {err}",
scope.scope_kind, scope.scope_key
);
if let Err(err) = wipe_consolidation_outputs(&root).await {
warn!("failed to wipe previous global consolidation outputs: {err}");
let _ = state_db
.mark_phase2_job_failed(
scope.scope_kind,
&scope.scope_key,
.mark_global_phase2_job_failed(
&ownership_token,
"failed to wipe previous consolidation outputs",
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await;
return;
return false;
}
let prompt = build_consolidation_prompt(&scope.memory_root);
let prompt = build_consolidation_prompt(&root);
let input = vec![UserInput::Text {
text: prompt,
text_elements: vec![],
}];
let mut consolidation_config = config.as_ref().clone();
consolidation_config.cwd = scope.memory_root.clone();
consolidation_config.cwd = root.clone();
let source = SessionSource::SubAgent(SubAgentSource::Other(
MEMORY_CONSOLIDATION_SUBAGENT_LABEL.to_string(),
));
@@ -193,31 +140,317 @@ pub(super) async fn run_memory_consolidation_for_scope(
{
Ok(consolidation_agent_id) => {
info!(
"memory phase-2 consolidation agent started: scope={} scope_key={} agent_id={}",
scope.scope_kind, scope.scope_key, consolidation_agent_id
"memory phase-2 global consolidation agent started: agent_id={consolidation_agent_id}"
);
spawn_phase2_completion_task(
session.as_ref(),
scope,
ownership_token,
materialized_watermark,
consolidation_agent_id,
);
true
}
Err(err) => {
warn!(
"failed to spawn memory consolidation agent for scope {}:{}: {err}",
scope.scope_kind, scope.scope_key
);
warn!("failed to spawn global memory consolidation agent: {err}");
let _ = state_db
.mark_phase2_job_failed(
scope.scope_kind,
&scope.scope_key,
.mark_global_phase2_job_failed(
&ownership_token,
"failed to spawn consolidation agent",
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await;
false
}
}
}
#[cfg(test)]
mod tests {
use super::run_global_memory_consolidation;
use crate::CodexAuth;
use crate::ThreadManager;
use crate::agent::control::AgentControl;
use crate::codex::Session;
use crate::codex::make_session_and_context;
use crate::config::Config;
use crate::config::test_config;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_state::Phase2JobClaimOutcome;
use codex_state::ThreadMetadataBuilder;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tempfile::TempDir;
struct DispatchHarness {
_codex_home: TempDir,
config: Arc<Config>,
session: Arc<Session>,
manager: ThreadManager,
state_db: Arc<codex_state::StateRuntime>,
}
impl DispatchHarness {
async fn new() -> Self {
let codex_home = tempfile::tempdir().expect("create temp codex home");
let mut config = test_config();
config.codex_home = codex_home.path().to_path_buf();
config.cwd = config.codex_home.clone();
let config = Arc::new(config);
let state_db = codex_state::StateRuntime::init(
config.codex_home.clone(),
config.model_provider_id.clone(),
None,
)
.await
.expect("initialize state db");
let manager = ThreadManager::with_models_provider_and_home(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let (mut session, _turn_context) = make_session_and_context().await;
session.services.state_db = Some(Arc::clone(&state_db));
session.services.agent_control = manager.agent_control();
Self {
_codex_home: codex_home,
config,
session: Arc::new(session),
manager,
state_db,
}
}
async fn seed_stage1_output(&self, source_updated_at: i64) {
let thread_id = ThreadId::new();
let mut metadata_builder = ThreadMetadataBuilder::new(
thread_id,
self.config
.codex_home
.join(format!("rollout-{thread_id}.jsonl")),
Utc::now(),
SessionSource::Cli,
);
metadata_builder.cwd = self.config.cwd.clone();
metadata_builder.model_provider = Some(self.config.model_provider_id.clone());
let metadata = metadata_builder.build(&self.config.model_provider_id);
self.state_db
.upsert_thread(&metadata)
.await
.expect("upsert thread metadata");
let claim = self
.state_db
.try_claim_stage1_job(
thread_id,
self.session.conversation_id,
source_updated_at,
3_600,
64,
)
.await
.expect("claim stage-1 job");
let ownership_token = match claim {
codex_state::Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
other => panic!("unexpected stage-1 claim outcome: {other:?}"),
};
assert!(
self.state_db
.mark_stage1_job_succeeded(
thread_id,
&ownership_token,
source_updated_at,
"raw memory",
"rollout summary",
)
.await
.expect("mark stage-1 success"),
"stage-1 success should enqueue global consolidation"
);
}
async fn shutdown_threads(&self) {
self.manager
.remove_and_close_all_threads()
.await
.expect("shutdown spawned threads");
}
fn user_input_ops_count(&self) -> usize {
self.manager
.captured_ops()
.into_iter()
.filter(|(_, op)| matches!(op, Op::UserInput { .. }))
.count()
}
}
#[tokio::test]
async fn dispatch_reclaims_stale_global_lock_and_starts_consolidation() {
let harness = DispatchHarness::new().await;
harness.seed_stage1_output(100).await;
let stale_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 0)
.await
.expect("claim stale global lock");
assert!(
matches!(stale_claim, Phase2JobClaimOutcome::Claimed { .. }),
"stale lock precondition should be claimed"
);
let scheduled =
run_global_memory_consolidation(&harness.session, Arc::clone(&harness.config)).await;
assert!(
scheduled,
"dispatch should reclaim stale lock and spawn one agent"
);
let running_claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim while running");
assert_eq!(running_claim, Phase2JobClaimOutcome::SkippedRunning);
let user_input_ops = harness.user_input_ops_count();
assert_eq!(user_input_ops, 1);
harness.shutdown_threads().await;
}
#[tokio::test]
async fn dispatch_schedules_only_one_agent_while_lock_is_running() {
let harness = DispatchHarness::new().await;
harness.seed_stage1_output(200).await;
let first_run =
run_global_memory_consolidation(&harness.session, Arc::clone(&harness.config)).await;
let second_run =
run_global_memory_consolidation(&harness.session, Arc::clone(&harness.config)).await;
assert!(first_run, "first dispatch should schedule consolidation");
assert!(
!second_run,
"second dispatch should skip while the global lock is running"
);
let user_input_ops = harness.user_input_ops_count();
assert_eq!(user_input_ops, 1);
harness.shutdown_threads().await;
}
#[tokio::test]
async fn dispatch_with_dirty_job_and_no_stage1_outputs_skips_spawn_and_clears_dirty_flag() {
let harness = DispatchHarness::new().await;
harness
.state_db
.enqueue_global_consolidation(999)
.await
.expect("enqueue global consolidation");
let scheduled =
run_global_memory_consolidation(&harness.session, Arc::clone(&harness.config)).await;
assert!(
!scheduled,
"dispatch should not spawn when no stage-1 outputs are available"
);
assert_eq!(harness.user_input_ops_count(), 0);
let claim = harness
.state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim global job after empty dispatch");
assert_eq!(
claim,
Phase2JobClaimOutcome::SkippedNotDirty,
"empty dispatch should finalize global job as up-to-date"
);
harness.shutdown_threads().await;
}
#[tokio::test]
async fn dispatch_marks_job_for_retry_when_spawn_agent_fails() {
let codex_home = tempfile::tempdir().expect("create temp codex home");
let mut config = test_config();
config.codex_home = codex_home.path().to_path_buf();
config.cwd = config.codex_home.clone();
let config = Arc::new(config);
let state_db = codex_state::StateRuntime::init(
config.codex_home.clone(),
config.model_provider_id.clone(),
None,
)
.await
.expect("initialize state db");
let (mut session, _turn_context) = make_session_and_context().await;
session.services.state_db = Some(Arc::clone(&state_db));
session.services.agent_control = AgentControl::default();
let session = Arc::new(session);
let thread_id = ThreadId::new();
let mut metadata_builder = ThreadMetadataBuilder::new(
thread_id,
config.codex_home.join(format!("rollout-{thread_id}.jsonl")),
Utc::now(),
SessionSource::Cli,
);
metadata_builder.cwd = config.cwd.clone();
metadata_builder.model_provider = Some(config.model_provider_id.clone());
let metadata = metadata_builder.build(&config.model_provider_id);
state_db
.upsert_thread(&metadata)
.await
.expect("upsert thread metadata");
let claim = state_db
.try_claim_stage1_job(thread_id, session.conversation_id, 100, 3_600, 64)
.await
.expect("claim stage-1 job");
let ownership_token = match claim {
codex_state::Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
other => panic!("unexpected stage-1 claim outcome: {other:?}"),
};
assert!(
state_db
.mark_stage1_job_succeeded(
thread_id,
&ownership_token,
100,
"raw memory",
"rollout summary",
)
.await
.expect("mark stage-1 success"),
"stage-1 success should enqueue global consolidation"
);
let scheduled = run_global_memory_consolidation(&session, Arc::clone(&config)).await;
assert!(
!scheduled,
"dispatch should return false when consolidation subagent cannot be spawned"
);
let retry_claim = state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim global job after spawn failure");
assert_eq!(
retry_claim,
Phase2JobClaimOutcome::SkippedNotDirty,
"spawn failures should leave the job in retry backoff instead of running"
);
}
}
@@ -12,13 +12,13 @@ use futures::StreamExt;
use tracing::warn;
use super::StageOneRequestContext;
use crate::memories::StageOneOutput;
use crate::memories::prompts::build_stage_one_input_message;
use crate::memories::rollout::StageOneRolloutFilter;
use crate::memories::rollout::serialize_filtered_rollout_response_items;
use crate::memories::stage_one::RAW_MEMORY_PROMPT;
use crate::memories::stage_one::parse_stage_one_output;
use crate::memories::stage_one::stage_one_output_schema;
use crate::memories::types::StageOneOutput;
use std::path::Path;
pub(super) async fn extract_stage_one_output(
+18 -163
View File
@@ -1,16 +1,12 @@
mod dispatch;
mod extract;
mod watch;
mod phase2;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
use crate::error::Result as CodexResult;
use crate::features::Feature;
use crate::memories::layout::memory_root;
use crate::memories::layout::migrate_legacy_user_memory_root_if_needed;
use crate::memories::scope::MEMORY_SCOPE_KEY_USER;
use crate::memories::scope::MEMORY_SCOPE_KIND_USER;
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
use codex_otel::OtelManager;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -18,8 +14,6 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use futures::StreamExt;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::info;
use tracing::warn;
@@ -47,52 +41,6 @@ impl StageOneRequestContext {
}
}
/// Canonical memory scope metadata used by both startup phases.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct MemoryScopeTarget {
/// Scope family used for DB ownership and dirty-state tracking.
pub(super) scope_kind: &'static str,
/// Scope identifier used for DB keys.
pub(super) scope_key: String,
/// On-disk root where phase-1 artifacts and phase-2 outputs live.
pub(super) memory_root: PathBuf,
}
/// Converts a pending scope consolidation row into a concrete filesystem target for phase 2.
///
/// Unsupported scope kinds or malformed keys are ignored.
pub(super) fn memory_scope_target_for_pending_scope(
config: &Config,
pending_scope: codex_state::PendingScopeConsolidation,
) -> Option<MemoryScopeTarget> {
let scope_kind = pending_scope.scope_kind;
let scope_key = pending_scope.scope_key;
match scope_kind.as_str() {
MEMORY_SCOPE_KIND_USER => {
if scope_key != MEMORY_SCOPE_KEY_USER {
warn!(
"skipping unsupported user memory scope key for phase-2: {}:{}",
scope_kind, scope_key
);
return None;
}
Some(MemoryScopeTarget {
scope_kind: MEMORY_SCOPE_KIND_USER,
scope_key,
memory_root: memory_root(&config.codex_home),
})
}
_ => {
warn!(
"skipping unsupported memory scope for phase-2 consolidation: {}:{}",
scope_kind, scope_key
);
None
}
}
}
/// Starts the asynchronous startup memory pipeline for an eligible root session.
///
/// The pipeline is skipped for ephemeral sessions, disabled feature flags, and
@@ -125,15 +73,11 @@ pub(crate) fn start_memories_startup_task(
/// Phase 1 selects rollout candidates, performs stage-1 extraction requests in
/// parallel, persists stage-1 outputs, and enqueues consolidation work.
///
/// Phase 2 claims pending scopes and spawns consolidation agents.
/// Phase 2 claims a global consolidation lock and spawns one consolidation agent.
pub(super) async fn run_memories_startup_pipeline(
session: &Arc<Session>,
config: Arc<Config>,
) -> CodexResult<()> {
if let Err(err) = migrate_legacy_user_memory_root_if_needed(&config.codex_home).await {
warn!("failed migrating legacy shared memory root: {err}");
}
let Some(state_db) = session.services.state_db.as_deref() else {
warn!("state db unavailable for memories startup pipeline; skipping");
return Ok(());
@@ -141,11 +85,7 @@ pub(super) async fn run_memories_startup_pipeline(
let allowed_sources = INTERACTIVE_SESSION_SOURCES
.iter()
.map(|value| match serde_json::to_value(value) {
Ok(Value::String(s)) => s,
Ok(other) => other.to_string(),
Err(_) => String::new(),
})
.map(ToString::to_string)
.collect::<Vec<_>>();
let claimed_candidates = match state_db
@@ -236,115 +176,30 @@ pub(super) async fn run_memories_startup_pipeline(
claimed_count, succeeded_count
);
let consolidation_scope_count = run_consolidation_dispatch(session, config).await;
let consolidation_job_count =
usize::from(dispatch::run_global_memory_consolidation(session, config).await);
info!(
"memory consolidation dispatch complete: {} scope(s) scheduled",
consolidation_scope_count
"memory consolidation dispatch complete: {} job(s) scheduled",
consolidation_job_count
);
Ok(())
}
async fn run_consolidation_dispatch(session: &Arc<Session>, config: Arc<Config>) -> usize {
let scopes = list_consolidation_scopes(
session.as_ref(),
config.as_ref(),
super::MAX_ROLLOUTS_PER_STARTUP,
)
.await;
let consolidation_scope_count = scopes.len();
futures::stream::iter(scopes.into_iter())
.map(|scope| {
let session = Arc::clone(session);
let config = Arc::clone(&config);
async move {
dispatch::run_memory_consolidation_for_scope(session, config, scope).await;
}
})
.buffer_unordered(super::PHASE_TWO_CONCURRENCY_LIMIT)
.collect::<Vec<_>>()
.await;
consolidation_scope_count
}
async fn list_consolidation_scopes(
session: &Session,
config: &Config,
limit: usize,
) -> Vec<MemoryScopeTarget> {
if limit == 0 {
return Vec::new();
}
let Some(state_db) = session.services.state_db.as_deref() else {
return Vec::new();
};
let pending_scopes = match state_db.list_pending_scope_consolidations(limit).await {
Ok(scopes) => scopes,
Err(_) => return Vec::new(),
};
pending_scopes
.into_iter()
.filter_map(|scope| memory_scope_target_for_pending_scope(config, scope))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use super::run_memories_startup_pipeline;
use crate::codex::make_session_and_context;
use crate::config::test_config;
use std::sync::Arc;
/// Verifies that phase-2 pending scope rows are translated only for supported scopes.
#[test]
fn pending_scope_mapping_accepts_supported_scopes_only() {
let mut config = test_config();
config.codex_home = "/tmp/memory-startup-test-home".into();
let user_target = memory_scope_target_for_pending_scope(
&config,
codex_state::PendingScopeConsolidation {
scope_kind: MEMORY_SCOPE_KIND_USER.to_string(),
scope_key: MEMORY_SCOPE_KEY_USER.to_string(),
},
)
.expect("valid user scope should map");
assert_eq!(user_target.scope_kind, MEMORY_SCOPE_KIND_USER);
assert!(
memory_scope_target_for_pending_scope(
&config,
codex_state::PendingScopeConsolidation {
scope_kind: MEMORY_SCOPE_KIND_USER.to_string(),
scope_key: "unexpected-user-key".to_string(),
},
)
.is_none()
);
assert!(
memory_scope_target_for_pending_scope(
&config,
codex_state::PendingScopeConsolidation {
scope_kind: "cwd".to_string(),
scope_key: "/tmp/project-a".to_string(),
},
)
.is_none()
);
assert!(
memory_scope_target_for_pending_scope(
&config,
codex_state::PendingScopeConsolidation {
scope_kind: "unknown".to_string(),
scope_key: "scope".to_string(),
},
)
.is_none()
);
#[tokio::test]
async fn startup_pipeline_is_noop_when_state_db_is_unavailable() {
let (session, _turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let config = Arc::new(test_config());
run_memories_startup_pipeline(&session, config)
.await
.expect("startup pipeline should skip cleanly without state db");
}
}
@@ -0,0 +1,384 @@
use crate::agent::AgentStatus;
use crate::agent::status::is_final as is_final_agent_status;
use crate::codex::Session;
use codex_protocol::ThreadId;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
use tracing::debug;
use tracing::info;
use tracing::warn;
use super::super::PHASE_TWO_JOB_HEARTBEAT_SECONDS;
use super::super::PHASE_TWO_JOB_LEASE_SECONDS;
use super::super::PHASE_TWO_JOB_RETRY_DELAY_SECONDS;
pub(super) fn spawn_phase2_completion_task(
session: &Session,
ownership_token: String,
completion_watermark: i64,
consolidation_agent_id: ThreadId,
) {
let state_db = session.services.state_db.clone();
let agent_control = session.services.agent_control.clone();
tokio::spawn(async move {
let Some(state_db) = state_db else {
return;
};
let status_rx = match agent_control.subscribe_status(consolidation_agent_id).await {
Ok(status_rx) => status_rx,
Err(err) => {
warn!(
"failed to subscribe to global memory consolidation agent {consolidation_agent_id}: {err}"
);
mark_phase2_failed_with_recovery(
state_db.as_ref(),
&ownership_token,
"failed to subscribe to consolidation agent status",
)
.await;
return;
}
};
run_phase2_completion_task(
Arc::clone(&state_db),
ownership_token,
completion_watermark,
consolidation_agent_id,
status_rx,
)
.await;
});
}
async fn run_phase2_completion_task(
state_db: Arc<codex_state::StateRuntime>,
ownership_token: String,
completion_watermark: i64,
consolidation_agent_id: ThreadId,
mut status_rx: watch::Receiver<AgentStatus>,
) {
let final_status = {
let mut heartbeat_interval =
tokio::time::interval(Duration::from_secs(PHASE_TWO_JOB_HEARTBEAT_SECONDS));
heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
let status = status_rx.borrow().clone();
if is_final_agent_status(&status) {
break status;
}
tokio::select! {
changed = status_rx.changed() => {
if changed.is_err() {
warn!(
"lost status updates for global memory consolidation agent {consolidation_agent_id}"
);
break status;
}
}
_ = heartbeat_interval.tick() => {
match state_db
.heartbeat_global_phase2_job(&ownership_token, PHASE_TWO_JOB_LEASE_SECONDS)
.await
{
Ok(true) => {}
Ok(false) => {
warn!(
"memory phase-2 heartbeat lost global ownership; finalizing as failure"
);
break AgentStatus::Errored(
"lost global phase-2 ownership during heartbeat".to_string(),
);
}
Err(err) => {
warn!(
"state db heartbeat_global_phase2_job failed during memories startup: {err}"
);
break AgentStatus::Errored(format!(
"phase-2 heartbeat update failed: {err}"
));
}
}
}
}
}
};
if is_phase2_success(&final_status) {
match state_db
.mark_global_phase2_job_succeeded(&ownership_token, completion_watermark)
.await
{
Ok(true) => {}
Ok(false) => {
debug!(
"memory phase-2 success finalization skipped after global ownership changed"
);
}
Err(err) => {
warn!(
"state db mark_global_phase2_job_succeeded failed during memories startup: {err}"
);
}
}
info!(
"memory phase-2 global consolidation agent finished: agent_id={consolidation_agent_id} final_status={final_status:?}"
);
return;
}
let failure_reason = phase2_failure_reason(&final_status);
mark_phase2_failed_with_recovery(state_db.as_ref(), &ownership_token, &failure_reason).await;
warn!(
"memory phase-2 global consolidation agent finished with non-success status: agent_id={consolidation_agent_id} final_status={final_status:?}"
);
}
async fn mark_phase2_failed_with_recovery(
state_db: &codex_state::StateRuntime,
ownership_token: &str,
failure_reason: &str,
) {
match state_db
.mark_global_phase2_job_failed(
ownership_token,
failure_reason,
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await
{
Ok(true) => {}
Ok(false) => match state_db
.mark_global_phase2_job_failed_if_unowned(
ownership_token,
failure_reason,
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await
{
Ok(true) => {
debug!(
"memory phase-2 failure finalization applied fallback update for unowned running job"
);
}
Ok(false) => {
debug!(
"memory phase-2 failure finalization skipped after global ownership changed"
);
}
Err(err) => {
warn!(
"state db mark_global_phase2_job_failed_if_unowned failed during memories startup: {err}"
);
}
},
Err(err) => {
warn!("state db mark_global_phase2_job_failed failed during memories startup: {err}");
}
}
}
fn is_phase2_success(final_status: &AgentStatus) -> bool {
matches!(final_status, AgentStatus::Completed(_))
}
fn phase2_failure_reason(final_status: &AgentStatus) -> String {
format!("consolidation agent finished with status {final_status:?}")
}
#[cfg(test)]
mod tests {
use super::is_phase2_success;
use super::phase2_failure_reason;
use super::run_phase2_completion_task;
use crate::agent::AgentStatus;
use codex_protocol::ThreadId;
use codex_state::Phase2JobClaimOutcome;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[test]
fn phase2_success_only_for_completed_status() {
assert!(is_phase2_success(&AgentStatus::Completed(None)));
assert!(!is_phase2_success(&AgentStatus::Running));
assert!(!is_phase2_success(&AgentStatus::Errored(
"oops".to_string()
)));
}
#[test]
fn phase2_failure_reason_includes_status() {
let status = AgentStatus::Errored("boom".to_string());
let reason = phase2_failure_reason(&status);
assert!(reason.contains("consolidation agent finished with status"));
assert!(reason.contains("boom"));
}
#[tokio::test]
async fn phase2_completion_marks_succeeded_for_completed_status() {
let codex_home = tempfile::tempdir().expect("create temp codex home");
let state_db = Arc::new(
codex_state::StateRuntime::init(
codex_home.path().to_path_buf(),
"test-provider".to_string(),
None,
)
.await
.expect("initialize state runtime"),
);
let owner = ThreadId::new();
state_db
.enqueue_global_consolidation(123)
.await
.expect("enqueue global consolidation");
let claim = state_db
.try_claim_global_phase2_job(owner, 3_600)
.await
.expect("claim global phase-2 job");
let ownership_token = match claim {
Phase2JobClaimOutcome::Claimed {
ownership_token, ..
} => ownership_token,
other => panic!("unexpected phase-2 claim outcome: {other:?}"),
};
let (_status_tx, status_rx) = tokio::sync::watch::channel(AgentStatus::Completed(None));
run_phase2_completion_task(
Arc::clone(&state_db),
ownership_token.clone(),
123,
ThreadId::new(),
status_rx,
)
.await;
let up_to_date_claim = state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim up-to-date global job");
assert_eq!(up_to_date_claim, Phase2JobClaimOutcome::SkippedNotDirty);
state_db
.enqueue_global_consolidation(124)
.await
.expect("enqueue advanced consolidation watermark");
let rerun_claim = state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim rerun global job");
assert!(
matches!(rerun_claim, Phase2JobClaimOutcome::Claimed { .. }),
"advanced watermark should be claimable after success finalization"
);
}
#[tokio::test]
async fn phase2_completion_marks_failed_when_status_updates_are_lost() {
let codex_home = tempfile::tempdir().expect("create temp codex home");
let state_db = Arc::new(
codex_state::StateRuntime::init(
codex_home.path().to_path_buf(),
"test-provider".to_string(),
None,
)
.await
.expect("initialize state runtime"),
);
state_db
.enqueue_global_consolidation(456)
.await
.expect("enqueue global consolidation");
let claim = state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim global phase-2 job");
let ownership_token = match claim {
Phase2JobClaimOutcome::Claimed {
ownership_token, ..
} => ownership_token,
other => panic!("unexpected phase-2 claim outcome: {other:?}"),
};
let (status_tx, status_rx) = tokio::sync::watch::channel(AgentStatus::Running);
drop(status_tx);
run_phase2_completion_task(
Arc::clone(&state_db),
ownership_token,
456,
ThreadId::new(),
status_rx,
)
.await;
let claim = state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim after failure finalization");
assert_eq!(
claim,
Phase2JobClaimOutcome::SkippedNotDirty,
"failure finalization should leave global job in retry-backoff, not running ownership"
);
}
#[tokio::test]
async fn phase2_completion_heartbeat_loss_does_not_steal_active_other_owner() {
let codex_home = tempfile::tempdir().expect("create temp codex home");
let state_db = Arc::new(
codex_state::StateRuntime::init(
codex_home.path().to_path_buf(),
"test-provider".to_string(),
None,
)
.await
.expect("initialize state runtime"),
);
state_db
.enqueue_global_consolidation(789)
.await
.expect("enqueue global consolidation");
let claim = state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim global phase-2 job");
let claimed_token = match claim {
Phase2JobClaimOutcome::Claimed {
ownership_token, ..
} => ownership_token,
other => panic!("unexpected phase-2 claim outcome: {other:?}"),
};
let (_status_tx, status_rx) = tokio::sync::watch::channel(AgentStatus::Running);
run_phase2_completion_task(
Arc::clone(&state_db),
"non-owner-token".to_string(),
789,
ThreadId::new(),
status_rx,
)
.await;
let claim = state_db
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim after heartbeat ownership loss");
assert_eq!(
claim,
Phase2JobClaimOutcome::SkippedRunning,
"heartbeat ownership-loss handling should not steal a live owner lease"
);
assert_eq!(
state_db
.mark_global_phase2_job_succeeded(claimed_token.as_str(), 789)
.await
.expect("mark original owner success"),
true,
"the original owner should still be able to finalize"
);
}
}
-188
View File
@@ -1,188 +0,0 @@
use crate::agent::AgentStatus;
use crate::agent::status::is_final as is_final_agent_status;
use crate::codex::Session;
use codex_protocol::ThreadId;
use std::time::Duration;
use tracing::debug;
use tracing::info;
use tracing::warn;
use super::super::PHASE_TWO_JOB_HEARTBEAT_SECONDS;
use super::super::PHASE_TWO_JOB_LEASE_SECONDS;
use super::super::PHASE_TWO_JOB_RETRY_DELAY_SECONDS;
use super::MemoryScopeTarget;
pub(super) fn spawn_phase2_completion_task(
session: &Session,
scope: MemoryScopeTarget,
ownership_token: String,
completion_watermark: i64,
consolidation_agent_id: ThreadId,
) {
let state_db = session.services.state_db.clone();
let agent_control = session.services.agent_control.clone();
tokio::spawn(async move {
let Some(state_db) = state_db.as_deref() else {
return;
};
let mut status_rx = match agent_control.subscribe_status(consolidation_agent_id).await {
Ok(status_rx) => status_rx,
Err(err) => {
warn!(
"failed to subscribe to memory consolidation agent {} for scope {}:{}: {err}",
consolidation_agent_id, scope.scope_kind, scope.scope_key
);
let _ = state_db
.mark_phase2_job_failed(
scope.scope_kind,
&scope.scope_key,
&ownership_token,
"failed to subscribe to consolidation agent status",
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await;
return;
}
};
let mut heartbeat_interval =
tokio::time::interval(Duration::from_secs(PHASE_TWO_JOB_HEARTBEAT_SECONDS));
heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let final_status = loop {
let status = status_rx.borrow().clone();
if is_final_agent_status(&status) {
break status;
}
tokio::select! {
changed = status_rx.changed() => {
if changed.is_err() {
warn!(
"lost status updates for memory consolidation agent {} in scope {}:{}",
consolidation_agent_id, scope.scope_kind, scope.scope_key
);
break status;
}
}
_ = heartbeat_interval.tick() => {
match state_db
.heartbeat_phase2_job(
scope.scope_kind,
&scope.scope_key,
&ownership_token,
PHASE_TWO_JOB_LEASE_SECONDS,
)
.await
{
Ok(true) => {}
Ok(false) => {
debug!(
"memory phase-2 heartbeat lost ownership for scope {}:{}; skipping finalization",
scope.scope_kind, scope.scope_key
);
return;
}
Err(err) => {
warn!(
"state db heartbeat_phase2_job failed during memories startup: {err}"
);
return;
}
}
}
}
};
if is_phase2_success(&final_status) {
match state_db
.mark_phase2_job_succeeded(
scope.scope_kind,
&scope.scope_key,
&ownership_token,
completion_watermark,
)
.await
{
Ok(true) => {}
Ok(false) => {
debug!(
"memory phase-2 success finalization skipped after ownership changed: scope={} scope_key={}",
scope.scope_kind, scope.scope_key
);
}
Err(err) => {
warn!(
"state db mark_phase2_job_succeeded failed during memories startup: {err}"
);
}
}
info!(
"memory phase-2 consolidation agent finished: scope={} scope_key={} agent_id={} final_status={final_status:?}",
scope.scope_kind, scope.scope_key, consolidation_agent_id
);
return;
}
let failure_reason = phase2_failure_reason(&final_status);
match state_db
.mark_phase2_job_failed(
scope.scope_kind,
&scope.scope_key,
&ownership_token,
&failure_reason,
PHASE_TWO_JOB_RETRY_DELAY_SECONDS,
)
.await
{
Ok(true) => {}
Ok(false) => {
debug!(
"memory phase-2 failure finalization skipped after ownership changed: scope={} scope_key={}",
scope.scope_kind, scope.scope_key
);
}
Err(err) => {
warn!("state db mark_phase2_job_failed failed during memories startup: {err}");
}
}
warn!(
"memory phase-2 consolidation agent finished with non-success status: scope={} scope_key={} agent_id={} final_status={final_status:?}",
scope.scope_kind, scope.scope_key, consolidation_agent_id
);
});
}
fn is_phase2_success(final_status: &AgentStatus) -> bool {
matches!(final_status, AgentStatus::Completed(_))
}
fn phase2_failure_reason(final_status: &AgentStatus) -> String {
format!("consolidation agent finished with status {final_status:?}")
}
#[cfg(test)]
mod tests {
use super::is_phase2_success;
use super::phase2_failure_reason;
use crate::agent::AgentStatus;
#[test]
fn phase2_success_only_for_completed_status() {
assert!(is_phase2_success(&AgentStatus::Completed(None)));
assert!(!is_phase2_success(&AgentStatus::Running));
assert!(!is_phase2_success(&AgentStatus::Errored(
"oops".to_string()
)));
}
#[test]
fn phase2_failure_reason_includes_status() {
let status = AgentStatus::Errored("boom".to_string());
let reason = phase2_failure_reason(&status);
assert!(reason.contains("consolidation agent finished with status"));
assert!(reason.contains("boom"));
}
}
+17 -20
View File
@@ -4,14 +4,13 @@ use std::fmt::Write as _;
use std::path::Path;
use tracing::warn;
use super::MAX_RAW_MEMORIES_PER_SCOPE;
use super::MAX_RAW_MEMORIES_FOR_GLOBAL;
use super::MEMORY_REGISTRY_FILENAME;
use super::SKILLS_SUBDIR;
use super::ensure_layout;
use super::raw_memories_file;
use super::rollout_summaries_dir;
use super::text::compact_whitespace;
use crate::memories::layout::LEGACY_CONSOLIDATED_FILENAME;
use crate::memories::layout::MEMORY_REGISTRY_FILENAME;
use crate::memories::layout::SKILLS_SUBDIR;
use crate::memories::layout::ensure_layout;
use crate::memories::layout::raw_memories_file;
use crate::memories::layout::rollout_summaries_dir;
/// Rebuild `raw_memories.md` from DB-backed stage-1 outputs.
pub(super) async fn rebuild_raw_memories_file_from_memories(
@@ -31,7 +30,7 @@ pub(super) async fn sync_rollout_summaries_from_memories(
let retained = memories
.iter()
.take(MAX_RAW_MEMORIES_PER_SCOPE)
.take(MAX_RAW_MEMORIES_FOR_GLOBAL)
.collect::<Vec<_>>();
let keep = retained
.iter()
@@ -49,16 +48,14 @@ pub(super) async fn sync_rollout_summaries_from_memories(
///
/// Phase-1 artifacts (`rollout_summaries/` and `raw_memories.md`) are preserved.
pub(super) async fn wipe_consolidation_outputs(root: &Path) -> std::io::Result<()> {
for file_name in [MEMORY_REGISTRY_FILENAME, LEGACY_CONSOLIDATED_FILENAME] {
let path = root.join(file_name);
if let Err(err) = tokio::fs::remove_file(&path).await
&& err.kind() != std::io::ErrorKind::NotFound
{
warn!(
"failed removing consolidation file {}: {err}",
path.display()
);
}
let path = root.join(MEMORY_REGISTRY_FILENAME);
if let Err(err) = tokio::fs::remove_file(&path).await
&& err.kind() != std::io::ErrorKind::NotFound
{
warn!(
"failed removing consolidation file {}: {err}",
path.display()
);
}
let skills_dir = root.join(SKILLS_SUBDIR);
@@ -77,7 +74,7 @@ pub(super) async fn wipe_consolidation_outputs(root: &Path) -> std::io::Result<(
async fn rebuild_raw_memories_file(root: &Path, memories: &[Stage1Output]) -> std::io::Result<()> {
let retained = memories
.iter()
.take(MAX_RAW_MEMORIES_PER_SCOPE)
.take(MAX_RAW_MEMORIES_FOR_GLOBAL)
.collect::<Vec<_>>();
let mut body = String::from("# Raw Memories\n\n");
@@ -152,7 +149,7 @@ async fn write_rollout_summary_for_thread(
.map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?;
writeln!(body)
.map_err(|err| std::io::Error::other(format!("format rollout summary: {err}")))?;
body.push_str(&compact_whitespace(&memory.summary));
body.push_str(&compact_whitespace(&memory.rollout_summary));
body.push('\n');
tokio::fs::write(path, body).await
+9 -46
View File
@@ -5,11 +5,10 @@ use super::stage_one::parse_stage_one_output;
use super::storage::rebuild_raw_memories_file_from_memories;
use super::storage::sync_rollout_summaries_from_memories;
use super::storage::wipe_consolidation_outputs;
use crate::memories::layout::ensure_layout;
use crate::memories::layout::memory_root;
use crate::memories::layout::migrate_legacy_user_memory_root_if_needed;
use crate::memories::layout::raw_memories_file;
use crate::memories::layout::rollout_summaries_dir;
use crate::memories::ensure_layout;
use crate::memories::memory_root;
use crate::memories::raw_memories_file;
use crate::memories::rollout_summaries_dir;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::ThreadId;
@@ -28,49 +27,18 @@ fn memory_root_uses_shared_global_path() {
assert_eq!(memory_root(&codex_home), codex_home.join("memories"));
}
#[tokio::test]
async fn migrate_legacy_user_memory_root_if_needed_copies_contents() {
let dir = tempdir().expect("tempdir");
let codex_home = dir.path().join("codex");
let legacy_root = codex_home.join("memories").join("user").join("memory");
tokio::fs::create_dir_all(legacy_root.join("rollout_summaries"))
.await
.expect("create legacy rollout summaries dir");
tokio::fs::write(
legacy_root.join("rollout_summaries").join("thread.md"),
"summary",
)
.await
.expect("write legacy rollout summary");
tokio::fs::write(legacy_root.join("raw_memories.md"), "raw")
.await
.expect("write legacy raw memories");
migrate_legacy_user_memory_root_if_needed(&codex_home)
.await
.expect("migrate legacy memory root");
let root = memory_root(&codex_home);
assert!(root.join("rollout_summaries").join("thread.md").is_file());
assert!(root.join("raw_memories.md").is_file());
}
#[test]
fn parse_stage_one_output_accepts_fenced_json() {
let raw = "```json\n{\"raw_memory\":\"abc\",\"rollout_summary\":\"short\",\"rollout_slug\":\"slug\"}\n```";
let raw = "```json\n{\"raw_memory\":\"abc\",\"rollout_summary\":\"short\"}\n```";
let parsed = parse_stage_one_output(raw).expect("parsed");
assert!(parsed.raw_memory.contains("abc"));
assert_eq!(parsed.rollout_summary, "short");
assert_eq!(parsed.rollout_slug, Some("slug".to_string()));
}
#[test]
fn parse_stage_one_output_accepts_legacy_keys() {
fn parse_stage_one_output_rejects_legacy_keys() {
let raw = r#"{"rawMemory":"abc","summary":"short"}"#;
let parsed = parse_stage_one_output(raw).expect("parsed");
assert!(parsed.raw_memory.contains("abc"));
assert_eq!(parsed.rollout_summary, "short");
assert_eq!(parsed.rollout_slug, None);
assert!(parse_stage_one_output(raw).is_err());
}
#[test]
@@ -194,7 +162,7 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
thread_id: ThreadId::try_from(keep_id.clone()).expect("thread id"),
source_updated_at: Utc.timestamp_opt(100, 0).single().expect("timestamp"),
raw_memory: "raw memory".to_string(),
summary: "short summary".to_string(),
rollout_summary: "short summary".to_string(),
generated_at: Utc.timestamp_opt(101, 0).single().expect("timestamp"),
}];
@@ -216,13 +184,12 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
}
#[tokio::test]
async fn wipe_consolidation_outputs_removes_registry_skills_and_legacy_file() {
async fn wipe_consolidation_outputs_removes_registry_and_skills() {
let dir = tempdir().expect("tempdir");
let root = dir.path().join("memory");
ensure_layout(&root).await.expect("ensure layout");
let memory_registry = root.join("MEMORY.md");
let legacy_consolidated = root.join("consolidated.md");
let skills_dir = root.join("skills").join("example");
tokio::fs::create_dir_all(&skills_dir)
@@ -231,15 +198,11 @@ async fn wipe_consolidation_outputs_removes_registry_skills_and_legacy_file() {
tokio::fs::write(&memory_registry, "memory")
.await
.expect("write memory registry");
tokio::fs::write(&legacy_consolidated, "legacy")
.await
.expect("write legacy consolidated");
wipe_consolidation_outputs(&root)
.await
.expect("wipe consolidation outputs");
assert!(!memory_registry.exists());
assert!(!legacy_consolidated.exists());
assert!(!root.join("skills").exists());
}
-15
View File
@@ -1,15 +0,0 @@
use serde::Deserialize;
/// Parsed stage-1 model output payload.
#[derive(Debug, Clone, Deserialize)]
pub(super) struct StageOneOutput {
/// Detailed markdown raw memory for a single rollout.
#[serde(rename = "raw_memory", alias = "rawMemory", alias = "traceMemory")]
pub(super) raw_memory: String,
/// Optional rollout slug from the model output. Accepted but ignored.
#[serde(default)]
pub(super) rollout_slug: Option<String>,
/// Compact summary line used for routing and indexing.
#[serde(rename = "rollout_summary", alias = "summary")]
pub(super) rollout_summary: String,
}
+66 -12
View File
@@ -30,6 +30,10 @@ use tracing::warn;
const ROLLOUT_PREFIX: &str = "rollout-";
const ROLLOUT_SUFFIX: &str = ".jsonl";
const BACKFILL_BATCH_SIZE: usize = 200;
#[cfg(not(test))]
const BACKFILL_LEASE_SECONDS: i64 = 900;
#[cfg(test)]
const BACKFILL_LEASE_SECONDS: i64 = 1;
pub(crate) fn builder_from_session_meta(
session_meta: &SessionMetaLine,
@@ -45,7 +49,7 @@ pub(crate) fn builder_from_session_meta(
builder.model_provider = session_meta.meta.model_provider.clone();
builder.cwd = session_meta.meta.cwd.clone();
builder.cli_version = Some(session_meta.meta.cli_version.clone());
builder.sandbox_policy = SandboxPolicy::ReadOnly;
builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
builder.approval_mode = AskForApproval::OnRequest;
if let Some(git) = session_meta.git.as_ref() {
builder.git_sha = git.commit_hash.clone();
@@ -133,7 +137,7 @@ pub(crate) async fn backfill_sessions(
otel: Option<&OtelManager>,
) {
let timer = otel.and_then(|otel| otel.start_timer(DB_METRIC_BACKFILL_DURATION_MS, &[]).ok());
let mut backfill_state = match runtime.get_backfill_state().await {
let backfill_state = match runtime.get_backfill_state().await {
Ok(state) => state,
Err(err) => {
warn!(
@@ -149,20 +153,66 @@ pub(crate) async fn backfill_sessions(
if backfill_state.status == BackfillStatus::Complete {
return;
}
if let Err(err) = runtime.mark_backfill_running().await {
warn!(
"failed to mark backfill running at {}: {err}",
let claimed = match runtime.try_claim_backfill(BACKFILL_LEASE_SECONDS).await {
Ok(claimed) => claimed,
Err(err) => {
warn!(
"failed to claim backfill worker at {}: {err}",
config.codex_home.display()
);
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_state_claim_running")],
);
}
return;
}
};
if !claimed {
info!(
"state db backfill already running at {}; skipping duplicate worker",
config.codex_home.display()
);
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_state_mark_running")],
return;
}
let mut backfill_state = match runtime.get_backfill_state().await {
Ok(state) => state,
Err(err) => {
warn!(
"failed to read claimed backfill state at {}: {err}",
config.codex_home.display()
);
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_state_read_claimed")],
);
}
BackfillState {
status: BackfillStatus::Running,
..Default::default()
}
}
};
if backfill_state.status != BackfillStatus::Running {
if let Err(err) = runtime.mark_backfill_running().await {
warn!(
"failed to mark backfill running at {}: {err}",
config.codex_home.display()
);
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_state_mark_running")],
);
}
} else {
backfill_state.status = BackfillStatus::Running;
}
} else {
backfill_state.status = BackfillStatus::Running;
}
let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR);
@@ -550,6 +600,10 @@ mod tests {
.checkpoint_backfill(first_watermark.as_str())
.await
.expect("checkpoint first watermark");
tokio::time::sleep(std::time::Duration::from_secs(
(BACKFILL_LEASE_SECONDS + 1) as u64,
))
.await;
let mut config = crate::config::test_config();
config.codex_home = codex_home.clone();
+3 -1
View File
@@ -108,7 +108,7 @@ fn is_write_patch_constrained_to_writable_paths(
) -> bool {
// Early‑exit if there are no declared writable roots.
let writable_roots = match sandbox_policy {
SandboxPolicy::ReadOnly => {
SandboxPolicy::ReadOnly { .. } => {
return false;
}
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
@@ -195,6 +195,7 @@ mod tests {
// only `cwd` is writable by default.
let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -216,6 +217,7 @@ mod tests {
// outside write should be permitted.
let policy_with_parent = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::try_from(parent).unwrap()],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+47 -6
View File
@@ -237,10 +237,40 @@ pub(crate) fn create_seatbelt_command_args(
}
};
let file_read_policy = if sandbox_policy.has_full_disk_read_access() {
"; allow read-only file operations\n(allow file-read*)"
let (file_read_policy, file_read_dir_params) = if sandbox_policy.has_full_disk_read_access() {
(
"; allow read-only file operations\n(allow file-read*)".to_string(),
Vec::new(),
)
} else {
""
let mut readable_roots_policies: Vec<String> = Vec::new();
let mut file_read_params = Vec::new();
for (index, root) in sandbox_policy
.get_readable_roots_with_cwd(sandbox_policy_cwd)
.into_iter()
.enumerate()
{
// Canonicalize to avoid mismatches like /var vs /private/var on macOS.
let canonical_root = root
.as_path()
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf());
let root_param = format!("READABLE_ROOT_{index}");
file_read_params.push((root_param.clone(), canonical_root));
readable_roots_policies.push(format!("(subpath (param \"{root_param}\"))"));
}
if readable_roots_policies.is_empty() {
("".to_string(), Vec::new())
} else {
(
format!(
"; allow read-only file operations\n(allow file-read*\n{}\n)",
readable_roots_policies.join(" ")
),
file_read_params,
)
}
};
let proxy = proxy_policy_inputs(network);
@@ -250,7 +280,12 @@ pub(crate) fn create_seatbelt_command_args(
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
);
let dir_params = [file_write_dir_params, macos_dir_params()].concat();
let dir_params = [
file_read_dir_params,
file_write_dir_params,
macos_dir_params(),
]
.concat();
let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
let definition_args = dir_params
@@ -317,7 +352,7 @@ mod tests {
#[test]
fn create_seatbelt_args_routes_network_through_proxy_ports() {
let policy = dynamic_network_policy(
&SandboxPolicy::ReadOnly,
&SandboxPolicy::new_read_only_policy(),
false,
&ProxyPolicyInputs {
ports: vec![43128, 48081],
@@ -351,7 +386,7 @@ mod tests {
#[test]
fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() {
let policy = dynamic_network_policy(
&SandboxPolicy::ReadOnly,
&SandboxPolicy::new_read_only_policy(),
false,
&ProxyPolicyInputs {
ports: vec![43128],
@@ -383,6 +418,7 @@ mod tests {
let policy = dynamic_network_policy(
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -410,6 +446,7 @@ mod tests {
let policy = dynamic_network_policy(
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -430,6 +467,7 @@ mod tests {
let policy = dynamic_network_policy(
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -479,6 +517,7 @@ mod tests {
.into_iter()
.map(|p| p.try_into().unwrap())
.collect(),
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -664,6 +703,7 @@ mod tests {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -748,6 +788,7 @@ mod tests {
// `.codex` checks are done properly for cwd.
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+1 -1
View File
@@ -252,7 +252,7 @@ fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &str) -> Stri
fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str {
match policy {
SandboxPolicy::ReadOnly => "read-only",
SandboxPolicy::ReadOnly { .. } => "read-only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
SandboxPolicy::DangerFullAccess => "danger-full-access",
SandboxPolicy::ExternalSandbox { .. } => "external-sandbox",
+4 -1
View File
@@ -326,7 +326,10 @@ mod tests {
#[test]
fn restricted_sandbox_requires_exec_approval_on_request() {
assert_eq!(
default_exec_approval_requirement(AskForApproval::OnRequest, &SandboxPolicy::ReadOnly),
default_exec_approval_requirement(
AskForApproval::OnRequest,
&SandboxPolicy::new_read_only_policy()
),
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
@@ -19,7 +19,7 @@ Consolidation goals:
4. Deduplicate aggressively and remove generic advice.
Expected outputs for this directory (create/update as needed):
- `MEMORY.md`: merged durable memory registry for this CWD.
- `MEMORY.md`: merged durable memory registry for this shared memory root.
- `skills/<skill-name>/...`: optional skill folders when there is clear reusable procedure value.
Do not rewrite phase-1 artifacts except when adding explicit cross-references:
@@ -1,4 +1,4 @@
Analyze this rollout and produce `raw_memory`, `rollout_summary`, and optional `rollout_slug` as JSON.
Analyze this rollout and produce `raw_memory` and `rollout_summary` as JSON.
rollout_context:
- rollout_path: {{ rollout_path }}
@@ -4,7 +4,6 @@ You are given one rollout and must produce exactly one JSON object.
Return exactly one JSON object with this schema:
- raw_memory: a detailed markdown raw memory for this rollout only.
- rollout_summary: a concise summary suitable for shared memory aggregation.
- rollout_slug: optional stable slug for the rollout (accepted but currently ignored).
Input contract:
- The user message contains:
@@ -578,6 +578,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -634,6 +635,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+18 -16
View File
@@ -628,6 +628,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -841,7 +842,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_requires_approval",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request.txt"),
content: "read-only-approval",
@@ -861,7 +862,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_requires_approval_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request_5_1.txt"),
content: "read-only-approval",
@@ -881,7 +882,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "trusted_command_on_request_read_only_runs_without_prompt",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-read-only",
},
@@ -896,7 +897,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "trusted_command_on_request_read_only_runs_without_prompt_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-read-only",
},
@@ -911,7 +912,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_blocks_network",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-blocked",
response_body: "should-not-see",
@@ -925,7 +926,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_denied_blocks_execution",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_request_denied.txt"),
content: "should-not-write",
@@ -946,7 +947,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_failure_escalates_after_sandbox_error",
approval_policy: OnFailure,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_failure.txt"),
content: "read-only-on-failure",
@@ -967,7 +968,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_failure_escalates_after_sandbox_error_gpt_5_1_no_exit",
approval_policy: OnFailure,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_on_failure_5_1.txt"),
content: "read-only-on-failure",
@@ -987,7 +988,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_network_escalates_when_approved",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-approved",
response_body: "read-only-network-ok",
@@ -1006,7 +1007,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_on_request_network_escalates_when_approved_gpt_5_1_no_exit",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::FetchUrl {
endpoint: "/ro/network-approved",
response_body: "read-only-network-ok",
@@ -1178,7 +1179,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_unless_trusted.txt"),
content: "read-only-unless-trusted",
@@ -1198,7 +1199,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_unless_trusted_requires_approval_gpt_5_1_no_exit",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"),
content: "read-only-unless-trusted",
@@ -1218,7 +1219,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "read_only_never_reports_sandbox_failure",
approval_policy: Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_never.txt"),
content: "read-only-never",
@@ -1242,7 +1243,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "trusted_command_never_runs_without_prompt",
approval_policy: Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunCommand {
command: "echo trusted-never",
},
@@ -1407,7 +1408,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
ScenarioSpec {
name: "unified exec on request escalated requires approval",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::RunUnifiedExecCommand {
command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'",
justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
@@ -1574,6 +1575,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file()
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -1687,7 +1689,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file()
async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::ReadOnly;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.approval_policy = Constrained::allow_any(approval_policy);
+2 -2
View File
@@ -63,7 +63,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() {
// routes ExecApprovalRequest via the parent.
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.sandbox_policy = Constrained::allow_any(SandboxPolicy::ReadOnly);
config.sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
});
let test = builder.build(&server).await.expect("build test codex");
@@ -144,7 +144,7 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() {
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
// Use a restricted sandbox so patch approval is required
config.sandbox_policy = Constrained::allow_any(SandboxPolicy::ReadOnly);
config.sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
config.include_apply_patch_tool = true;
});
let test = builder.build(&server).await.expect("build test codex");
+6 -6
View File
@@ -53,7 +53,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -86,7 +86,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: next_model.to_string(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -141,7 +141,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -174,7 +174,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: next_model.to_string(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -288,7 +288,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: image_model_slug.to_string(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -307,7 +307,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: text_model_slug.to_string(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -457,6 +457,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> {
let writable_root = AbsolutePathBuf::try_from(writable.path())?;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+14 -14
View File
@@ -94,7 +94,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -141,7 +141,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result<
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -195,7 +195,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> {
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -255,7 +255,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -303,7 +303,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -337,7 +337,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -400,7 +400,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -434,7 +434,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -506,7 +506,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -540,7 +540,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: test.config.approval_policy.value(),
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: test.session_configured.model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -651,7 +651,7 @@ async fn ignores_remote_personality_if_remote_models_disabled() -> anyhow::Resul
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: remote_slug.to_string(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -767,7 +767,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: remote_slug.to_string(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -881,7 +881,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: remote_slug.to_string(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -915,7 +915,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: remote_slug.to_string(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
@@ -375,6 +375,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
let writable = TempDir::new().unwrap();
let new_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable.path().try_into().unwrap()],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -618,6 +619,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
let writable = TempDir::new().unwrap();
let new_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+6 -6
View File
@@ -126,7 +126,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
@@ -293,7 +293,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
@@ -490,7 +490,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: text_only_model_slug.to_string(),
effort: None,
summary: ReasoningSummary::Auto,
@@ -602,7 +602,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
@@ -761,7 +761,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
@@ -952,7 +952,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
+5 -3
View File
@@ -77,6 +77,7 @@ async fn if_parent_of_repo_is_writable_then_dot_git_folder_is_writable() {
let test_scenario = create_test_scenario(&tmp);
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![test_scenario.repo_parent.as_path().try_into().unwrap()],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -103,6 +104,7 @@ async fn if_git_repo_is_writable_root_then_dot_git_folder_is_read_only() {
let test_scenario = create_test_scenario(&tmp);
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![test_scenario.repo_root.as_path().try_into().unwrap()],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -145,7 +147,7 @@ async fn danger_full_access_allows_all_writes() {
async fn read_only_forbids_all_writes() {
let tmp = TempDir::new().expect("should be able to create temp dir");
let test_scenario = create_test_scenario(&tmp);
let policy = SandboxPolicy::ReadOnly;
let policy = SandboxPolicy::new_read_only_policy();
test_scenario
.run_test(
@@ -171,7 +173,7 @@ async fn openpty_works_under_seatbelt() {
return;
}
let policy = SandboxPolicy::ReadOnly;
let policy = SandboxPolicy::new_read_only_policy();
let command_cwd = std::env::current_dir().expect("getcwd");
let sandbox_cwd = command_cwd.clone();
@@ -229,7 +231,7 @@ async fn java_home_finds_runtime_under_seatbelt() {
return;
}
let policy = SandboxPolicy::ReadOnly;
let policy = SandboxPolicy::new_read_only_policy();
let command_cwd = std::env::current_dir().expect("getcwd");
let sandbox_cwd = command_cwd.clone();
+1 -1
View File
@@ -230,7 +230,7 @@ async fn sandbox_denied_shell_returns_original_output() -> Result<()> {
fixture
.submit_turn_with_policy(
"run a command that should be denied by the read-only sandbox",
SandboxPolicy::ReadOnly,
SandboxPolicy::new_read_only_policy(),
)
.await?;
+3 -3
View File
@@ -384,7 +384,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
fixture
.submit_turn_with_policy(
"call the rmcp echo tool with a very large message",
SandboxPolicy::ReadOnly,
SandboxPolicy::new_read_only_policy(),
)
.await?;
@@ -485,7 +485,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
@@ -742,7 +742,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
fixture
.submit_turn_with_policy(
"call the rmcp echo tool with a very large message",
SandboxPolicy::ReadOnly,
SandboxPolicy::new_read_only_policy(),
)
.await?;
+2 -2
View File
@@ -2540,7 +2540,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
// Important!
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
@@ -2644,7 +2644,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
+19 -10
View File
@@ -44,9 +44,12 @@ async fn web_search_mode_cached_sets_external_web_access_false() {
.await
.expect("create test Codex conversation");
test.submit_turn_with_policy("hello cached web search", SandboxPolicy::ReadOnly)
.await
.expect("submit turn");
test.submit_turn_with_policy(
"hello cached web search",
SandboxPolicy::new_read_only_policy(),
)
.await
.expect("submit turn");
let body = resp_mock.single_request().body_json();
let tool = find_web_search_tool(&body);
@@ -82,9 +85,12 @@ async fn web_search_mode_takes_precedence_over_legacy_flags() {
.await
.expect("create test Codex conversation");
test.submit_turn_with_policy("hello cached+live flags", SandboxPolicy::ReadOnly)
.await
.expect("submit turn");
test.submit_turn_with_policy(
"hello cached+live flags",
SandboxPolicy::new_read_only_policy(),
)
.await
.expect("submit turn");
let body = resp_mock.single_request().body_json();
let tool = find_web_search_tool(&body);
@@ -121,9 +127,12 @@ async fn web_search_mode_defaults_to_cached_when_features_disabled() {
.await
.expect("create test Codex conversation");
test.submit_turn_with_policy("hello default cached web search", SandboxPolicy::ReadOnly)
.await
.expect("submit turn");
test.submit_turn_with_policy(
"hello default cached web search",
SandboxPolicy::new_read_only_policy(),
)
.await
.expect("submit turn");
let body = resp_mock.single_request().body_json();
let tool = find_web_search_tool(&body);
@@ -169,7 +178,7 @@ async fn web_search_mode_updates_between_turns_with_sandbox_policy() {
.await
.expect("create test Codex conversation");
test.submit_turn_with_policy("hello cached", SandboxPolicy::ReadOnly)
test.submit_turn_with_policy("hello cached", SandboxPolicy::new_read_only_policy())
.await
.expect("submit first turn");
test.submit_turn_with_policy("hello live", SandboxPolicy::DangerFullAccess)
+1 -1
View File
@@ -123,7 +123,7 @@ impl ExecTool {
.await
.clone()
.unwrap_or_else(|| SandboxState {
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
codex_linux_sandbox_exe: None,
sandbox_cwd: PathBuf::from(&params.workdir),
use_linux_sandbox_bwrap: false,
+2 -1
View File
@@ -88,7 +88,7 @@ where
S: Service<RoleClient> + ClientHandler,
{
let sandbox_state = SandboxState {
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
codex_linux_sandbox_exe,
sandbox_cwd: sandbox_cwd.as_ref().to_path_buf(),
use_linux_sandbox_bwrap: false,
@@ -110,6 +110,7 @@ where
// Note that sandbox_cwd will already be included as a writable root
// when the sandbox policy is expanded.
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
// Disable writes to temp dir because this is a test, so
// writable_folder is likely also under /tmp and we want to be
@@ -92,7 +92,7 @@ fn session_configured_produces_thread_started_event() {
model: "codex-mini-latest".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
+5 -3
View File
@@ -69,7 +69,7 @@ async fn spawn_command_under_sandbox(
async fn linux_sandbox_test_env() -> Option<HashMap<String, String>> {
let command_cwd = std::env::current_dir().ok()?;
let sandbox_cwd = command_cwd.clone();
let policy = SandboxPolicy::ReadOnly;
let policy = SandboxPolicy::new_read_only_policy();
if can_apply_linux_sandbox_policy(&policy, &command_cwd, sandbox_cwd.as_path(), HashMap::new())
.await
@@ -134,6 +134,7 @@ async fn python_multiprocessing_lock_works_under_sandbox() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -194,7 +195,7 @@ async fn python_getpwuid_works_under_sandbox() {
return;
}
let policy = SandboxPolicy::ReadOnly;
let policy = SandboxPolicy::new_read_only_policy();
let command_cwd = std::env::current_dir().expect("should be able to get current dir");
let sandbox_cwd = command_cwd.clone();
@@ -247,6 +248,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() {
// is under a writable root.
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -387,7 +389,7 @@ fn unix_sock_body() {
async fn allow_unix_socketpair_recvfrom() {
run_code_under_sandbox(
"allow_unix_socketpair_recvfrom",
&SandboxPolicy::ReadOnly,
&SandboxPolicy::new_read_only_policy(),
|| async { unix_sock_body() },
)
.await
+7
View File
@@ -141,6 +141,13 @@ fn create_bwrap_flags(
/// 4. `--dev-bind /dev/null /dev/null` preserves the common sink even under a
/// read-only root.
fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result<Vec<String>> {
if !sandbox_policy.has_full_disk_read_access() {
return Err(CodexErr::UnsupportedOperation(
"Restricted read-only access is not yet supported by the Linux bubblewrap backend."
.to_string(),
));
}
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
ensure_mount_targets_exist(&writable_roots)?;
+9 -5
View File
@@ -62,6 +62,13 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
}
if apply_landlock_fs && !sandbox_policy.has_full_disk_write_access() {
if !sandbox_policy.has_full_disk_read_access() {
return Err(CodexErr::UnsupportedOperation(
"Restricted read-only access is not supported by the legacy Linux Landlock filesystem backend."
.to_string(),
));
}
let writable_roots = sandbox_policy
.get_writable_roots_with_cwd(cwd)
.into_iter()
@@ -70,9 +77,6 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
}
// TODO(ragona): Add appropriate restrictions if
// `sandbox_policy.has_full_disk_read_access()` is `false`.
Ok(())
}
@@ -222,11 +226,11 @@ mod tests {
#[test]
fn restricted_network_policy_always_installs_seccomp() {
assert!(should_install_network_seccomp(
&SandboxPolicy::ReadOnly,
&SandboxPolicy::new_read_only_policy(),
false
));
assert!(should_install_network_seccomp(
&SandboxPolicy::ReadOnly,
&SandboxPolicy::new_read_only_policy(),
true
));
}
+3 -3
View File
@@ -391,7 +391,7 @@ mod tests {
fn inserts_bwrap_argv0_before_command_separator() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::ReadOnly,
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
@@ -425,7 +425,7 @@ mod tests {
fn inserts_unshare_net_when_network_isolation_requested() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::ReadOnly,
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
@@ -439,7 +439,7 @@ mod tests {
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::ReadOnly,
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
+3 -3
View File
@@ -301,7 +301,7 @@ mod tests {
model: "gpt-4o".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffort::default()),
history_log_id: 1,
@@ -343,7 +343,7 @@ mod tests {
model: "gpt-4o".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffort::default()),
history_log_id: 1,
@@ -409,7 +409,7 @@ mod tests {
model: "gpt-4o".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffort::default()),
history_log_id: 1,
+7 -7
View File
@@ -3,7 +3,7 @@
`codex-network-proxy` is Codex's local network policy enforcement proxy. It runs:
- an HTTP proxy (default `127.0.0.1:3128`)
- an optional SOCKS5 proxy (default `127.0.0.1:8081`, disabled by default)
- a SOCKS5 proxy (default `127.0.0.1:8081`, enabled by default)
- an admin HTTP API (default `127.0.0.1:8080`)
It enforces an allow/deny policy and a "limited" mode intended for read-only network access.
@@ -21,14 +21,14 @@ Example config:
enabled = true
proxy_url = "http://127.0.0.1:3128"
admin_url = "http://127.0.0.1:8080"
# Optional SOCKS5 listener (disabled by default).
enable_socks5 = false
# SOCKS5 listener (enabled by default).
enable_socks5 = true
socks_url = "http://127.0.0.1:8081"
enable_socks5_udp = false
enable_socks5_udp = true
# When `enabled` is false, the proxy no-ops and does not bind listeners.
# When true, respect HTTP(S)_PROXY/ALL_PROXY for upstream requests (HTTP(S) proxies only),
# including CONNECT tunnels in full mode.
allow_upstream_proxy = false
allow_upstream_proxy = true
# By default, non-loopback binds are clamped to loopback for safety.
# If you want to expose these listeners beyond localhost, you must opt in explicitly.
dangerously_allow_non_loopback_proxy = false
@@ -37,13 +37,13 @@ mode = "full" # default when unset; use "limited" for read-only mode
# Hosts must match the allowlist (unless denied).
# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured.
allowed_domains = ["*.openai.com"]
allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"]
denied_domains = ["evil.example"]
# If false, local/private networking is rejected. Explicit allowlisting of local IP literals
# (or `localhost`) is required to permit them.
# Hostnames that resolve to local/private IPs are still blocked even if allowlisted.
allow_local_binding = false
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"]
+46 -8
View File
@@ -15,6 +15,7 @@ pub struct NetworkProxyConfig {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct NetworkProxySettings {
#[serde(default)]
pub enabled: bool,
@@ -22,13 +23,10 @@ pub struct NetworkProxySettings {
pub proxy_url: String,
#[serde(default = "default_admin_url")]
pub admin_url: String,
#[serde(default)]
pub enable_socks5: bool,
#[serde(default = "default_socks_url")]
pub socks_url: String,
#[serde(default)]
pub enable_socks5_udp: bool,
#[serde(default)]
pub allow_upstream_proxy: bool,
#[serde(default)]
pub dangerously_allow_non_loopback_proxy: bool,
@@ -42,7 +40,6 @@ pub struct NetworkProxySettings {
pub denied_domains: Vec<String>,
#[serde(default)]
pub allow_unix_sockets: Vec<String>,
#[serde(default)]
pub allow_local_binding: bool,
}
@@ -52,17 +49,17 @@ impl Default for NetworkProxySettings {
enabled: false,
proxy_url: default_proxy_url(),
admin_url: default_admin_url(),
enable_socks5: false,
enable_socks5: true,
socks_url: default_socks_url(),
enable_socks5_udp: false,
allow_upstream_proxy: false,
enable_socks5_udp: true,
allow_upstream_proxy: true,
dangerously_allow_non_loopback_proxy: false,
dangerously_allow_non_loopback_admin: false,
mode: NetworkMode::default(),
allowed_domains: Vec::new(),
denied_domains: Vec::new(),
allow_unix_sockets: Vec::new(),
allow_local_binding: false,
allow_local_binding: true,
}
}
}
@@ -329,6 +326,47 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn network_proxy_settings_default_matches_local_use_baseline() {
assert_eq!(
NetworkProxySettings::default(),
NetworkProxySettings {
enabled: false,
proxy_url: "http://127.0.0.1:3128".to_string(),
admin_url: "http://127.0.0.1:8080".to_string(),
enable_socks5: true,
socks_url: "http://127.0.0.1:8081".to_string(),
enable_socks5_udp: true,
allow_upstream_proxy: true,
dangerously_allow_non_loopback_proxy: false,
dangerously_allow_non_loopback_admin: false,
mode: NetworkMode::Full,
allowed_domains: Vec::new(),
denied_domains: Vec::new(),
allow_unix_sockets: Vec::new(),
allow_local_binding: true,
}
);
}
#[test]
fn partial_network_config_uses_struct_defaults_for_missing_fields() {
let config: NetworkProxyConfig = serde_json::from_str(
r#"{
"network": {
"enabled": true
}
}"#,
)
.unwrap();
let expected = NetworkProxySettings {
enabled: true,
..NetworkProxySettings::default()
};
assert_eq!(config.network, expected);
}
#[test]
fn parse_host_port_defaults_for_empty_string() {
assert!(parse_host_port("", 1234).is_err());
+61 -13
View File
@@ -29,10 +29,10 @@ struct ReservedListeners {
}
impl ReservedListeners {
fn new(http: StdTcpListener, socks: StdTcpListener, admin: StdTcpListener) -> Self {
fn new(http: StdTcpListener, socks: Option<StdTcpListener>, admin: StdTcpListener) -> Self {
Self {
http: Mutex::new(Some(http)),
socks: Mutex::new(Some(socks)),
socks: Mutex::new(socks),
admin: Mutex::new(Some(admin)),
}
}
@@ -133,15 +133,20 @@ impl NetworkProxyBuilder {
let current_cfg = state.current_cfg().await?;
let (requested_http_addr, requested_socks_addr, requested_admin_addr, reserved_listeners) =
if self.managed_by_codex {
let runtime = config::resolve_runtime(&current_cfg)?;
let (http_listener, socks_listener, admin_listener) =
reserve_loopback_ephemeral_listeners()
reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5)
.context("reserve managed loopback proxy listeners")?;
let http_addr = http_listener
.local_addr()
.context("failed to read reserved HTTP proxy address")?;
let socks_addr = socks_listener
.local_addr()
.context("failed to read reserved SOCKS5 proxy address")?;
let socks_addr = if let Some(socks_listener) = socks_listener.as_ref() {
socks_listener
.local_addr()
.context("failed to read reserved SOCKS5 proxy address")?
} else {
runtime.socks_addr
};
let admin_addr = admin_listener
.local_addr()
.context("failed to read reserved admin API address")?;
@@ -186,13 +191,19 @@ impl NetworkProxyBuilder {
}
}
fn reserve_loopback_ephemeral_listeners() -> Result<(StdTcpListener, StdTcpListener, StdTcpListener)>
{
Ok((
reserve_loopback_ephemeral_listener().context("reserve HTTP proxy listener")?,
reserve_loopback_ephemeral_listener().context("reserve SOCKS5 proxy listener")?,
reserve_loopback_ephemeral_listener().context("reserve admin API listener")?,
))
fn reserve_loopback_ephemeral_listeners(
reserve_socks_listener: bool,
) -> Result<(StdTcpListener, Option<StdTcpListener>, StdTcpListener)> {
let http_listener =
reserve_loopback_ephemeral_listener().context("reserve HTTP proxy listener")?;
let socks_listener = if reserve_socks_listener {
Some(reserve_loopback_ephemeral_listener().context("reserve SOCKS5 proxy listener")?)
} else {
None
};
let admin_listener =
reserve_loopback_ephemeral_listener().context("reserve admin API listener")?;
Ok((http_listener, socks_listener, admin_listener))
}
fn reserve_loopback_ephemeral_listener() -> Result<StdTcpListener> {
@@ -612,6 +623,43 @@ mod tests {
);
}
#[tokio::test]
async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
let settings = NetworkProxySettings {
enable_socks5: false,
socks_url: "http://127.0.0.1:43129".to_string(),
..NetworkProxySettings::default()
};
let state = Arc::new(network_proxy_state_for_policy(settings));
let proxy = match NetworkProxy::builder().state(state).build().await {
Ok(proxy) => proxy,
Err(err) => {
if err
.chain()
.any(|cause| cause.to_string().contains("Operation not permitted"))
{
return;
}
panic!("failed to build managed proxy: {err:#}");
}
};
assert!(proxy.http_addr.ip().is_loopback());
assert!(proxy.admin_addr.ip().is_loopback());
assert_eq!(
proxy.socks_addr,
"127.0.0.1:43129".parse::<SocketAddr>().unwrap()
);
assert!(
proxy
.reserved_listeners
.as_ref()
.expect("managed builder should reserve listeners")
.take_socks()
.is_none()
);
}
#[test]
fn proxy_url_env_value_resolves_lowercase_aliases() {
let mut env = HashMap::new();
+2 -1
View File
@@ -312,7 +312,7 @@ impl DeveloperInstructions {
let (sandbox_mode, writable_roots) = match sandbox_policy {
SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None),
SandboxPolicy::ReadOnly => (SandboxMode::ReadOnly, None),
SandboxPolicy::ReadOnly { .. } => (SandboxMode::ReadOnly, None),
SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None),
SandboxPolicy::WorkspaceWrite { .. } => {
let roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
@@ -1223,6 +1223,7 @@ mod tests {
fn builds_permissions_from_policy() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+159 -9
View File
@@ -4,6 +4,7 @@
//! between user and agent.
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fmt;
use std::path::Path;
@@ -382,6 +383,107 @@ impl NetworkAccess {
}
}
fn default_include_platform_defaults() -> bool {
true
}
/// Determines how read-only file access is granted inside a restricted
/// sandbox.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS)]
#[strum(serialize_all = "kebab-case")]
#[serde(tag = "type", rename_all = "kebab-case")]
#[ts(tag = "type")]
pub enum ReadOnlyAccess {
/// Restrict reads to an explicit set of roots.
///
/// When `include_platform_defaults` is `true`, platform defaults required
/// for basic execution are included in addition to `readable_roots`.
Restricted {
/// Include built-in platform read roots required for basic process
/// execution.
#[serde(default = "default_include_platform_defaults")]
include_platform_defaults: bool,
/// Additional absolute roots that should be readable.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
readable_roots: Vec<AbsolutePathBuf>,
},
/// Allow unrestricted file reads.
#[default]
FullAccess,
}
impl ReadOnlyAccess {
pub fn has_full_disk_read_access(&self) -> bool {
matches!(self, ReadOnlyAccess::FullAccess)
}
/// Returns the readable roots for restricted read access.
///
/// For [`ReadOnlyAccess::FullAccess`], returns an empty list because
/// callers should grant blanket read access instead.
pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
let mut roots: Vec<AbsolutePathBuf> = match self {
ReadOnlyAccess::FullAccess => return Vec::new(),
ReadOnlyAccess::Restricted {
include_platform_defaults,
readable_roots,
} => {
let mut roots = readable_roots.clone();
if *include_platform_defaults {
#[cfg(target_os = "macos")]
for platform_path in [
"/bin", "/dev", "/etc", "/Library", "/private", "/sbin", "/System", "/tmp",
"/usr",
] {
#[allow(clippy::expect_used)]
roots.push(
AbsolutePathBuf::from_absolute_path(platform_path)
.expect("platform defaults should be absolute"),
);
}
#[cfg(target_os = "linux")]
for platform_path in ["/bin", "/dev", "/etc", "/lib", "/lib64", "/tmp", "/usr"]
{
#[allow(clippy::expect_used)]
roots.push(
AbsolutePathBuf::from_absolute_path(platform_path)
.expect("platform defaults should be absolute"),
);
}
#[cfg(target_os = "windows")]
for platform_path in [
r"C:\Windows",
r"C:\Program Files",
r"C:\Program Files (x86)",
r"C:\ProgramData",
] {
#[allow(clippy::expect_used)]
roots.push(
AbsolutePathBuf::from_absolute_path(platform_path)
.expect("platform defaults should be absolute"),
);
}
match AbsolutePathBuf::from_absolute_path(cwd) {
Ok(cwd_root) => roots.push(cwd_root),
Err(err) => {
error!("Ignoring invalid cwd {cwd:?} for sandbox readable root: {err}");
}
}
}
roots
}
};
let mut seen = HashSet::new();
roots.retain(|root| seen.insert(root.to_path_buf()));
roots
}
}
/// Determines execution restrictions for model shell commands.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)]
#[strum(serialize_all = "kebab-case")]
@@ -391,9 +493,16 @@ pub enum SandboxPolicy {
#[serde(rename = "danger-full-access")]
DangerFullAccess,
/// Read-only access to the entire file-system.
/// Read-only access configuration.
#[serde(rename = "read-only")]
ReadOnly,
ReadOnly {
/// Read access granted while running under this policy.
#[serde(
default,
skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access"
)]
access: ReadOnlyAccess,
},
/// Indicates the process is already in an external sandbox. Allows full
/// disk access while honoring the provided network setting.
@@ -413,6 +522,13 @@ pub enum SandboxPolicy {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
writable_roots: Vec<AbsolutePathBuf>,
/// Read access granted while running under this policy.
#[serde(
default,
skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access"
)]
read_only_access: ReadOnlyAccess,
/// When set to `true`, outbound network access is allowed. `false` by
/// default.
#[serde(default)]
@@ -473,7 +589,9 @@ impl FromStr for SandboxPolicy {
impl SandboxPolicy {
/// Returns a policy with read-only disk access and no network.
pub fn new_read_only_policy() -> Self {
SandboxPolicy::ReadOnly
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
}
}
/// Returns a policy that can read the entire disk, but can only write to
@@ -482,22 +600,29 @@ impl SandboxPolicy {
pub fn new_workspace_write_policy() -> Self {
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
}
/// Always returns `true`; restricting read access is not supported.
pub fn has_full_disk_read_access(&self) -> bool {
true
match self {
SandboxPolicy::DangerFullAccess => true,
SandboxPolicy::ExternalSandbox { .. } => true,
SandboxPolicy::ReadOnly { access } => access.has_full_disk_read_access(),
SandboxPolicy::WorkspaceWrite {
read_only_access, ..
} => read_only_access.has_full_disk_read_access(),
}
}
pub fn has_full_disk_write_access(&self) -> bool {
match self {
SandboxPolicy::DangerFullAccess => true,
SandboxPolicy::ExternalSandbox { .. } => true,
SandboxPolicy::ReadOnly => false,
SandboxPolicy::ReadOnly { .. } => false,
SandboxPolicy::WorkspaceWrite { .. } => false,
}
}
@@ -506,11 +631,35 @@ impl SandboxPolicy {
match self {
SandboxPolicy::DangerFullAccess => true,
SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(),
SandboxPolicy::ReadOnly => false,
SandboxPolicy::ReadOnly { .. } => false,
SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
}
}
/// Returns the list of readable roots (tailored to the current working
/// directory) when read access is restricted.
///
/// For policies with full read access, this returns an empty list because
/// callers should grant blanket reads.
pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
let mut roots = match self {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
SandboxPolicy::ReadOnly { access } => access.get_readable_roots_with_cwd(cwd),
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
..
} => {
let mut roots = read_only_access.get_readable_roots_with_cwd(cwd);
roots.extend(writable_roots.clone());
roots
}
};
let mut seen = HashSet::new();
roots.retain(|root| seen.insert(root.to_path_buf()));
roots
}
/// Returns the list of writable roots (tailored to the current working
/// directory) together with subpaths that should remain read‑only under
/// each writable root.
@@ -518,9 +667,10 @@ impl SandboxPolicy {
match self {
SandboxPolicy::DangerFullAccess => Vec::new(),
SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
SandboxPolicy::ReadOnly => Vec::new(),
SandboxPolicy::ReadOnly { .. } => Vec::new(),
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access: _,
exclude_tmpdir_env_var,
exclude_slash_tmp,
network_access: _,
@@ -2705,7 +2855,7 @@ mod tests {
model: "codex-mini-latest".to_string(),
model_provider_id: "openai".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
@@ -1,22 +1,16 @@
DROP TABLE IF EXISTS thread_memory;
DROP TABLE IF EXISTS memory_phase1_jobs;
DROP TABLE IF EXISTS memory_scope_dirty;
DROP TABLE IF EXISTS memory_phase2_jobs;
DROP TABLE IF EXISTS memory_consolidation_locks;
CREATE TABLE IF NOT EXISTS stage1_outputs (
CREATE TABLE stage1_outputs (
thread_id TEXT PRIMARY KEY,
source_updated_at INTEGER NOT NULL,
raw_memory TEXT NOT NULL,
summary TEXT NOT NULL,
rollout_summary TEXT NOT NULL,
generated_at INTEGER NOT NULL,
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_stage1_outputs_source_updated_at
CREATE INDEX idx_stage1_outputs_source_updated_at
ON stage1_outputs(source_updated_at DESC, thread_id DESC);
CREATE TABLE IF NOT EXISTS jobs (
CREATE TABLE jobs (
kind TEXT NOT NULL,
job_key TEXT NOT NULL,
status TEXT NOT NULL,
@@ -33,5 +27,5 @@ CREATE TABLE IF NOT EXISTS jobs (
PRIMARY KEY (kind, job_key)
);
CREATE INDEX IF NOT EXISTS idx_jobs_kind_status_retry_lease
CREATE INDEX idx_jobs_kind_status_retry_lease
ON jobs(kind, status, retry_at, lease_until);
@@ -1,9 +0,0 @@
CREATE TABLE thread_memory (
thread_id TEXT PRIMARY KEY,
trace_summary TEXT NOT NULL,
memory_summary TEXT NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE INDEX idx_thread_memory_updated_at ON thread_memory(updated_at DESC, thread_id DESC);
@@ -1,8 +0,0 @@
CREATE TABLE memory_consolidation_locks (
cwd TEXT PRIMARY KEY,
working_thread_id TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_memory_consolidation_locks_updated_at
ON memory_consolidation_locks(updated_at DESC);
@@ -1,85 +0,0 @@
DROP TABLE IF EXISTS thread_memory;
DROP TABLE IF EXISTS memory_consolidation_locks;
DROP TABLE IF EXISTS memory_phase1_jobs;
DROP TABLE IF EXISTS memory_scope_dirty;
DROP TABLE IF EXISTS memory_phase2_jobs;
CREATE TABLE thread_memory (
thread_id TEXT NOT NULL,
scope_kind TEXT NOT NULL,
scope_key TEXT NOT NULL,
raw_memory TEXT NOT NULL,
memory_summary TEXT NOT NULL,
updated_at INTEGER NOT NULL,
last_used_at INTEGER,
used_count INTEGER NOT NULL DEFAULT 0,
invalidated_at INTEGER,
invalid_reason TEXT,
PRIMARY KEY (thread_id, scope_kind, scope_key),
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE INDEX idx_thread_memory_scope_last_used_at
ON thread_memory(scope_kind, scope_key, last_used_at DESC, thread_id DESC);
CREATE INDEX idx_thread_memory_scope_updated_at
ON thread_memory(scope_kind, scope_key, updated_at DESC, thread_id DESC);
CREATE TABLE memory_phase1_jobs (
thread_id TEXT NOT NULL,
scope_kind TEXT NOT NULL,
scope_key TEXT NOT NULL,
status TEXT NOT NULL,
owner_session_id TEXT,
started_at INTEGER,
finished_at INTEGER,
failure_reason TEXT,
source_updated_at INTEGER NOT NULL,
raw_memory_path TEXT,
summary_hash TEXT,
ownership_token TEXT,
PRIMARY KEY (thread_id, scope_kind, scope_key),
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE INDEX idx_memory_phase1_jobs_status_started_at
ON memory_phase1_jobs(status, started_at DESC);
CREATE INDEX idx_memory_phase1_jobs_scope
ON memory_phase1_jobs(scope_kind, scope_key);
CREATE TABLE memory_scope_dirty (
scope_kind TEXT NOT NULL,
scope_key TEXT NOT NULL,
dirty INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope_kind, scope_key)
);
CREATE INDEX idx_memory_scope_dirty_dirty
ON memory_scope_dirty(dirty, updated_at DESC);
CREATE TABLE memory_phase2_jobs (
scope_kind TEXT NOT NULL,
scope_key TEXT NOT NULL,
status TEXT NOT NULL,
owner_session_id TEXT,
agent_thread_id TEXT,
started_at INTEGER,
last_heartbeat_at INTEGER,
finished_at INTEGER,
attempt INTEGER NOT NULL DEFAULT 0,
failure_reason TEXT,
ownership_token TEXT,
PRIMARY KEY (scope_kind, scope_key)
);
CREATE INDEX idx_memory_phase2_jobs_status_heartbeat
ON memory_phase2_jobs(status, last_heartbeat_at DESC);
CREATE TABLE memory_consolidation_locks (
cwd TEXT PRIMARY KEY,
working_thread_id TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_memory_consolidation_locks_updated_at
ON memory_consolidation_locks(updated_at DESC);
+4 -5
View File
@@ -14,6 +14,7 @@ mod runtime;
pub use model::LogEntry;
pub use model::LogQuery;
pub use model::LogRow;
pub use model::Phase2JobClaimOutcome;
/// Preferred entrypoint: owns configuration and metrics.
pub use runtime::StateRuntime;
@@ -27,17 +28,15 @@ pub use model::BackfillStats;
pub use model::BackfillStatus;
pub use model::ExtractionOutcome;
pub use model::SortKey;
pub use model::Stage1JobClaim;
pub use model::Stage1JobClaimOutcome;
pub use model::Stage1Output;
pub use model::Stage1StartupClaimParams;
pub use model::ThreadMetadata;
pub use model::ThreadMetadataBuilder;
pub use model::ThreadsPage;
pub use runtime::PendingScopeConsolidation;
pub use runtime::Phase2JobClaimOutcome;
pub use runtime::STATE_DB_FILENAME;
pub use runtime::STATE_DB_VERSION;
pub use runtime::Stage1JobClaim;
pub use runtime::Stage1JobClaimOutcome;
pub use runtime::Stage1StartupClaimParams;
pub use runtime::state_db_filename;
pub use runtime::state_db_path;
+105
View File
@@ -0,0 +1,105 @@
use anyhow::Result;
use chrono::DateTime;
use chrono::Utc;
use codex_protocol::ThreadId;
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
use super::ThreadMetadata;
/// Stored stage-1 memory extraction output for a single thread.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stage1Output {
pub thread_id: ThreadId,
pub source_updated_at: DateTime<Utc>,
pub raw_memory: String,
pub rollout_summary: String,
pub generated_at: DateTime<Utc>,
}
#[derive(Debug)]
pub(crate) struct Stage1OutputRow {
thread_id: String,
source_updated_at: i64,
raw_memory: String,
rollout_summary: String,
generated_at: i64,
}
impl Stage1OutputRow {
pub(crate) fn try_from_row(row: &SqliteRow) -> Result<Self> {
Ok(Self {
thread_id: row.try_get("thread_id")?,
source_updated_at: row.try_get("source_updated_at")?,
raw_memory: row.try_get("raw_memory")?,
rollout_summary: row.try_get("rollout_summary")?,
generated_at: row.try_get("generated_at")?,
})
}
}
impl TryFrom<Stage1OutputRow> for Stage1Output {
type Error = anyhow::Error;
fn try_from(row: Stage1OutputRow) -> std::result::Result<Self, Self::Error> {
Ok(Self {
thread_id: ThreadId::try_from(row.thread_id)?,
source_updated_at: epoch_seconds_to_datetime(row.source_updated_at)?,
raw_memory: row.raw_memory,
rollout_summary: row.rollout_summary,
generated_at: epoch_seconds_to_datetime(row.generated_at)?,
})
}
}
fn epoch_seconds_to_datetime(secs: i64) -> Result<DateTime<Utc>> {
DateTime::<Utc>::from_timestamp(secs, 0)
.ok_or_else(|| anyhow::anyhow!("invalid unix timestamp: {secs}"))
}
/// Result of trying to claim a stage-1 memory extraction job.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Stage1JobClaimOutcome {
/// The caller owns the job and should continue with extraction.
Claimed { ownership_token: String },
/// Existing output is already newer than or equal to the source rollout.
SkippedUpToDate,
/// Another worker currently owns a fresh lease for this job.
SkippedRunning,
/// The job is in backoff and should not be retried yet.
SkippedRetryBackoff,
/// The job has exhausted retries and should not be retried automatically.
SkippedRetryExhausted,
}
/// Claimed stage-1 job with thread metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stage1JobClaim {
pub thread: ThreadMetadata,
pub ownership_token: String,
}
#[derive(Debug, Clone, Copy)]
pub struct Stage1StartupClaimParams<'a> {
pub scan_limit: usize,
pub max_claimed: usize,
pub max_age_days: i64,
pub min_rollout_idle_hours: i64,
pub allowed_sources: &'a [String],
pub lease_seconds: i64,
}
/// Result of trying to claim a phase-2 consolidation job.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase2JobClaimOutcome {
/// The caller owns the global lock and should spawn consolidation.
Claimed {
ownership_token: String,
/// Snapshot of `input_watermark` at claim time.
input_watermark: i64,
},
/// The global job is not pending consolidation (or is already up to date).
SkippedNotDirty,
/// Another worker currently owns a fresh global consolidation lease.
SkippedRunning,
}
+7 -3
View File
@@ -1,6 +1,6 @@
mod backfill_state;
mod log;
mod stage1_output;
mod memories;
mod thread_metadata;
pub use backfill_state::BackfillState;
@@ -8,7 +8,11 @@ pub use backfill_state::BackfillStatus;
pub use log::LogEntry;
pub use log::LogQuery;
pub use log::LogRow;
pub use stage1_output::Stage1Output;
pub use memories::Phase2JobClaimOutcome;
pub use memories::Stage1JobClaim;
pub use memories::Stage1JobClaimOutcome;
pub use memories::Stage1Output;
pub use memories::Stage1StartupClaimParams;
pub use thread_metadata::Anchor;
pub use thread_metadata::BackfillStats;
pub use thread_metadata::ExtractionOutcome;
@@ -17,7 +21,7 @@ pub use thread_metadata::ThreadMetadata;
pub use thread_metadata::ThreadMetadataBuilder;
pub use thread_metadata::ThreadsPage;
pub(crate) use stage1_output::Stage1OutputRow;
pub(crate) use memories::Stage1OutputRow;
pub(crate) use thread_metadata::ThreadRow;
pub(crate) use thread_metadata::anchor_from_item;
pub(crate) use thread_metadata::datetime_to_epoch_seconds;
-56
View File
@@ -1,56 +0,0 @@
use anyhow::Result;
use chrono::DateTime;
use chrono::Utc;
use codex_protocol::ThreadId;
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
/// Stored stage-1 memory extraction output for a single thread.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stage1Output {
pub thread_id: ThreadId,
pub source_updated_at: DateTime<Utc>,
pub raw_memory: String,
pub summary: String,
pub generated_at: DateTime<Utc>,
}
#[derive(Debug)]
pub(crate) struct Stage1OutputRow {
thread_id: String,
source_updated_at: i64,
raw_memory: String,
summary: String,
generated_at: i64,
}
impl Stage1OutputRow {
pub(crate) fn try_from_row(row: &SqliteRow) -> Result<Self> {
Ok(Self {
thread_id: row.try_get("thread_id")?,
source_updated_at: row.try_get("source_updated_at")?,
raw_memory: row.try_get("raw_memory")?,
summary: row.try_get("summary")?,
generated_at: row.try_get("generated_at")?,
})
}
}
impl TryFrom<Stage1OutputRow> for Stage1Output {
type Error = anyhow::Error;
fn try_from(row: Stage1OutputRow) -> std::result::Result<Self, Self::Error> {
Ok(Self {
thread_id: ThreadId::try_from(row.thread_id)?,
source_updated_at: epoch_seconds_to_datetime(row.source_updated_at)?,
raw_memory: row.raw_memory,
summary: row.summary,
generated_at: epoch_seconds_to_datetime(row.generated_at)?,
})
}
}
fn epoch_seconds_to_datetime(secs: i64) -> Result<DateTime<Utc>> {
DateTime::<Utc>::from_timestamp(secs, 0)
.ok_or_else(|| anyhow::anyhow!("invalid unix timestamp: {secs}"))
}
+1 -1
View File
@@ -138,7 +138,7 @@ impl ThreadMetadataBuilder {
model_provider: None,
cwd: PathBuf::new(),
cli_version: None,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
approval_mode: AskForApproval::OnRequest,
archived_at: None,
git_sha: None,
+369 -172
View File
@@ -39,13 +39,10 @@ use uuid::Uuid;
pub const STATE_DB_FILENAME: &str = "state";
pub const STATE_DB_VERSION: u32 = 4;
const MEMORY_SCOPE_KIND_USER: &str = "user";
const MEMORY_SCOPE_KEY_USER: &str = "user";
const METRIC_DB_INIT: &str = "codex.db.init";
mod memory;
// Memory-specific CRUD and phase job lifecycle methods live in `runtime/memory.rs`.
mod memories;
// Memory-specific CRUD and phase job lifecycle methods live in `runtime/memories.rs`.
#[derive(Clone)]
pub struct StateRuntime {
@@ -54,62 +51,6 @@ pub struct StateRuntime {
pool: Arc<sqlx::SqlitePool>,
}
/// Result of trying to claim a stage-1 memory extraction job.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Stage1JobClaimOutcome {
/// The caller owns the job and should continue with extraction.
Claimed { ownership_token: String },
/// Existing output is already newer than or equal to the source rollout.
SkippedUpToDate,
/// Another worker currently owns a fresh lease for this job.
SkippedRunning,
/// The job is in backoff and should not be retried yet.
SkippedRetryBackoff,
/// The job has exhausted retries and should not be retried automatically.
SkippedRetryExhausted,
}
/// Claimed stage-1 job with thread metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stage1JobClaim {
pub thread: ThreadMetadata,
pub ownership_token: String,
}
#[derive(Debug, Clone, Copy)]
pub struct Stage1StartupClaimParams<'a> {
pub scan_limit: usize,
pub max_claimed: usize,
pub max_age_days: i64,
pub min_rollout_idle_hours: i64,
pub allowed_sources: &'a [String],
pub lease_seconds: i64,
}
/// Scope row used to queue phase-2 consolidation work.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingScopeConsolidation {
/// Scope family (`user`).
pub scope_kind: String,
/// Scope identifier keyed by `scope_kind`.
pub scope_key: String,
}
/// Result of trying to claim a phase-2 consolidation job.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase2JobClaimOutcome {
/// The caller owns the scope and should spawn consolidation.
Claimed {
ownership_token: String,
/// Snapshot of `input_watermark` at claim time.
input_watermark: i64,
},
/// The scope is not pending consolidation (or is already up to date).
SkippedNotDirty,
/// Another worker currently owns a fresh lease for this scope.
SkippedRunning,
}
impl StateRuntime {
/// Initialize the state runtime using the provided Codex home and default provider.
///
@@ -167,6 +108,34 @@ WHERE id = 1
crate::BackfillState::try_from_row(&row)
}
/// Attempt to claim ownership of rollout metadata backfill.
///
/// Returns `true` when this runtime claimed the backfill worker slot.
/// Returns `false` if backfill is already complete or currently owned by a
/// non-expired worker.
pub async fn try_claim_backfill(&self, lease_seconds: i64) -> anyhow::Result<bool> {
self.ensure_backfill_state_row().await?;
let now = Utc::now().timestamp();
let lease_cutoff = now.saturating_sub(lease_seconds.max(0));
let result = sqlx::query(
r#"
UPDATE backfill_state
SET status = ?, updated_at = ?
WHERE id = 1
AND status != ?
AND (status != ? OR updated_at <= ?)
"#,
)
.bind(crate::BackfillStatus::Running.as_str())
.bind(now)
.bind(crate::BackfillStatus::Complete.as_str())
.bind(crate::BackfillStatus::Running.as_str())
.bind(lease_cutoff)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() == 1)
}
/// Mark rollout metadata backfill as running.
pub async fn mark_backfill_running(&self) -> anyhow::Result<()> {
self.ensure_backfill_state_row().await?;
@@ -926,15 +895,14 @@ fn push_thread_order_and_limit(
#[cfg(test)]
mod tests {
use super::PendingScopeConsolidation;
use super::Phase2JobClaimOutcome;
use super::STATE_DB_FILENAME;
use super::STATE_DB_VERSION;
use super::Stage1JobClaimOutcome;
use super::Stage1StartupClaimParams;
use super::StateRuntime;
use super::ThreadMetadata;
use super::state_db_filename;
use crate::model::Phase2JobClaimOutcome;
use crate::model::Stage1JobClaimOutcome;
use crate::model::Stage1StartupClaimParams;
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
@@ -945,6 +913,7 @@ mod tests {
use sqlx::Row;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use uuid::Uuid;
@@ -1087,6 +1056,58 @@ mod tests {
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn backfill_claim_is_singleton_until_stale_and_blocked_when_complete() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("initialize runtime");
let claimed = runtime
.try_claim_backfill(3600)
.await
.expect("initial backfill claim");
assert_eq!(claimed, true);
let duplicate_claim = runtime
.try_claim_backfill(3600)
.await
.expect("duplicate backfill claim");
assert_eq!(duplicate_claim, false);
let stale_updated_at = Utc::now().timestamp().saturating_sub(10_000);
sqlx::query(
r#"
UPDATE backfill_state
SET status = ?, updated_at = ?
WHERE id = 1
"#,
)
.bind(crate::BackfillStatus::Running.as_str())
.bind(stale_updated_at)
.execute(runtime.pool.as_ref())
.await
.expect("force stale backfill lease");
let stale_claim = runtime
.try_claim_backfill(10)
.await
.expect("stale backfill claim");
assert_eq!(stale_claim, true);
runtime
.mark_backfill_complete(None)
.await
.expect("mark complete");
let claim_after_complete = runtime
.try_claim_backfill(3600)
.await
.expect("claim after complete");
assert_eq!(claim_after_complete, false);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn stage1_claim_skips_when_up_to_date() {
let codex_home = unique_temp_dir();
@@ -1185,6 +1206,133 @@ mod tests {
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn stage1_concurrent_claim_for_same_thread_is_conflict_safe() {
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");
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_id,
codex_home.join("workspace"),
))
.await
.expect("upsert thread");
let owner_a = 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");
let thread_id_a = thread_id;
let thread_id_b = thread_id;
let runtime_a = Arc::clone(&runtime);
let runtime_b = Arc::clone(&runtime);
let claim_with_retry = |runtime: Arc<StateRuntime>,
thread_id: ThreadId,
owner: ThreadId| async move {
for attempt in 0..5 {
match runtime
.try_claim_stage1_job(thread_id, owner, 100, 3_600, 64)
.await
{
Ok(outcome) => return outcome,
Err(err) if err.to_string().contains("database is locked") && attempt < 4 => {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
Err(err) => panic!("claim stage1 should not fail: {err}"),
}
}
panic!("claim stage1 should have returned within retry budget")
};
let (claim_a, claim_b) = tokio::join!(
claim_with_retry(runtime_a, thread_id_a, owner_a),
claim_with_retry(runtime_b, thread_id_b, owner_b),
);
let claim_outcomes = vec![claim_a, claim_b];
let claimed_count = claim_outcomes
.iter()
.filter(|outcome| matches!(outcome, Stage1JobClaimOutcome::Claimed { .. }))
.count();
assert_eq!(claimed_count, 1);
assert!(
claim_outcomes.iter().all(|outcome| {
matches!(
outcome,
Stage1JobClaimOutcome::Claimed { .. } | Stage1JobClaimOutcome::SkippedRunning
)
}),
"unexpected claim outcomes: {claim_outcomes:?}"
);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn stage1_concurrent_claims_respect_running_cap() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("initialize runtime");
let thread_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
let thread_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_a,
codex_home.join("workspace-a"),
))
.await
.expect("upsert thread a");
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_b,
codex_home.join("workspace-b"),
))
.await
.expect("upsert thread b");
let owner_a = 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");
let runtime_a = Arc::clone(&runtime);
let runtime_b = Arc::clone(&runtime);
let (claim_a, claim_b) = tokio::join!(
async move {
runtime_a
.try_claim_stage1_job(thread_a, owner_a, 100, 3_600, 1)
.await
.expect("claim stage1 thread a")
},
async move {
runtime_b
.try_claim_stage1_job(thread_b, owner_b, 101, 3_600, 1)
.await
.expect("claim stage1 thread b")
},
);
let claim_outcomes = vec![claim_a, claim_b];
let claimed_count = claim_outcomes
.iter()
.filter(|outcome| matches!(outcome, Stage1JobClaimOutcome::Claimed { .. }))
.count();
assert_eq!(claimed_count, 1);
assert!(
claim_outcomes
.iter()
.any(|outcome| { matches!(outcome, Stage1JobClaimOutcome::SkippedRunning) }),
"one concurrent claim should be throttled by running cap: {claim_outcomes:?}"
);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn claim_stage1_jobs_filters_by_age_idle_and_current_thread() {
let codex_home = unique_temp_dir();
@@ -1463,7 +1611,7 @@ WHERE kind = 'memory_stage1'
}
#[tokio::test]
async fn phase2_consolidation_jobs_rerun_when_watermark_advances() {
async fn phase2_global_consolidation_reruns_when_watermark_advances() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
@@ -1472,24 +1620,12 @@ WHERE kind = 'memory_stage1'
let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
runtime
.enqueue_scope_consolidation("user", "user", 100)
.enqueue_global_consolidation(100)
.await
.expect("enqueue scope");
let scopes = runtime
.list_pending_scope_consolidations(10)
.await
.expect("list pending");
assert_eq!(
scopes,
vec![PendingScopeConsolidation {
scope_kind: "user".to_string(),
scope_key: "user".to_string(),
}]
);
.expect("enqueue global consolidation");
let claim = runtime
.try_claim_phase2_job("user", "user", owner, 3600)
.try_claim_global_phase2_job(owner, 3600)
.await
.expect("claim phase2");
let (ownership_token, input_watermark) = match claim {
@@ -1501,30 +1637,25 @@ WHERE kind = 'memory_stage1'
};
assert!(
runtime
.mark_phase2_job_succeeded(
"user",
"user",
ownership_token.as_str(),
input_watermark,
)
.mark_global_phase2_job_succeeded(ownership_token.as_str(), input_watermark)
.await
.expect("mark phase2 succeeded"),
"phase2 success should finalize for current token"
);
let claim_up_to_date = runtime
.try_claim_phase2_job("user", "user", owner, 3600)
.try_claim_global_phase2_job(owner, 3600)
.await
.expect("claim phase2 up-to-date");
assert_eq!(claim_up_to_date, Phase2JobClaimOutcome::SkippedNotDirty);
runtime
.enqueue_scope_consolidation("user", "user", 101)
.enqueue_global_consolidation(101)
.await
.expect("enqueue scope again");
.expect("enqueue global consolidation again");
let claim_rerun = runtime
.try_claim_phase2_job("user", "user", owner, 3600)
.try_claim_global_phase2_job(owner, 3600)
.await
.expect("claim phase2 rerun");
assert!(
@@ -1536,7 +1667,7 @@ WHERE kind = 'memory_stage1'
}
#[tokio::test]
async fn list_stage1_outputs_for_user_scope_returns_latest_outputs() {
async fn list_stage1_outputs_for_global_returns_latest_outputs() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
@@ -1607,20 +1738,20 @@ WHERE kind = 'memory_stage1'
);
let outputs = runtime
.list_stage1_outputs_for_scope("user", "user", 10)
.list_stage1_outputs_for_global(10)
.await
.expect("list stage1 outputs for user scope");
.expect("list stage1 outputs for global");
assert_eq!(outputs.len(), 2);
assert_eq!(outputs[0].thread_id, thread_id_b);
assert_eq!(outputs[0].summary, "summary b");
assert_eq!(outputs[0].rollout_summary, "summary b");
assert_eq!(outputs[1].thread_id, thread_id_a);
assert_eq!(outputs[1].summary, "summary a");
assert_eq!(outputs[1].rollout_summary, "summary a");
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn mark_stage1_job_succeeded_enqueues_single_user_scope() {
async fn mark_stage1_job_succeeded_enqueues_global_consolidation() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
@@ -1679,107 +1810,173 @@ WHERE kind = 'memory_stage1'
"stage1 success should persist output for thread b"
);
let pending_scopes = runtime
.list_pending_scope_consolidations(10)
let claim = runtime
.try_claim_global_phase2_job(owner, 3600)
.await
.expect("list pending scopes");
.expect("claim global consolidation");
let input_watermark = match claim {
Phase2JobClaimOutcome::Claimed {
input_watermark, ..
} => input_watermark,
other => panic!("unexpected global consolidation claim outcome: {other:?}"),
};
assert_eq!(input_watermark, 101);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn phase2_global_lock_allows_only_one_fresh_runner() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("initialize runtime");
runtime
.enqueue_global_consolidation(200)
.await
.expect("enqueue global consolidation");
let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner a");
let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner b");
let running_claim = runtime
.try_claim_global_phase2_job(owner_a, 3600)
.await
.expect("claim global lock");
assert!(
matches!(running_claim, Phase2JobClaimOutcome::Claimed { .. }),
"first owner should claim global lock"
);
let second_claim = runtime
.try_claim_global_phase2_job(owner_b, 3600)
.await
.expect("claim global lock from second owner");
assert_eq!(second_claim, Phase2JobClaimOutcome::SkippedRunning);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn phase2_global_lock_stale_lease_allows_takeover() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("initialize runtime");
runtime
.enqueue_global_consolidation(300)
.await
.expect("enqueue global consolidation");
let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner a");
let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner b");
let initial_claim = runtime
.try_claim_global_phase2_job(owner_a, 3600)
.await
.expect("claim initial global lock");
let token_a = match initial_claim {
Phase2JobClaimOutcome::Claimed {
ownership_token, ..
} => ownership_token,
other => panic!("unexpected initial claim outcome: {other:?}"),
};
sqlx::query("UPDATE jobs SET lease_until = ? WHERE kind = ? AND job_key = ?")
.bind(Utc::now().timestamp() - 1)
.bind("memory_consolidate_global")
.bind("global")
.execute(runtime.pool.as_ref())
.await
.expect("expire global consolidation lease");
let takeover_claim = runtime
.try_claim_global_phase2_job(owner_b, 3600)
.await
.expect("claim stale global lock");
let (token_b, input_watermark) = match takeover_claim {
Phase2JobClaimOutcome::Claimed {
ownership_token,
input_watermark,
} => (ownership_token, input_watermark),
other => panic!("unexpected takeover claim outcome: {other:?}"),
};
assert_ne!(token_a, token_b);
assert_eq!(input_watermark, 300);
assert_eq!(
pending_scopes,
vec![PendingScopeConsolidation {
scope_kind: "user".to_string(),
scope_key: "user".to_string(),
}]
runtime
.mark_global_phase2_job_succeeded(token_a.as_str(), 300)
.await
.expect("mark stale owner success result"),
false,
"stale owner should lose finalization ownership after takeover"
);
assert!(
runtime
.mark_global_phase2_job_succeeded(token_b.as_str(), 300)
.await
.expect("mark takeover owner success"),
"takeover owner should finalize consolidation"
);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn list_pending_scope_consolidations_omits_unclaimable_jobs() {
async fn phase2_failure_fallback_updates_unowned_running_job() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("initialize runtime");
let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
runtime
.enqueue_scope_consolidation("user", "scope-running", 200)
.enqueue_global_consolidation(400)
.await
.expect("enqueue running scope");
runtime
.enqueue_scope_consolidation("user", "scope-backoff", 199)
.await
.expect("enqueue backoff scope");
runtime
.enqueue_scope_consolidation("user", "scope-exhausted", 198)
.await
.expect("enqueue exhausted scope");
runtime
.enqueue_scope_consolidation("user", "scope-claimable-a", 90)
.await
.expect("enqueue claimable scope a");
runtime
.enqueue_scope_consolidation("user", "scope-claimable-b", 89)
.await
.expect("enqueue claimable scope b");
.expect("enqueue global consolidation");
let running_claim = runtime
.try_claim_phase2_job("user", "scope-running", owner, 3600)
let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner");
let claim = runtime
.try_claim_global_phase2_job(owner, 3_600)
.await
.expect("claim running scope");
assert!(
matches!(running_claim, Phase2JobClaimOutcome::Claimed { .. }),
"scope-running should be claimed"
);
let backoff_claim = runtime
.try_claim_phase2_job("user", "scope-backoff", owner, 3600)
.await
.expect("claim backoff scope");
let backoff_token = match backoff_claim {
.expect("claim global consolidation");
let ownership_token = match claim {
Phase2JobClaimOutcome::Claimed {
ownership_token, ..
} => ownership_token,
other => panic!("unexpected backoff claim outcome: {other:?}"),
other => panic!("unexpected claim outcome: {other:?}"),
};
assert!(
runtime
.mark_phase2_job_failed(
"user",
"scope-backoff",
backoff_token.as_str(),
"temporary failure",
3600,
)
.await
.expect("mark backoff scope failed"),
"backoff scope should transition to retry backoff"
);
sqlx::query("UPDATE jobs SET retry_remaining = 0 WHERE kind = ? AND job_key = ?")
.bind("memory_consolidate_user")
.bind("scope-exhausted")
sqlx::query("UPDATE jobs SET ownership_token = NULL WHERE kind = ? AND job_key = ?")
.bind("memory_consolidate_global")
.bind("global")
.execute(runtime.pool.as_ref())
.await
.expect("set exhausted scope retries to zero");
.expect("clear ownership token");
let pending = runtime
.list_pending_scope_consolidations(2)
.await
.expect("list pending scopes");
assert_eq!(
pending,
vec![
PendingScopeConsolidation {
scope_kind: "user".to_string(),
scope_key: "scope-claimable-a".to_string(),
},
PendingScopeConsolidation {
scope_kind: "user".to_string(),
scope_key: "scope-claimable-b".to_string(),
},
]
runtime
.mark_global_phase2_job_failed(ownership_token.as_str(), "lost", 3_600)
.await
.expect("mark phase2 failed with strict ownership"),
false,
"strict failure update should not match unowned running job"
);
assert!(
runtime
.mark_global_phase2_job_failed_if_unowned(ownership_token.as_str(), "lost", 3_600)
.await
.expect("fallback failure update should match unowned running job"),
"fallback failure update should transition the unowned running job"
);
let claim = runtime
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
.await
.expect("claim after fallback failure");
assert_eq!(claim, Phase2JobClaimOutcome::SkippedNotDirty);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
@@ -1800,7 +1997,7 @@ WHERE kind = 'memory_stage1'
cwd,
cli_version: "0.0.0".to_string(),
title: String::new(),
sandbox_policy: crate::extract::enum_to_string(&SandboxPolicy::ReadOnly),
sandbox_policy: crate::extract::enum_to_string(&SandboxPolicy::new_read_only_policy()),
approval_mode: crate::extract::enum_to_string(&AskForApproval::OnRequest),
tokens_used: 0,
first_user_message: Some("hello".to_string()),
@@ -1,6 +1,10 @@
use super::*;
use crate::Stage1Output;
use crate::model::Phase2JobClaimOutcome;
use crate::model::Stage1JobClaim;
use crate::model::Stage1JobClaimOutcome;
use crate::model::Stage1Output;
use crate::model::Stage1OutputRow;
use crate::model::Stage1StartupClaimParams;
use crate::model::ThreadRow;
use chrono::Duration;
use sqlx::Executor;
@@ -8,26 +12,11 @@ use sqlx::QueryBuilder;
use sqlx::Sqlite;
const JOB_KIND_MEMORY_STAGE1: &str = "memory_stage1";
const JOB_KIND_MEMORY_CONSOLIDATE_USER: &str = "memory_consolidate_user";
const JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL: &str = "memory_consolidate_global";
const MEMORY_CONSOLIDATION_JOB_KEY: &str = "global";
const DEFAULT_RETRY_REMAINING: i64 = 3;
fn job_kind_for_scope(scope_kind: &str) -> Option<&'static str> {
if scope_kind == MEMORY_SCOPE_KIND_USER {
Some(JOB_KIND_MEMORY_CONSOLIDATE_USER)
} else {
None
}
}
fn scope_kind_for_job_kind(job_kind: &str) -> Option<&'static str> {
if job_kind == JOB_KIND_MEMORY_CONSOLIDATE_USER {
Some(MEMORY_SCOPE_KIND_USER)
} else {
None
}
}
impl StateRuntime {
pub async fn claim_stage1_jobs_for_startup(
&self,
@@ -132,7 +121,7 @@ FROM threads
) -> anyhow::Result<Option<Stage1Output>> {
let row = sqlx::query(
r#"
SELECT thread_id, source_updated_at, raw_memory, summary, generated_at
SELECT thread_id, source_updated_at, raw_memory, rollout_summary, generated_at
FROM stage1_outputs
WHERE thread_id = ?
"#,
@@ -145,33 +134,25 @@ WHERE thread_id = ?
.transpose()
}
pub async fn list_stage1_outputs_for_scope(
pub async fn list_stage1_outputs_for_global(
&self,
scope_kind: &str,
_scope_key: &str,
n: usize,
) -> anyhow::Result<Vec<Stage1Output>> {
if n == 0 {
return Ok(Vec::new());
}
let rows = match scope_kind {
MEMORY_SCOPE_KIND_USER => {
sqlx::query(
r#"
SELECT so.thread_id, so.source_updated_at, so.raw_memory, so.summary, so.generated_at
let rows = sqlx::query(
r#"
SELECT so.thread_id, so.source_updated_at, so.raw_memory, so.rollout_summary, so.generated_at
FROM stage1_outputs AS so
JOIN threads AS t ON t.id = so.thread_id
ORDER BY so.source_updated_at DESC, so.thread_id DESC
LIMIT ?
"#,
)
.bind(n as i64)
.fetch_all(self.pool.as_ref())
.await?
}
_ => return Ok(Vec::new()),
};
"#,
)
.bind(n as i64)
.fetch_all(self.pool.as_ref())
.await?;
rows.into_iter()
.map(|row| Stage1OutputRow::try_from_row(&row).and_then(Stage1Output::try_from))
@@ -213,67 +194,8 @@ WHERE thread_id = ?
}
}
let existing_job = sqlx::query(
let rows_affected = sqlx::query(
r#"
SELECT status, lease_until, retry_at, retry_remaining
FROM jobs
WHERE kind = ? AND job_key = ?
"#,
)
.bind(JOB_KIND_MEMORY_STAGE1)
.bind(thread_id.as_str())
.fetch_optional(&mut *tx)
.await?;
let should_insert = if let Some(existing_job) = existing_job {
let status: String = existing_job.try_get("status")?;
let existing_lease_until: Option<i64> = existing_job.try_get("lease_until")?;
let retry_at: Option<i64> = existing_job.try_get("retry_at")?;
let retry_remaining: i64 = existing_job.try_get("retry_remaining")?;
if retry_remaining <= 0 {
tx.commit().await?;
return Ok(Stage1JobClaimOutcome::SkippedRetryExhausted);
}
if retry_at.is_some_and(|retry_at| retry_at > now) {
tx.commit().await?;
return Ok(Stage1JobClaimOutcome::SkippedRetryBackoff);
}
if status == "running"
&& existing_lease_until.is_some_and(|lease_until| lease_until > now)
{
tx.commit().await?;
return Ok(Stage1JobClaimOutcome::SkippedRunning);
}
false
} else {
true
};
let fresh_running_jobs = sqlx::query(
r#"
SELECT COUNT(*) AS count
FROM jobs
WHERE kind = ?
AND status = 'running'
AND lease_until IS NOT NULL
AND lease_until > ?
"#,
)
.bind(JOB_KIND_MEMORY_STAGE1)
.bind(now)
.fetch_one(&mut *tx)
.await?
.try_get::<i64, _>("count")?;
if fresh_running_jobs >= max_running_jobs {
tx.commit().await?;
return Ok(Stage1JobClaimOutcome::SkippedRunning);
}
if should_insert {
sqlx::query(
r#"
INSERT INTO jobs (
kind,
job_key,
@@ -288,61 +210,96 @@ INSERT INTO jobs (
last_error,
input_watermark,
last_success_watermark
) VALUES (?, ?, 'running', ?, ?, ?, NULL, ?, NULL, ?, NULL, ?, NULL)
"#,
)
.bind(JOB_KIND_MEMORY_STAGE1)
.bind(thread_id.as_str())
.bind(worker_id.as_str())
.bind(ownership_token.as_str())
.bind(now)
.bind(lease_until)
.bind(DEFAULT_RETRY_REMAINING)
.bind(source_updated_at)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Ok(Stage1JobClaimOutcome::Claimed { ownership_token });
}
let rows_affected = sqlx::query(
r#"
UPDATE jobs
SET
)
SELECT ?, ?, 'running', ?, ?, ?, NULL, ?, NULL, ?, NULL, ?, NULL
WHERE (
SELECT COUNT(*)
FROM jobs
WHERE kind = ?
AND status = 'running'
AND lease_until IS NOT NULL
AND lease_until > ?
) < ?
ON CONFLICT(kind, job_key) DO UPDATE SET
status = 'running',
worker_id = ?,
ownership_token = ?,
started_at = ?,
worker_id = excluded.worker_id,
ownership_token = excluded.ownership_token,
started_at = excluded.started_at,
finished_at = NULL,
lease_until = ?,
lease_until = excluded.lease_until,
retry_at = NULL,
last_error = NULL,
input_watermark = ?
WHERE kind = ? AND job_key = ?
AND (status != 'running' OR lease_until IS NULL OR lease_until <= ?)
AND (retry_at IS NULL OR retry_at <= ?)
AND retry_remaining > 0
input_watermark = excluded.input_watermark
WHERE
(jobs.status != 'running' OR jobs.lease_until IS NULL OR jobs.lease_until <= excluded.started_at)
AND (jobs.retry_at IS NULL OR jobs.retry_at <= excluded.started_at)
AND jobs.retry_remaining > 0
AND (
SELECT COUNT(*)
FROM jobs AS running_jobs
WHERE running_jobs.kind = excluded.kind
AND running_jobs.status = 'running'
AND running_jobs.lease_until IS NOT NULL
AND running_jobs.lease_until > excluded.started_at
AND running_jobs.job_key != excluded.job_key
) < ?
"#,
)
.bind(JOB_KIND_MEMORY_STAGE1)
.bind(thread_id.as_str())
.bind(worker_id.as_str())
.bind(ownership_token.as_str())
.bind(now)
.bind(lease_until)
.bind(DEFAULT_RETRY_REMAINING)
.bind(source_updated_at)
.bind(JOB_KIND_MEMORY_STAGE1)
.bind(thread_id.as_str())
.bind(now)
.bind(now)
.bind(max_running_jobs)
.bind(max_running_jobs)
.execute(&mut *tx)
.await?
.rows_affected();
tx.commit().await?;
if rows_affected == 0 {
Ok(Stage1JobClaimOutcome::SkippedRunning)
} else {
Ok(Stage1JobClaimOutcome::Claimed { ownership_token })
if rows_affected > 0 {
tx.commit().await?;
return Ok(Stage1JobClaimOutcome::Claimed { ownership_token });
}
let existing_job = sqlx::query(
r#"
SELECT status, lease_until, retry_at, retry_remaining
FROM jobs
WHERE kind = ? AND job_key = ?
"#,
)
.bind(JOB_KIND_MEMORY_STAGE1)
.bind(thread_id.as_str())
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
if let Some(existing_job) = existing_job {
let status: String = existing_job.try_get("status")?;
let existing_lease_until: Option<i64> = existing_job.try_get("lease_until")?;
let retry_at: Option<i64> = existing_job.try_get("retry_at")?;
let retry_remaining: i64 = existing_job.try_get("retry_remaining")?;
if retry_remaining <= 0 {
return Ok(Stage1JobClaimOutcome::SkippedRetryExhausted);
}
if retry_at.is_some_and(|retry_at| retry_at > now) {
return Ok(Stage1JobClaimOutcome::SkippedRetryBackoff);
}
if status == "running"
&& existing_lease_until.is_some_and(|lease_until| lease_until > now)
{
return Ok(Stage1JobClaimOutcome::SkippedRunning);
}
}
Ok(Stage1JobClaimOutcome::SkippedRunning)
}
pub async fn mark_stage1_job_succeeded(
@@ -351,7 +308,7 @@ WHERE kind = ? AND job_key = ?
ownership_token: &str,
source_updated_at: i64,
raw_memory: &str,
summary: &str,
rollout_summary: &str,
) -> anyhow::Result<bool> {
let now = Utc::now().timestamp();
let thread_id = thread_id.to_string();
@@ -389,13 +346,13 @@ INSERT INTO stage1_outputs (
thread_id,
source_updated_at,
raw_memory,
summary,
rollout_summary,
generated_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(thread_id) DO UPDATE SET
source_updated_at = excluded.source_updated_at,
raw_memory = excluded.raw_memory,
summary = excluded.summary,
rollout_summary = excluded.rollout_summary,
generated_at = excluded.generated_at
WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at
"#,
@@ -403,18 +360,12 @@ WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at
.bind(thread_id.as_str())
.bind(source_updated_at)
.bind(raw_memory)
.bind(summary)
.bind(rollout_summary)
.bind(now)
.execute(&mut *tx)
.await?;
enqueue_scope_consolidation_with_executor(
&mut *tx,
MEMORY_SCOPE_KIND_USER,
MEMORY_SCOPE_KEY_USER,
source_updated_at,
)
.await?;
enqueue_global_consolidation_with_executor(&mut *tx, source_updated_at).await?;
tx.commit().await?;
Ok(true)
@@ -458,77 +409,16 @@ WHERE kind = ? AND job_key = ?
Ok(rows_affected > 0)
}
pub async fn enqueue_scope_consolidation(
&self,
scope_kind: &str,
scope_key: &str,
input_watermark: i64,
) -> anyhow::Result<()> {
enqueue_scope_consolidation_with_executor(
self.pool.as_ref(),
scope_kind,
scope_key,
input_watermark,
)
.await
pub async fn enqueue_global_consolidation(&self, input_watermark: i64) -> anyhow::Result<()> {
enqueue_global_consolidation_with_executor(self.pool.as_ref(), input_watermark).await
}
pub async fn list_pending_scope_consolidations(
/// Try to claim the global phase-2 consolidation job.
pub async fn try_claim_global_phase2_job(
&self,
limit: usize,
) -> anyhow::Result<Vec<PendingScopeConsolidation>> {
if limit == 0 {
return Ok(Vec::new());
}
let now = Utc::now().timestamp();
let rows = sqlx::query(
r#"
SELECT kind, job_key
FROM jobs
WHERE kind = ?
AND input_watermark IS NOT NULL
AND input_watermark > COALESCE(last_success_watermark, 0)
AND retry_remaining > 0
AND (retry_at IS NULL OR retry_at <= ?)
AND (status != 'running' OR lease_until IS NULL OR lease_until <= ?)
ORDER BY input_watermark DESC, kind ASC, job_key ASC
LIMIT ?
"#,
)
.bind(JOB_KIND_MEMORY_CONSOLIDATE_USER)
.bind(now)
.bind(now)
.bind(limit as i64)
.fetch_all(self.pool.as_ref())
.await?;
Ok(rows
.into_iter()
.filter_map(|row| {
let kind: String = row.try_get("kind").ok()?;
let scope_kind = scope_kind_for_job_kind(&kind)?;
let scope_key: String = row.try_get("job_key").ok()?;
Some(PendingScopeConsolidation {
scope_kind: scope_kind.to_string(),
scope_key,
})
})
.collect::<Vec<_>>())
}
/// Try to claim a phase-2 consolidation job for `(scope_kind, scope_key)`.
pub async fn try_claim_phase2_job(
&self,
scope_kind: &str,
scope_key: &str,
worker_id: ThreadId,
lease_seconds: i64,
) -> anyhow::Result<Phase2JobClaimOutcome> {
let Some(job_kind) = job_kind_for_scope(scope_kind) else {
return Ok(Phase2JobClaimOutcome::SkippedNotDirty);
};
let now = Utc::now().timestamp();
let lease_until = now.saturating_add(lease_seconds.max(0));
let ownership_token = Uuid::new_v4().to_string();
@@ -543,8 +433,8 @@ FROM jobs
WHERE kind = ? AND job_key = ?
"#,
)
.bind(job_kind)
.bind(scope_key)
.bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
.bind(MEMORY_CONSOLIDATION_JOB_KEY)
.fetch_optional(&mut *tx)
.await?;
@@ -593,6 +483,7 @@ SET
retry_at = NULL,
last_error = NULL
WHERE kind = ? AND job_key = ?
AND input_watermark > COALESCE(last_success_watermark, 0)
AND (status != 'running' OR lease_until IS NULL OR lease_until <= ?)
AND (retry_at IS NULL OR retry_at <= ?)
AND retry_remaining > 0
@@ -602,8 +493,8 @@ WHERE kind = ? AND job_key = ?
.bind(ownership_token.as_str())
.bind(now)
.bind(lease_until)
.bind(job_kind)
.bind(scope_key)
.bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
.bind(MEMORY_CONSOLIDATION_JOB_KEY)
.bind(now)
.bind(now)
.execute(&mut *tx)
@@ -621,17 +512,11 @@ WHERE kind = ? AND job_key = ?
}
}
pub async fn heartbeat_phase2_job(
pub async fn heartbeat_global_phase2_job(
&self,
scope_kind: &str,
scope_key: &str,
ownership_token: &str,
lease_seconds: i64,
) -> anyhow::Result<bool> {
let Some(job_kind) = job_kind_for_scope(scope_kind) else {
return Ok(false);
};
let now = Utc::now().timestamp();
let lease_until = now.saturating_add(lease_seconds.max(0));
let rows_affected = sqlx::query(
@@ -643,8 +528,8 @@ WHERE kind = ? AND job_key = ?
"#,
)
.bind(lease_until)
.bind(job_kind)
.bind(scope_key)
.bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
.bind(MEMORY_CONSOLIDATION_JOB_KEY)
.bind(ownership_token)
.execute(self.pool.as_ref())
.await?
@@ -653,17 +538,11 @@ WHERE kind = ? AND job_key = ?
Ok(rows_affected > 0)
}
pub async fn mark_phase2_job_succeeded(
pub async fn mark_global_phase2_job_succeeded(
&self,
scope_kind: &str,
scope_key: &str,
ownership_token: &str,
completed_watermark: i64,
) -> anyhow::Result<bool> {
let Some(job_kind) = job_kind_for_scope(scope_kind) else {
return Ok(false);
};
let now = Utc::now().timestamp();
let rows_affected = sqlx::query(
r#"
@@ -680,8 +559,8 @@ WHERE kind = ? AND job_key = ?
)
.bind(now)
.bind(completed_watermark)
.bind(job_kind)
.bind(scope_key)
.bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
.bind(MEMORY_CONSOLIDATION_JOB_KEY)
.bind(ownership_token)
.execute(self.pool.as_ref())
.await?
@@ -690,18 +569,12 @@ WHERE kind = ? AND job_key = ?
Ok(rows_affected > 0)
}
pub async fn mark_phase2_job_failed(
pub async fn mark_global_phase2_job_failed(
&self,
scope_kind: &str,
scope_key: &str,
ownership_token: &str,
failure_reason: &str,
retry_delay_seconds: i64,
) -> anyhow::Result<bool> {
let Some(job_kind) = job_kind_for_scope(scope_kind) else {
return Ok(false);
};
let now = Utc::now().timestamp();
let retry_at = now.saturating_add(retry_delay_seconds.max(0));
let rows_affected = sqlx::query(
@@ -721,8 +594,44 @@ WHERE kind = ? AND job_key = ?
.bind(now)
.bind(retry_at)
.bind(failure_reason)
.bind(job_kind)
.bind(scope_key)
.bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
.bind(MEMORY_CONSOLIDATION_JOB_KEY)
.bind(ownership_token)
.execute(self.pool.as_ref())
.await?
.rows_affected();
Ok(rows_affected > 0)
}
pub async fn mark_global_phase2_job_failed_if_unowned(
&self,
ownership_token: &str,
failure_reason: &str,
retry_delay_seconds: i64,
) -> anyhow::Result<bool> {
let now = Utc::now().timestamp();
let retry_at = now.saturating_add(retry_delay_seconds.max(0));
let rows_affected = sqlx::query(
r#"
UPDATE jobs
SET
status = 'error',
finished_at = ?,
lease_until = NULL,
retry_at = ?,
retry_remaining = retry_remaining - 1,
last_error = ?
WHERE kind = ? AND job_key = ?
AND status = 'running'
AND (ownership_token = ? OR ownership_token IS NULL)
"#,
)
.bind(now)
.bind(retry_at)
.bind(failure_reason)
.bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
.bind(MEMORY_CONSOLIDATION_JOB_KEY)
.bind(ownership_token)
.execute(self.pool.as_ref())
.await?
@@ -732,19 +641,13 @@ WHERE kind = ? AND job_key = ?
}
}
async fn enqueue_scope_consolidation_with_executor<'e, E>(
async fn enqueue_global_consolidation_with_executor<'e, E>(
executor: E,
scope_kind: &str,
scope_key: &str,
input_watermark: i64,
) -> anyhow::Result<()>
where
E: Executor<'e, Database = Sqlite>,
{
let Some(job_kind) = job_kind_for_scope(scope_kind) else {
return Ok(());
};
sqlx::query(
r#"
INSERT INTO jobs (
@@ -775,8 +678,8 @@ ON CONFLICT(kind, job_key) DO UPDATE SET
input_watermark = max(COALESCE(jobs.input_watermark, 0), excluded.input_watermark)
"#,
)
.bind(job_kind)
.bind(scope_key)
.bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
.bind(MEMORY_CONSOLIDATION_JOB_KEY)
.bind(DEFAULT_RETRY_REMAINING)
.bind(input_watermark)
.execute(executor)
+3 -3
View File
@@ -16,7 +16,7 @@ pub fn add_dir_warning_message(
SandboxPolicy::WorkspaceWrite { .. }
| SandboxPolicy::DangerFullAccess
| SandboxPolicy::ExternalSandbox { .. } => None,
SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)),
SandboxPolicy::ReadOnly { .. } => Some(format_warning(additional_dirs)),
}
}
@@ -64,7 +64,7 @@ mod tests {
#[test]
fn warns_for_read_only() {
let sandbox = SandboxPolicy::ReadOnly;
let sandbox = SandboxPolicy::new_read_only_policy();
let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")];
let message = add_dir_warning_message(&dirs, &sandbox)
.expect("expected warning for read-only sandbox");
@@ -76,7 +76,7 @@ mod tests {
#[test]
fn returns_none_when_no_additional_dirs() {
let sandbox = SandboxPolicy::ReadOnly;
let sandbox = SandboxPolicy::new_read_only_policy();
let dirs: Vec<PathBuf> = Vec::new();
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
}
+10 -8
View File
@@ -1156,7 +1156,7 @@ impl App {
&& matches!(
app.config.sandbox_policy.get(),
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_core::protocol::SandboxPolicy::ReadOnly
| codex_core::protocol::SandboxPolicy::ReadOnly { .. }
)
&& !app
.config
@@ -1982,7 +1982,7 @@ impl App {
let policy_is_workspace_write_or_ro = matches!(
&policy,
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_core::protocol::SandboxPolicy::ReadOnly
| codex_core::protocol::SandboxPolicy::ReadOnly { .. }
);
if let Err(err) = self.config.sandbox_policy.set(policy.clone()) {
@@ -1992,9 +1992,11 @@ impl App {
return Ok(AppRunControl::Continue);
}
#[cfg(target_os = "windows")]
if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly)
|| WindowsSandboxLevel::from_config(&self.config)
!= WindowsSandboxLevel::Disabled
if !matches!(
&policy,
codex_core::protocol::SandboxPolicy::ReadOnly { .. }
) || WindowsSandboxLevel::from_config(&self.config)
!= WindowsSandboxLevel::Disabled
{
self.config.forced_auto_mode_downgraded_on_windows = false;
}
@@ -3047,7 +3049,7 @@ mod tests {
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
@@ -3102,7 +3104,7 @@ mod tests {
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
@@ -3149,7 +3151,7 @@ mod tests {
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
+12 -11
View File
@@ -5408,15 +5408,16 @@ impl ChatWidget {
}
matches!(
(&preset.sandbox, current_sandbox),
(SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly)
| (
SandboxPolicy::DangerFullAccess,
SandboxPolicy::DangerFullAccess
)
| (
SandboxPolicy::WorkspaceWrite { .. },
SandboxPolicy::WorkspaceWrite { .. }
)
(
SandboxPolicy::ReadOnly { .. },
SandboxPolicy::ReadOnly { .. }
) | (
SandboxPolicy::DangerFullAccess,
SandboxPolicy::DangerFullAccess
) | (
SandboxPolicy::WorkspaceWrite { .. },
SandboxPolicy::WorkspaceWrite { .. }
)
)
}
@@ -5537,7 +5538,7 @@ impl ChatWidget {
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let describe_policy = |policy: &SandboxPolicy| match policy {
SandboxPolicy::WorkspaceWrite { .. } => "Agent mode",
SandboxPolicy::ReadOnly => "Read-Only mode",
SandboxPolicy::ReadOnly { .. } => "Read-Only mode",
_ => "Agent mode",
};
let mode_label = preset
@@ -5949,7 +5950,7 @@ impl ChatWidget {
/// Set the sandbox policy in the widget's config copy.
pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> {
#[cfg(target_os = "windows")]
let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly)
let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly { .. })
|| WindowsSandboxLevel::from_config(&self.config) != WindowsSandboxLevel::Disabled;
self.config.sandbox_policy.set(policy)?;
+6 -5
View File
@@ -148,7 +148,7 @@ async fn resumed_initial_messages_render_history() {
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
@@ -216,7 +216,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
@@ -334,7 +334,7 @@ async fn submission_preserves_text_elements_and_local_images() {
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
@@ -414,7 +414,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
@@ -2972,7 +2972,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
@@ -4032,6 +4032,7 @@ async fn preset_matching_ignores_extra_writable_roots() {
.expect("auto preset exists");
let current_sandbox = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+35 -2
View File
@@ -27,14 +27,30 @@ pub(crate) fn new_debug_config_output(
socks_addr,
admin_addr,
} = proxy;
let all_proxy = session_all_proxy_url(
http_addr,
socks_addr,
config
.network
.as_ref()
.is_some_and(codex_core::config::NetworkProxySpec::socks_enabled),
);
lines.push(format!(" - HTTP_PROXY = http://{http_addr}").into());
lines.push(format!(" - ALL_PROXY = socks5h://{socks_addr}").into());
lines.push(format!(" - ALL_PROXY = {all_proxy}").into());
lines.push(format!(" - ADMIN_PROXY = http://{admin_addr}").into());
}
PlainHistoryCell::new(lines)
}
fn session_all_proxy_url(http_addr: &str, socks_addr: &str, socks_enabled: bool) -> String {
if socks_enabled {
format!("socks5h://{socks_addr}")
} else {
format!("http://{http_addr}")
}
}
fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
let mut lines = vec!["/debug-config".magenta().into(), "".into()];
@@ -288,6 +304,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String {
#[cfg(test)]
mod tests {
use super::render_debug_config_lines;
use super::session_all_proxy_url;
use codex_app_server_protocol::ConfigLayerSource;
use codex_core::config::Constrained;
use codex_core::config_loader::ConfigLayerEntry;
@@ -386,7 +403,7 @@ mod tests {
Some(RequirementSource::CloudRequirements),
);
requirements.sandbox_policy = ConstrainedWithSource::new(
Constrained::allow_any(SandboxPolicy::ReadOnly),
Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
Some(RequirementSource::SystemRequirementsToml {
file: requirements_file.clone(),
}),
@@ -503,4 +520,20 @@ mod tests {
rendered.contains("allowed_web_search_modes: disabled (source: cloud requirements)")
);
}
#[test]
fn session_all_proxy_url_uses_socks_when_enabled() {
assert_eq!(
session_all_proxy_url("127.0.0.1:3128", "127.0.0.1:8081", true),
"socks5h://127.0.0.1:8081".to_string()
);
}
#[test]
fn session_all_proxy_url_uses_http_when_socks_disabled() {
assert_eq!(
session_all_proxy_url("127.0.0.1:3128", "127.0.0.1:8081", false),
"http://127.0.0.1:3128".to_string()
);
}
}
+1 -1
View File
@@ -157,7 +157,7 @@ impl StatusHistoryCell {
.unwrap_or_else(|| "<unknown>".to_string());
let sandbox = match config.sandbox_policy.get() {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::ReadOnly { .. } => "read-only".to_string(),
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
SandboxPolicy::ExternalSandbox { network_access } => {
if matches!(network_access, NetworkAccess::Enabled) {
+1
View File
@@ -100,6 +100,7 @@ async fn status_snapshot_includes_reasoning_details() {
.sandbox_policy
.set(SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
+1 -1
View File
@@ -262,7 +262,7 @@ pub fn apply_capability_denies_for_world_writable(
}
(sid, roots)
}
SandboxPolicy::ReadOnly => (
SandboxPolicy::ReadOnly { .. } => (
unsafe { convert_string_sid_to_sid(&caps.readonly) }.ok_or_else(|| {
anyhow!("ConvertStringSidToSidW failed for readonly capability")
})?,
@@ -114,6 +114,11 @@ pub fn main() -> Result<()> {
);
let policy = parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?;
if !policy.has_full_disk_read_access() {
anyhow::bail!(
"Restricted read-only access is not yet supported by the Windows sandbox backend"
);
}
let mut cap_psids: Vec<*mut c_void> = Vec::new();
for sid in &req.cap_sids {
let Some(psid) = (unsafe { convert_string_sid_to_sid(sid) }) else {
@@ -129,7 +134,9 @@ pub fn main() -> Result<()> {
let base = unsafe { get_current_token_for_restriction()? };
let token_res: Result<HANDLE> = unsafe {
match &policy {
SandboxPolicy::ReadOnly => create_readonly_token_with_caps_from(base, &cap_psids),
SandboxPolicy::ReadOnly { .. } => {
create_readonly_token_with_caps_from(base, &cap_psids)
}
SandboxPolicy::WorkspaceWrite { .. } => {
create_workspace_write_token_with_caps_from(base, &cap_psids)
}
@@ -238,9 +238,14 @@ mod windows_impl {
) {
anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing")
}
if !policy.has_full_disk_read_access() {
anyhow::bail!(
"Restricted read-only access is not yet supported by the Windows sandbox backend"
);
}
let caps = load_or_create_cap_sids(codex_home)?;
let (psid_to_use, cap_sids) = match &policy {
SandboxPolicy::ReadOnly => (
SandboxPolicy::ReadOnly { .. } => (
unsafe { convert_string_sid_to_sid(&caps.readonly).unwrap() },
vec![caps.readonly.clone()],
),
@@ -487,7 +492,7 @@ mod windows_impl {
#[test]
fn applies_network_block_for_read_only() {
assert!(!SandboxPolicy::ReadOnly.has_full_network_access());
assert!(!SandboxPolicy::new_read_only_policy().has_full_network_access());
}
}
}
+9 -2
View File
@@ -258,10 +258,15 @@ mod windows_impl {
) {
anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing")
}
if !policy.has_full_disk_read_access() {
anyhow::bail!(
"Restricted read-only access is not yet supported by the Windows sandbox backend"
);
}
let caps = load_or_create_cap_sids(codex_home)?;
let (h_token, psid_generic, psid_workspace): (HANDLE, *mut c_void, Option<*mut c_void>) = unsafe {
match &policy {
SandboxPolicy::ReadOnly => {
SandboxPolicy::ReadOnly { .. } => {
let psid = convert_string_sid_to_sid(&caps.readonly).unwrap();
let (h, _) = super::token::create_readonly_token_with_cap(psid)?;
(h, psid, None)
@@ -528,7 +533,9 @@ mod windows_impl {
#[test]
fn applies_network_block_for_read_only() {
assert!(should_apply_network_block(&SandboxPolicy::ReadOnly));
assert!(should_apply_network_block(
&SandboxPolicy::new_read_only_policy()
));
}
}
}
+5 -2
View File
@@ -3,7 +3,7 @@ pub use codex_protocol::protocol::SandboxPolicy;
pub fn parse_policy(value: &str) -> Result<SandboxPolicy> {
match value {
"read-only" => Ok(SandboxPolicy::ReadOnly),
"read-only" => Ok(SandboxPolicy::new_read_only_policy()),
"workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()),
"danger-full-access" | "external-sandbox" => anyhow::bail!(
"DangerFullAccess and ExternalSandbox are not supported for sandboxing"
@@ -52,6 +52,9 @@ mod tests {
#[test]
fn parses_read_only_policy() {
assert_eq!(parse_policy("read-only").unwrap(), SandboxPolicy::ReadOnly);
assert_eq!(
parse_policy("read-only").unwrap(),
SandboxPolicy::new_read_only_policy()
);
}
}