mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Merge remote-tracking branch 'origin/main' into dev/alexz/connector-guardian-managed-app-requirements
# Conflicts: # codex-rs/cloud-config/src/lib.rs # codex-rs/core/src/connectors_tests.rs
This commit is contained in:
@@ -34,8 +34,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The pinned runtime currently publishes a musllinux Linux wheel.
|
||||
# Build in Alpine so release type generation installs that wheel.
|
||||
# Build in a glibc Linux image so release type generation installs
|
||||
# the pinned manylinux runtime wheel.
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e HOME=/tmp/codex-python-sdk-home \
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
-v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \
|
||||
-v "${RUNNER_TEMP}:${RUNNER_TEMP}" \
|
||||
-w "${GITHUB_WORKSPACE}/sdk/python" \
|
||||
python:3.12-alpine \
|
||||
python:3.12-slim \
|
||||
sh -euxc '
|
||||
python -m venv /tmp/release-tools
|
||||
/tmp/release-tools/bin/python -m pip install build twine uv==0.11.3
|
||||
|
||||
@@ -6,19 +6,6 @@ on:
|
||||
release-lto:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AZURE_TRUSTED_SIGNING_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_TENANT_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME:
|
||||
required: true
|
||||
|
||||
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
|
||||
# nested submodules. Prefer the system git CLI across every Cargo invocation.
|
||||
@@ -134,11 +121,22 @@ jobs:
|
||||
- name: Stage Windows binaries
|
||||
shell: bash
|
||||
run: |
|
||||
output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}"
|
||||
release_dir="target/${{ matrix.target }}/release"
|
||||
output_dir="$release_dir/staged-${{ matrix.bundle }}"
|
||||
mkdir -p "$output_dir"
|
||||
for binary in ${{ matrix.binaries }}; do
|
||||
cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe"
|
||||
cp "target/${{ matrix.target }}/release/${binary}.pdb" "$output_dir/${binary}.pdb"
|
||||
pdb_name="${binary//-/_}"
|
||||
pdb_path="$release_dir/${pdb_name}.pdb"
|
||||
if [[ ! -f "$pdb_path" ]]; then
|
||||
pdb_path="$release_dir/${binary}.pdb"
|
||||
fi
|
||||
if [[ ! -f "$pdb_path" ]]; then
|
||||
echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe"
|
||||
cp "$pdb_path" "$output_dir/${binary}.pdb"
|
||||
done
|
||||
|
||||
- name: Upload Windows binaries
|
||||
@@ -153,6 +151,9 @@ jobs:
|
||||
- build-windows-binaries
|
||||
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
environment:
|
||||
name: azure-artifact-signing
|
||||
deployment: false
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -865,7 +865,6 @@ jobs:
|
||||
uses: ./.github/workflows/rust-release-windows.yml
|
||||
with:
|
||||
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
|
||||
secrets: inherit
|
||||
|
||||
argument-comment-lint-release-assets:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }}
|
||||
|
||||
@@ -23,15 +23,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Run inside Alpine so dependency resolution exercises the pinned
|
||||
# runtime wheel on the same Linux wheel family that CI installs.
|
||||
# Run inside a glibc Linux image so dependency resolution exercises
|
||||
# the pinned manylinux runtime wheel that users install.
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e HOME=/tmp/codex-python-sdk-home \
|
||||
-e UV_LINK_MODE=copy \
|
||||
-v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \
|
||||
-w "${GITHUB_WORKSPACE}/sdk/python" \
|
||||
python:3.12-alpine \
|
||||
python:3.12-slim \
|
||||
sh -euxc '
|
||||
python -m venv /tmp/uv
|
||||
/tmp/uv/bin/python -m pip install uv==0.11.3
|
||||
|
||||
Generated
-3
@@ -2347,7 +2347,6 @@ dependencies = [
|
||||
name = "codex-cloud-config"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codex-backend-client",
|
||||
@@ -2356,7 +2355,6 @@ dependencies = [
|
||||
"codex-login",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"hmac 0.12.1",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
@@ -2365,7 +2363,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ use crate::facts::SkillInvokedInput;
|
||||
use crate::facts::SubAgentThreadStartedInput;
|
||||
use crate::facts::ThreadInitializationMode;
|
||||
use crate::facts::TrackEventsContext;
|
||||
use crate::facts::TurnCodexErrorFact;
|
||||
use crate::facts::TurnResolvedConfigFact;
|
||||
use crate::facts::TurnStatus;
|
||||
use crate::facts::TurnSteerRequestError;
|
||||
@@ -132,6 +133,7 @@ use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -389,6 +391,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo
|
||||
sandbox_network_access: true,
|
||||
collaboration_mode: ModeKind::Plan,
|
||||
personality: None,
|
||||
workspace_kind: None,
|
||||
is_first_turn: true,
|
||||
}
|
||||
}
|
||||
@@ -833,6 +836,7 @@ fn sample_permissions_approval_request(request_id: i64) -> ServerRequest {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "permissions-1".to_string(),
|
||||
environment_id: None,
|
||||
started_at_ms: 1_000,
|
||||
cwd: test_path_buf("/tmp").abs(),
|
||||
reason: Some("need network".to_string()),
|
||||
@@ -3252,10 +3256,14 @@ fn turn_event_serializes_expected_shape() {
|
||||
sandbox_network_access: true,
|
||||
collaboration_mode: Some("plan"),
|
||||
personality: Some("pragmatic".to_string()),
|
||||
workspace_kind: Some("projectless".to_string()),
|
||||
num_input_images: 2,
|
||||
is_first_turn: true,
|
||||
status: Some(TurnStatus::Completed),
|
||||
turn_error: None,
|
||||
codex_error_kind: None,
|
||||
codex_error_subreason: None,
|
||||
codex_error_http_status_code: None,
|
||||
steer_count: Some(0),
|
||||
total_tool_call_count: None,
|
||||
shell_command_count: None,
|
||||
@@ -3314,10 +3322,14 @@ fn turn_event_serializes_expected_shape() {
|
||||
"sandbox_network_access": true,
|
||||
"collaboration_mode": "plan",
|
||||
"personality": "pragmatic",
|
||||
"workspace_kind": "projectless",
|
||||
"num_input_images": 2,
|
||||
"is_first_turn": true,
|
||||
"status": "completed",
|
||||
"turn_error": null,
|
||||
"codex_error_kind": null,
|
||||
"codex_error_subreason": null,
|
||||
"codex_error_http_status_code": null,
|
||||
"steer_count": 0,
|
||||
"total_tool_call_count": null,
|
||||
"shell_command_count": null,
|
||||
@@ -3631,6 +3643,7 @@ async fn turn_lifecycle_emits_turn_event() {
|
||||
);
|
||||
assert!(payload["event_params"].get("product_client_id").is_none());
|
||||
assert_eq!(payload["event_params"]["ephemeral"], json!(false));
|
||||
assert_eq!(payload["event_params"]["workspace_kind"], json!(null));
|
||||
assert_eq!(payload["event_params"]["num_input_images"], json!(1));
|
||||
assert_eq!(payload["event_params"]["status"], json!("completed"));
|
||||
assert_eq!(payload["event_params"]["steer_count"], json!(0));
|
||||
@@ -3983,6 +3996,18 @@ async fn turn_lifecycle_emits_failed_turn_event() {
|
||||
/*include_token_usage*/ false,
|
||||
)
|
||||
.await;
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(Box::new(
|
||||
TurnCodexErrorFact::from_codex_err(
|
||||
"thread-2".to_string(),
|
||||
"turn-2".to_string(),
|
||||
&CodexErr::InvalidRequest("unknown turn environment id `env-2`".to_string()),
|
||||
),
|
||||
))),
|
||||
&mut out,
|
||||
)
|
||||
.await;
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
|
||||
@@ -3999,6 +4024,18 @@ async fn turn_lifecycle_emits_failed_turn_event() {
|
||||
let payload = serde_json::to_value(&out[0]).expect("serialize turn event");
|
||||
assert_eq!(payload["event_params"]["status"], json!("failed"));
|
||||
assert_eq!(payload["event_params"]["turn_error"], json!("badRequest"));
|
||||
assert_eq!(
|
||||
payload["event_params"]["codex_error_kind"],
|
||||
json!("invalid_request")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["event_params"]["codex_error_subreason"],
|
||||
json!("unknown turn environment id `env-2`")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["event_params"]["codex_error_http_status_code"],
|
||||
json!(null)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4031,6 +4068,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() {
|
||||
let payload = serde_json::to_value(&out[0]).expect("serialize turn event");
|
||||
assert_eq!(payload["event_params"]["status"], json!("interrupted"));
|
||||
assert_eq!(payload["event_params"]["turn_error"], json!(null));
|
||||
assert_eq!(payload["event_params"]["codex_error_kind"], json!(null));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::facts::SkillInvocation;
|
||||
use crate::facts::SkillInvokedInput;
|
||||
use crate::facts::SubAgentThreadStartedInput;
|
||||
use crate::facts::TrackEventsContext;
|
||||
use crate::facts::TurnCodexErrorFact;
|
||||
use crate::facts::TurnResolvedConfigFact;
|
||||
use crate::facts::TurnTokenUsageFact;
|
||||
use crate::reducer::AnalyticsReducer;
|
||||
@@ -256,6 +257,12 @@ impl AnalyticsEventsClient {
|
||||
)));
|
||||
}
|
||||
|
||||
pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) {
|
||||
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(
|
||||
Box::new(fact),
|
||||
)));
|
||||
}
|
||||
|
||||
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::time::Instant;
|
||||
use crate::facts::AcceptedLineFingerprint;
|
||||
use crate::facts::AppInvocation;
|
||||
use crate::facts::CodexCompactionEvent;
|
||||
use crate::facts::CodexErrKind;
|
||||
use crate::facts::CompactionImplementation;
|
||||
use crate::facts::CompactionPhase;
|
||||
use crate::facts::CompactionReason;
|
||||
@@ -793,10 +794,14 @@ pub(crate) struct CodexTurnEventParams {
|
||||
pub(crate) sandbox_network_access: bool,
|
||||
pub(crate) collaboration_mode: Option<&'static str>,
|
||||
pub(crate) personality: Option<String>,
|
||||
pub(crate) workspace_kind: Option<String>,
|
||||
pub(crate) num_input_images: usize,
|
||||
pub(crate) is_first_turn: bool,
|
||||
pub(crate) status: Option<TurnStatus>,
|
||||
pub(crate) turn_error: Option<CodexErrorInfo>,
|
||||
pub(crate) codex_error_kind: Option<CodexErrKind>,
|
||||
pub(crate) codex_error_subreason: Option<String>,
|
||||
pub(crate) codex_error_http_status_code: Option<u16>,
|
||||
pub(crate) steer_count: Option<usize>,
|
||||
pub(crate) total_tool_call_count: Option<usize>,
|
||||
pub(crate) shell_command_count: Option<usize>,
|
||||
|
||||
@@ -15,6 +15,7 @@ use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -29,6 +30,9 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const INVALID_REQUEST_SUBREASON_MAX_BYTES: usize = 512;
|
||||
const INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX: &str = "...";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AcceptedLineFingerprint {
|
||||
pub path_hash: String,
|
||||
@@ -81,6 +85,7 @@ pub struct TurnResolvedConfigFact {
|
||||
pub sandbox_network_access: bool,
|
||||
pub collaboration_mode: ModeKind,
|
||||
pub personality: Option<Personality>,
|
||||
pub workspace_kind: Option<String>,
|
||||
pub is_first_turn: bool,
|
||||
}
|
||||
|
||||
@@ -99,6 +104,147 @@ pub struct TurnTokenUsageFact {
|
||||
pub token_usage: TokenUsage,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TurnCodexErrorFact {
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) error: TurnCodexError,
|
||||
}
|
||||
|
||||
impl TurnCodexErrorFact {
|
||||
pub fn from_codex_err(thread_id: String, turn_id: String, error: &CodexErr) -> Self {
|
||||
Self {
|
||||
turn_id,
|
||||
thread_id,
|
||||
error: TurnCodexError::from_codex_err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum CodexErrKind {
|
||||
TurnAborted,
|
||||
Stream,
|
||||
ContextWindowExceeded,
|
||||
ThreadNotFound,
|
||||
AgentLimitReached,
|
||||
SessionConfiguredNotFirstEvent,
|
||||
Timeout,
|
||||
RequestTimeout,
|
||||
Spawn,
|
||||
Interrupted,
|
||||
UnexpectedStatus,
|
||||
InvalidRequest,
|
||||
InvalidImageRequest,
|
||||
UsageLimitReached,
|
||||
ServerOverloaded,
|
||||
CyberPolicy,
|
||||
ResponseStreamFailed,
|
||||
ConnectionFailed,
|
||||
QuotaExceeded,
|
||||
UsageNotIncluded,
|
||||
InternalServerError,
|
||||
RetryLimit,
|
||||
InternalAgentDied,
|
||||
Sandbox,
|
||||
LandlockSandboxExecutableNotProvided,
|
||||
UnsupportedOperation,
|
||||
RefreshTokenFailed,
|
||||
Fatal,
|
||||
Io,
|
||||
Json,
|
||||
#[cfg(target_os = "linux")]
|
||||
LandlockRuleset,
|
||||
#[cfg(target_os = "linux")]
|
||||
LandlockPathFd,
|
||||
TokioJoin,
|
||||
EnvVar,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TurnCodexError {
|
||||
pub(crate) kind: CodexErrKind,
|
||||
pub(crate) subreason: Option<String>,
|
||||
pub(crate) http_status_code: Option<u16>,
|
||||
}
|
||||
|
||||
impl TurnCodexError {
|
||||
fn from_codex_err(error: &CodexErr) -> Self {
|
||||
Self {
|
||||
kind: error.into(),
|
||||
subreason: match error {
|
||||
CodexErr::InvalidRequest(message) => {
|
||||
// InvalidRequest can contain raw provider response bodies, so bound the
|
||||
// analytics copy without changing the source CodexErr.
|
||||
let subreason = if message.len() <= INVALID_REQUEST_SUBREASON_MAX_BYTES {
|
||||
message.clone()
|
||||
} else {
|
||||
let truncated_len = message.floor_char_boundary(
|
||||
INVALID_REQUEST_SUBREASON_MAX_BYTES
|
||||
.saturating_sub(INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX.len()),
|
||||
);
|
||||
format!(
|
||||
"{}{INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX}",
|
||||
&message[..truncated_len]
|
||||
)
|
||||
};
|
||||
Some(subreason)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
http_status_code: error.http_status_code_value(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&CodexErr> for CodexErrKind {
|
||||
fn from(error: &CodexErr) -> Self {
|
||||
match error {
|
||||
CodexErr::TurnAborted => CodexErrKind::TurnAborted,
|
||||
CodexErr::Stream(..) => CodexErrKind::Stream,
|
||||
CodexErr::ContextWindowExceeded => CodexErrKind::ContextWindowExceeded,
|
||||
CodexErr::ThreadNotFound(_) => CodexErrKind::ThreadNotFound,
|
||||
CodexErr::AgentLimitReached { .. } => CodexErrKind::AgentLimitReached,
|
||||
CodexErr::SessionConfiguredNotFirstEvent => {
|
||||
CodexErrKind::SessionConfiguredNotFirstEvent
|
||||
}
|
||||
CodexErr::Timeout => CodexErrKind::Timeout,
|
||||
CodexErr::RequestTimeout => CodexErrKind::RequestTimeout,
|
||||
CodexErr::Spawn => CodexErrKind::Spawn,
|
||||
CodexErr::Interrupted => CodexErrKind::Interrupted,
|
||||
CodexErr::UnexpectedStatus(_) => CodexErrKind::UnexpectedStatus,
|
||||
CodexErr::InvalidRequest(_) => CodexErrKind::InvalidRequest,
|
||||
CodexErr::InvalidImageRequest() => CodexErrKind::InvalidImageRequest,
|
||||
CodexErr::UsageLimitReached(_) => CodexErrKind::UsageLimitReached,
|
||||
CodexErr::ServerOverloaded => CodexErrKind::ServerOverloaded,
|
||||
CodexErr::CyberPolicy { .. } => CodexErrKind::CyberPolicy,
|
||||
CodexErr::ResponseStreamFailed(_) => CodexErrKind::ResponseStreamFailed,
|
||||
CodexErr::ConnectionFailed(_) => CodexErrKind::ConnectionFailed,
|
||||
CodexErr::QuotaExceeded => CodexErrKind::QuotaExceeded,
|
||||
CodexErr::UsageNotIncluded => CodexErrKind::UsageNotIncluded,
|
||||
CodexErr::InternalServerError => CodexErrKind::InternalServerError,
|
||||
CodexErr::RetryLimit(_) => CodexErrKind::RetryLimit,
|
||||
CodexErr::InternalAgentDied => CodexErrKind::InternalAgentDied,
|
||||
CodexErr::Sandbox(_) => CodexErrKind::Sandbox,
|
||||
CodexErr::LandlockSandboxExecutableNotProvided => {
|
||||
CodexErrKind::LandlockSandboxExecutableNotProvided
|
||||
}
|
||||
CodexErr::UnsupportedOperation(_) => CodexErrKind::UnsupportedOperation,
|
||||
CodexErr::RefreshTokenFailed(_) => CodexErrKind::RefreshTokenFailed,
|
||||
CodexErr::Fatal(_) => CodexErrKind::Fatal,
|
||||
CodexErr::Io(_) => CodexErrKind::Io,
|
||||
CodexErr::Json(_) => CodexErrKind::Json,
|
||||
#[cfg(target_os = "linux")]
|
||||
CodexErr::LandlockRuleset(_) => CodexErrKind::LandlockRuleset,
|
||||
#[cfg(target_os = "linux")]
|
||||
CodexErr::LandlockPathFd(_) => CodexErrKind::LandlockPathFd,
|
||||
CodexErr::TokioJoin(_) => CodexErrKind::TokioJoin,
|
||||
CodexErr::EnvVar(_) => CodexErrKind::EnvVar,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnStatus {
|
||||
@@ -329,6 +475,7 @@ pub(crate) enum CustomAnalyticsFact {
|
||||
GuardianReview(Box<GuardianReviewEventParams>),
|
||||
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
|
||||
TurnTokenUsage(Box<TurnTokenUsageFact>),
|
||||
TurnCodexError(Box<TurnCodexErrorFact>),
|
||||
SkillInvoked(SkillInvokedInput),
|
||||
AppMentioned(AppMentionedInput),
|
||||
AppUsed(AppUsedInput),
|
||||
|
||||
@@ -38,6 +38,7 @@ pub use facts::SkillInvocation;
|
||||
pub use facts::SubAgentThreadStartedInput;
|
||||
pub use facts::ThreadInitializationMode;
|
||||
pub use facts::TrackEventsContext;
|
||||
pub use facts::TurnCodexErrorFact;
|
||||
pub use facts::TurnResolvedConfigFact;
|
||||
pub use facts::TurnStatus;
|
||||
pub use facts::TurnSteerRejectionReason;
|
||||
|
||||
@@ -70,6 +70,8 @@ use crate::facts::PluginUsedInput;
|
||||
use crate::facts::SkillInvokedInput;
|
||||
use crate::facts::SubAgentThreadStartedInput;
|
||||
use crate::facts::ThreadInitializationMode;
|
||||
use crate::facts::TurnCodexError;
|
||||
use crate::facts::TurnCodexErrorFact;
|
||||
use crate::facts::TurnResolvedConfigFact;
|
||||
use crate::facts::TurnStatus;
|
||||
use crate::facts::TurnSteerRejectionReason;
|
||||
@@ -322,6 +324,7 @@ struct TurnState {
|
||||
started_at: Option<u64>,
|
||||
token_usage: Option<TokenUsage>,
|
||||
completed: Option<CompletedTurnState>,
|
||||
codex_error: Option<TurnCodexError>,
|
||||
latest_diff: Option<String>,
|
||||
steer_count: usize,
|
||||
tool_counts: TurnToolCounts,
|
||||
@@ -461,6 +464,9 @@ impl AnalyticsReducer {
|
||||
CustomAnalyticsFact::TurnTokenUsage(input) => {
|
||||
self.ingest_turn_token_usage(*input, out).await;
|
||||
}
|
||||
CustomAnalyticsFact::TurnCodexError(input) => {
|
||||
self.ingest_turn_codex_error(*input);
|
||||
}
|
||||
CustomAnalyticsFact::SkillInvoked(input) => {
|
||||
self.ingest_skill_invoked(input, out).await;
|
||||
}
|
||||
@@ -606,6 +612,7 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
codex_error: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
tool_counts: TurnToolCounts::default(),
|
||||
@@ -630,6 +637,7 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
codex_error: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
tool_counts: TurnToolCounts::default(),
|
||||
@@ -639,6 +647,29 @@ impl AnalyticsReducer {
|
||||
self.maybe_emit_turn_event(&turn_id, out).await;
|
||||
}
|
||||
|
||||
fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) {
|
||||
let TurnCodexErrorFact {
|
||||
turn_id,
|
||||
thread_id,
|
||||
error,
|
||||
} = input;
|
||||
let turn_state = self.turns.entry(turn_id).or_insert(TurnState {
|
||||
connection_id: None,
|
||||
thread_id: None,
|
||||
num_input_images: None,
|
||||
resolved_config: None,
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
codex_error: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
tool_counts: TurnToolCounts::default(),
|
||||
});
|
||||
turn_state.thread_id.get_or_insert(thread_id);
|
||||
turn_state.codex_error = Some(error);
|
||||
}
|
||||
|
||||
async fn ingest_skill_invoked(
|
||||
&mut self,
|
||||
input: SkillInvokedInput,
|
||||
@@ -795,6 +826,7 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
codex_error: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
tool_counts: TurnToolCounts::default(),
|
||||
@@ -1154,6 +1186,7 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
codex_error: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
tool_counts: TurnToolCounts::default(),
|
||||
@@ -1175,6 +1208,7 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
codex_error: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
tool_counts: TurnToolCounts::default(),
|
||||
@@ -1194,6 +1228,7 @@ impl AnalyticsReducer {
|
||||
started_at: None,
|
||||
token_usage: None,
|
||||
completed: None,
|
||||
codex_error: None,
|
||||
latest_diff: None,
|
||||
steer_count: 0,
|
||||
tool_counts: TurnToolCounts::default(),
|
||||
@@ -2448,9 +2483,11 @@ fn codex_turn_event_params(
|
||||
sandbox_network_access,
|
||||
collaboration_mode,
|
||||
personality,
|
||||
workspace_kind,
|
||||
is_first_turn,
|
||||
} = resolved_config;
|
||||
let token_usage = turn_state.token_usage.clone();
|
||||
let codex_error = turn_state.codex_error.as_ref();
|
||||
CodexTurnEventParams {
|
||||
thread_id,
|
||||
session_id: thread_metadata.session_id.clone(),
|
||||
@@ -2479,10 +2516,14 @@ fn codex_turn_event_params(
|
||||
sandbox_network_access,
|
||||
collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)),
|
||||
personality: personality_mode(personality),
|
||||
workspace_kind,
|
||||
num_input_images,
|
||||
is_first_turn,
|
||||
status: completed.status,
|
||||
turn_error: completed.turn_error,
|
||||
codex_error_kind: codex_error.map(|error| error.kind),
|
||||
codex_error_subreason: codex_error.and_then(|error| error.subreason.clone()),
|
||||
codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code),
|
||||
steer_count: Some(turn_state.steer_count),
|
||||
total_tool_call_count: Some(turn_state.tool_counts.total),
|
||||
shell_command_count: Some(turn_state.tool_counts.shell_command),
|
||||
|
||||
@@ -43,7 +43,7 @@ use codex_app_server_protocol::Result as JsonRpcResult;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
use codex_config::RemoteThreadConfigLoader;
|
||||
@@ -339,8 +339,8 @@ pub struct InProcessClientStartArgs {
|
||||
pub loader_overrides: LoaderOverrides,
|
||||
/// Whether config API paths should reject unknown config fields.
|
||||
pub strict_config: bool,
|
||||
/// Preloaded cloud requirements provider.
|
||||
pub cloud_requirements: CloudRequirementsLoader,
|
||||
/// Preloaded cloud config bundle provider.
|
||||
pub cloud_config_bundle: CloudConfigBundleLoader,
|
||||
/// Feedback sink used by app-server/core telemetry and logs.
|
||||
pub feedback: CodexFeedback,
|
||||
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
|
||||
@@ -406,7 +406,7 @@ impl InProcessClientStartArgs {
|
||||
cli_overrides: self.cli_overrides,
|
||||
loader_overrides: self.loader_overrides,
|
||||
strict_config: self.strict_config,
|
||||
cloud_requirements: self.cloud_requirements,
|
||||
cloud_config_bundle: self.cloud_config_bundle,
|
||||
thread_config_loader,
|
||||
feedback: self.feedback,
|
||||
log_db: self.log_db,
|
||||
@@ -1035,7 +1035,7 @@ mod tests {
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
state_db: Some(state_db),
|
||||
@@ -2199,7 +2199,7 @@ mod tests {
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
state_db: None,
|
||||
@@ -2240,7 +2240,7 @@ mod tests {
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
state_db: None,
|
||||
|
||||
+8
-1
@@ -285,6 +285,13 @@
|
||||
"cwd": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -319,4 +326,4 @@
|
||||
],
|
||||
"title": "PermissionsRequestApprovalParams",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -1590,6 +1590,13 @@
|
||||
"cwd": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1998,4 +2005,4 @@
|
||||
}
|
||||
],
|
||||
"title": "ServerRequest"
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -3783,6 +3783,13 @@
|
||||
"cwd": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -19232,4 +19239,4 @@
|
||||
},
|
||||
"title": "CodexAppServerProtocol",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
import type { RequestPermissionProfile } from "./RequestPermissionProfile";
|
||||
|
||||
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string,
|
||||
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, environmentId: string | null,
|
||||
/**
|
||||
* Unix timestamp (in milliseconds) when this approval request started.
|
||||
*/
|
||||
|
||||
@@ -663,6 +663,8 @@ pub struct PermissionsRequestApprovalParams {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
#[serde(default)]
|
||||
pub environment_id: Option<String>,
|
||||
/// Unix timestamp (in milliseconds) when this approval request started.
|
||||
#[ts(type = "number")]
|
||||
pub started_at_ms: i64,
|
||||
|
||||
@@ -400,6 +400,7 @@ fn permissions_request_approval_uses_request_permission_profile() {
|
||||
"threadId": "thr_123",
|
||||
"turnId": "turn_123",
|
||||
"itemId": "call_123",
|
||||
"environmentId": "remote",
|
||||
"startedAtMs": 1,
|
||||
"cwd": absolute_path_string("repo"),
|
||||
"reason": "Select a workspace root",
|
||||
@@ -416,6 +417,7 @@ fn permissions_request_approval_uses_request_permission_profile() {
|
||||
.expect("permissions request should deserialize");
|
||||
|
||||
assert_eq!(params.cwd, absolute_path("repo"));
|
||||
assert_eq!(params.environment_id.as_deref(), Some("remote"));
|
||||
assert_eq!(
|
||||
params.permissions,
|
||||
RequestPermissionProfile {
|
||||
|
||||
@@ -1379,7 +1379,7 @@ the client can offer session-scoped and/or persistent approval choices.
|
||||
|
||||
### Permission requests
|
||||
|
||||
The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve project-root permissions and relative deny globs.
|
||||
The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `environmentId` and `cwd` fields identify the environment and directory used to resolve project-root permissions and relative deny globs.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1389,6 +1389,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov
|
||||
"threadId": "thr_123",
|
||||
"turnId": "turn_123",
|
||||
"itemId": "call_123",
|
||||
"environmentId": "local",
|
||||
"cwd": "/Users/me/project",
|
||||
"reason": "Select a workspace root",
|
||||
"permissions": {
|
||||
|
||||
@@ -761,6 +761,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
item_id: request.call_id.clone(),
|
||||
environment_id: request.environment_id.clone(),
|
||||
started_at_ms: request.started_at_ms,
|
||||
cwd: request_cwd.clone(),
|
||||
reason: request.reason,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_cloud_config::cloud_requirements_loader;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_cloud_config::cloud_config_bundle_loader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
@@ -31,7 +31,7 @@ pub(crate) struct ConfigManager {
|
||||
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
strict_config: bool,
|
||||
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
|
||||
cloud_config_bundle: Arc<RwLock<CloudConfigBundleLoader>>,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
|
||||
}
|
||||
@@ -42,7 +42,7 @@ impl ConfigManager {
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
strict_config: bool,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
cloud_config_bundle: CloudConfigBundleLoader,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
thread_config_loader: Arc<dyn ThreadConfigLoader>,
|
||||
) -> Self {
|
||||
@@ -52,7 +52,7 @@ impl ConfigManager {
|
||||
runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())),
|
||||
loader_overrides,
|
||||
strict_config,
|
||||
cloud_requirements: Arc::new(RwLock::new(cloud_requirements)),
|
||||
cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)),
|
||||
arg0_paths,
|
||||
thread_config_loader: Arc::new(RwLock::new(thread_config_loader)),
|
||||
}
|
||||
@@ -73,8 +73,8 @@ impl ConfigManager {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn current_cloud_requirements(&self) -> CloudRequirementsLoader {
|
||||
self.cloud_requirements
|
||||
pub(crate) fn current_cloud_config_bundle(&self) -> CloudConfigBundleLoader {
|
||||
self.cloud_config_bundle
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
@@ -90,17 +90,17 @@ impl ConfigManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn replace_cloud_requirements_loader(
|
||||
pub(crate) fn replace_cloud_config_bundle_loader(
|
||||
&self,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
) {
|
||||
let loader =
|
||||
cloud_requirements_loader(auth_manager, chatgpt_base_url, self.codex_home.clone());
|
||||
if let Ok(mut guard) = self.cloud_requirements.write() {
|
||||
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, self.codex_home.clone());
|
||||
if let Ok(mut guard) = self.cloud_config_bundle.write() {
|
||||
*guard = loader;
|
||||
} else {
|
||||
warn!("failed to update cloud requirements loader");
|
||||
warn!("failed to update cloud config bundle loader");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ impl ConfigManager {
|
||||
.strict_config(self.strict_config)
|
||||
.harness_overrides(typesafe_overrides)
|
||||
.fallback_cwd(fallback_cwd)
|
||||
.cloud_requirements(self.current_cloud_requirements())
|
||||
.cloud_config_bundle(self.current_cloud_config_bundle())
|
||||
.thread_config_loader(self.current_thread_config_loader())
|
||||
.build()
|
||||
.await?;
|
||||
@@ -274,8 +274,8 @@ impl ConfigManager {
|
||||
codex_config::ConfigLoadOptions {
|
||||
loader_overrides: self.loader_overrides.clone(),
|
||||
strict_config: self.strict_config,
|
||||
cloud_config_bundle: self.current_cloud_config_bundle(),
|
||||
},
|
||||
self.current_cloud_requirements(),
|
||||
thread_config_loader.as_ref(),
|
||||
)
|
||||
.await
|
||||
@@ -303,14 +303,14 @@ impl ConfigManager {
|
||||
codex_home: PathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
cloud_config_bundle: CloudConfigBundleLoader,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
codex_home,
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
/*strict_config*/ false,
|
||||
cloud_requirements,
|
||||
cloud_config_bundle,
|
||||
Arg0DispatchPaths::default(),
|
||||
Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
)
|
||||
@@ -322,7 +322,7 @@ impl ConfigManager {
|
||||
codex_home,
|
||||
Vec::new(),
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@ use codex_app_server_protocol::AppConfig;
|
||||
use codex_app_server_protocol::AppToolApproval;
|
||||
use codex_app_server_protocol::AppsConfig;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::FeatureRequirementsToml;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
@@ -356,7 +355,7 @@ async fn read_includes_origins_and_layers() {
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
|
||||
let response = service
|
||||
@@ -435,7 +434,7 @@ writable_roots = ["~/code"]
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
|
||||
let response = service
|
||||
@@ -475,7 +474,7 @@ async fn write_value_reports_override() {
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
|
||||
let result = service
|
||||
@@ -578,7 +577,7 @@ async fn write_value_defaults_to_selected_user_config_path() {
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
@@ -618,7 +617,7 @@ async fn load_default_config_preserves_selected_user_config_path_after_load_erro
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
|
||||
service
|
||||
@@ -648,7 +647,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
|
||||
let error = service
|
||||
@@ -708,14 +707,12 @@ async fn write_value_rejects_feature_requirement_conflict() {
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
feature_requirements: Some(FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("personality".to_string(), true)]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}),
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[features]
|
||||
personality = true
|
||||
"#,
|
||||
),
|
||||
);
|
||||
|
||||
let error = service
|
||||
@@ -765,7 +762,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
|
||||
tmp.path().to_path_buf(),
|
||||
cli_overrides,
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
|
||||
let response = service
|
||||
@@ -821,7 +818,7 @@ async fn write_value_reports_managed_override() {
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
|
||||
let result = service
|
||||
|
||||
@@ -77,7 +77,7 @@ use codex_app_server_protocol::Result;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
use codex_core::config::Config;
|
||||
@@ -124,8 +124,8 @@ pub struct InProcessStartArgs {
|
||||
pub loader_overrides: LoaderOverrides,
|
||||
/// Whether config API paths should reject unknown config fields.
|
||||
pub strict_config: bool,
|
||||
/// Preloaded cloud requirements provider.
|
||||
pub cloud_requirements: CloudRequirementsLoader,
|
||||
/// Preloaded cloud config bundle provider.
|
||||
pub cloud_config_bundle: CloudConfigBundleLoader,
|
||||
/// Loader used to fetch typed thread config sources before a thread starts.
|
||||
pub thread_config_loader: Arc<dyn ThreadConfigLoader>,
|
||||
/// Feedback sink used by app-server/core telemetry and logs.
|
||||
@@ -415,7 +415,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
|
||||
args.cli_overrides,
|
||||
args.loader_overrides,
|
||||
args.strict_config,
|
||||
args.cloud_requirements,
|
||||
args.cloud_config_bundle,
|
||||
args.arg0_paths.clone(),
|
||||
args.thread_config_loader,
|
||||
);
|
||||
@@ -772,7 +772,7 @@ mod tests {
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
|
||||
@@ -473,11 +473,14 @@ pub async fn run_main_with_transport_options(
|
||||
.replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader));
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
|
||||
config_manager.replace_cloud_requirements_loader(auth_manager, config.chatgpt_base_url);
|
||||
config_manager
|
||||
.replace_cloud_config_bundle_loader(auth_manager, config.chatgpt_base_url);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to preload config for cloud requirements");
|
||||
// TODO(gt): Make cloud requirements preload failures blocking once we can fail-closed.
|
||||
warn!(error = %err, "Failed to preload config for cloud config bundle");
|
||||
// TODO: Decide whether bootstrap config preload failures should block startup.
|
||||
// If this fails, we cannot install cloud/thread config loaders, so non-strict
|
||||
// startup may continue without managed cloud config.
|
||||
}
|
||||
};
|
||||
let mut config_warnings = Vec::new();
|
||||
|
||||
@@ -103,7 +103,7 @@ mod tests {
|
||||
use crate::extensions::thread_extensions;
|
||||
use async_trait::async_trait;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::ThreadConfigContext;
|
||||
use codex_config::ThreadConfigLoadError;
|
||||
@@ -213,7 +213,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
/*strict_config*/ false,
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
Arg0DispatchPaths::default(),
|
||||
loader.clone(),
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
@@ -240,7 +240,7 @@ async fn build_test_processor(
|
||||
Vec::new(),
|
||||
LoaderOverrides::default(),
|
||||
/*strict_config*/ false,
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
Arg0DispatchPaths::default(),
|
||||
Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
);
|
||||
|
||||
@@ -266,8 +266,8 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_chatgpt::workspace_settings;
|
||||
use codex_config::CloudRequirementsLoadError;
|
||||
use codex_config::CloudRequirementsLoadErrorCode;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::loader::project_trust_key;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
|
||||
@@ -586,7 +586,7 @@ impl AccountRequestProcessor {
|
||||
)
|
||||
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
|
||||
self.auth_manager.reload().await;
|
||||
self.config_manager.replace_cloud_requirements_loader(
|
||||
self.config_manager.replace_cloud_config_bundle_loader(
|
||||
self.auth_manager.clone(),
|
||||
self.config.chatgpt_base_url.clone(),
|
||||
);
|
||||
@@ -645,7 +645,7 @@ impl AccountRequestProcessor {
|
||||
let auth_manager = thread_manager.auth_manager();
|
||||
auth_manager.reload().await;
|
||||
config_manager
|
||||
.replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url);
|
||||
.replace_cloud_config_bundle_loader(auth_manager.clone(), chatgpt_base_url);
|
||||
config_manager
|
||||
.sync_default_client_residency_requirement()
|
||||
.await;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::*;
|
||||
|
||||
fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> {
|
||||
fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBundleLoadError> {
|
||||
let mut current: Option<&(dyn std::error::Error + 'static)> = err
|
||||
.get_ref()
|
||||
.map(|source| source as &(dyn std::error::Error + 'static));
|
||||
while let Some(source) = current {
|
||||
if let Some(cloud_error) = source.downcast_ref::<CloudRequirementsLoadError>() {
|
||||
if let Some(cloud_error) = source.downcast_ref::<CloudConfigBundleLoadError>() {
|
||||
return Some(cloud_error);
|
||||
}
|
||||
current = source.source();
|
||||
@@ -14,16 +14,16 @@ fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequireme
|
||||
}
|
||||
|
||||
pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
|
||||
let data = cloud_requirements_load_error(err).map(|cloud_error| {
|
||||
let data = cloud_config_bundle_load_error(err).map(|cloud_error| {
|
||||
let mut data = serde_json::json!({
|
||||
"reason": "cloudRequirements",
|
||||
"reason": "cloudConfigBundle",
|
||||
"errorCode": format!("{:?}", cloud_error.code()),
|
||||
"detail": cloud_error.to_string(),
|
||||
});
|
||||
if let Some(status_code) = cloud_error.status_code() {
|
||||
data["statusCode"] = serde_json::json!(status_code);
|
||||
}
|
||||
if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth {
|
||||
if cloud_error.code() == CloudConfigBundleLoadErrorCode::Auth {
|
||||
data["action"] = serde_json::json!("relogin");
|
||||
}
|
||||
data
|
||||
|
||||
@@ -2766,7 +2766,7 @@ impl ThreadRequestProcessor {
|
||||
.as_ref()
|
||||
.or(source_thread.rollout_path.as_ref());
|
||||
if let (Some(requested_path), Some(active_path)) = (params.path.as_ref(), active_path)
|
||||
&& requested_path != active_path
|
||||
&& !path_utils::paths_match_after_normalization(requested_path, active_path)
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`",
|
||||
|
||||
@@ -54,7 +54,7 @@ mod thread_processor_behavior_tests {
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::SessionThreadConfig;
|
||||
use codex_config::StaticThreadConfigLoader;
|
||||
@@ -508,9 +508,9 @@ mod thread_processor_behavior_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_load_error_marks_cloud_requirements_failures_for_relogin() {
|
||||
let err = std::io::Error::other(CloudRequirementsLoadError::new(
|
||||
CloudRequirementsLoadErrorCode::Auth,
|
||||
fn config_load_error_marks_cloud_config_bundle_failures_for_relogin() {
|
||||
let err = std::io::Error::other(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
Some(401),
|
||||
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
));
|
||||
@@ -520,7 +520,7 @@ mod thread_processor_behavior_tests {
|
||||
assert_eq!(
|
||||
error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudRequirements",
|
||||
"reason": "cloudConfigBundle",
|
||||
"errorCode": "Auth",
|
||||
"action": "relogin",
|
||||
"statusCode": 401,
|
||||
@@ -535,7 +535,7 @@ mod thread_processor_behavior_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() {
|
||||
fn config_load_error_leaves_non_cloud_config_bundle_failures_unmarked() {
|
||||
let err = std::io::Error::other("required MCP servers failed to initialize");
|
||||
|
||||
let error = config_load_error(&err);
|
||||
@@ -549,11 +549,11 @@ mod thread_processor_behavior_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() {
|
||||
let err = std::io::Error::other(CloudRequirementsLoadError::new(
|
||||
CloudRequirementsLoadErrorCode::RequestFailed,
|
||||
fn config_load_error_marks_non_auth_cloud_config_bundle_failures_without_relogin() {
|
||||
let err = std::io::Error::other(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::RequestFailed,
|
||||
/*status_code*/ None,
|
||||
"Failed to load cloud requirements (workspace-managed policies).",
|
||||
"Failed to load cloud config bundle (workspace-managed policies).",
|
||||
));
|
||||
|
||||
let error = config_load_error(&err);
|
||||
@@ -561,9 +561,29 @@ mod thread_processor_behavior_tests {
|
||||
assert_eq!(
|
||||
error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudRequirements",
|
||||
"reason": "cloudConfigBundle",
|
||||
"errorCode": "RequestFailed",
|
||||
"detail": "Failed to load cloud requirements (workspace-managed policies).",
|
||||
"detail": "Failed to load cloud config bundle (workspace-managed policies).",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_load_error_marks_invalid_cloud_config_bundle_failures_without_relogin() {
|
||||
let err = std::io::Error::other(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
"invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)",
|
||||
));
|
||||
|
||||
let error = config_load_error(&err);
|
||||
|
||||
assert_eq!(
|
||||
error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudConfigBundle",
|
||||
"errorCode": "InvalidBundle",
|
||||
"detail": "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)",
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -595,7 +615,7 @@ mod thread_processor_behavior_tests {
|
||||
Vec::new(),
|
||||
LoaderOverrides::default(),
|
||||
/*strict_config*/ false,
|
||||
CloudRequirementsLoader::default(),
|
||||
CloudConfigBundleLoader::default(),
|
||||
Arg0DispatchPaths::default(),
|
||||
Arc::new(StaticThreadConfigLoader::new(vec![
|
||||
ThreadConfigSource::Session(SessionThreadConfig {
|
||||
|
||||
@@ -15,7 +15,7 @@ use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
@@ -150,7 +150,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const RESULT: &str = "cG5n";
|
||||
|
||||
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
|
||||
// subprocesses or processing test RPCs under load.
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> {
|
||||
let call_id = "image-run-1";
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_image_response(&server).await;
|
||||
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
"image_gen",
|
||||
"imagegen",
|
||||
&json!({
|
||||
"action": "generate",
|
||||
"prompt": "paint a blue whale",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Generate an image".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
let completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
wait_for_image_generation_completed(&mut mcp),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let ThreadItem::ImageGeneration {
|
||||
status,
|
||||
revised_prompt,
|
||||
result,
|
||||
saved_path: Some(saved_path),
|
||||
..
|
||||
} = completed.item
|
||||
else {
|
||||
panic!("expected completed image generation item with saved path");
|
||||
};
|
||||
assert_eq!(status, "completed");
|
||||
assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale"));
|
||||
assert_eq!(result, RESULT);
|
||||
assert_eq!(std::fs::read(&saved_path)?, b"png");
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output = requests[1].function_call_output(call_id);
|
||||
assert_eq!(
|
||||
output["output"][0],
|
||||
json!({
|
||||
"type": "input_image",
|
||||
"image_url": format!("data:image/png;base64,{RESULT}"),
|
||||
"detail": "high",
|
||||
})
|
||||
);
|
||||
assert_eq!(output["output"].as_array().map(Vec::len), Some(1));
|
||||
assert!(
|
||||
!requests[1]
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.any(|text| text.contains("Generated images are saved to")),
|
||||
"standalone image generation should not emit the legacy developer-message hint"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_image_generation_completed(
|
||||
mcp: &mut TestAppServer,
|
||||
) -> Result<ItemCompletedNotification> {
|
||||
loop {
|
||||
let notification = mcp
|
||||
.read_stream_until_notification_message("item/completed")
|
||||
.await?;
|
||||
let completed: ItemCompletedNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.context("item/completed notification should include params")?,
|
||||
)?;
|
||||
if matches!(&completed.item, ThreadItem::ImageGeneration { .. }) {
|
||||
return Ok(completed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn mount_image_response(server: &MockServer) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/images/generations"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"created": 1,
|
||||
"data": [{"b64_json": RESULT}],
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
model_provider = "openai-custom"
|
||||
chatgpt_base_url = "{server_uri}"
|
||||
|
||||
[features]
|
||||
imagegenext = true
|
||||
|
||||
[model_providers.openai-custom]
|
||||
name = "OpenAI"
|
||||
base_url = "{server_uri}/api/codex"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
supports_websockets = false
|
||||
requires_openai_auth = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
@@ -201,7 +201,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
|
||||
@@ -17,6 +17,7 @@ mod experimental_feature_list;
|
||||
mod external_agent_config;
|
||||
mod fs;
|
||||
mod hooks_list;
|
||||
mod image_generation;
|
||||
mod initialize;
|
||||
mod marketplace_add;
|
||||
mod marketplace_remove;
|
||||
|
||||
@@ -34,7 +34,7 @@ use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
@@ -76,7 +76,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
|
||||
@@ -510,10 +510,10 @@ async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
async fn thread_fork_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/requirements"))
|
||||
.and(path("/backend-api/wham/config/bundle"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401)
|
||||
.insert_header("content-type", "text/html")
|
||||
@@ -594,7 +594,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
assert_eq!(
|
||||
fork_err.error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudRequirements",
|
||||
"reason": "cloudConfigBundle",
|
||||
"errorCode": "Auth",
|
||||
"action": "relogin",
|
||||
"statusCode": 401,
|
||||
|
||||
@@ -41,7 +41,7 @@ use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
@@ -376,7 +376,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
@@ -442,7 +442,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
@@ -528,7 +528,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
|
||||
@@ -2410,6 +2410,36 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result
|
||||
)
|
||||
.await??;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let active_path = thread.path.as_ref().expect("thread should have path");
|
||||
let active_path_display = active_path.as_os_str().to_string_lossy();
|
||||
let equivalent_path = if let Some(path) = active_path_display.strip_prefix(r"\\?\UNC\") {
|
||||
PathBuf::from(format!(r"\\{path}"))
|
||||
} else if let Some(path) = active_path_display.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(path)
|
||||
} else if let Some(path) = active_path_display.strip_prefix(r"\\") {
|
||||
PathBuf::from(format!(r"\\?\UNC\{path}"))
|
||||
} else {
|
||||
PathBuf::from(format!(r"\\?\{active_path_display}"))
|
||||
};
|
||||
let normalized_resume_id = primary
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread_id.clone(),
|
||||
path: Some(equivalent_path),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let normalized_resume_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
primary.read_stream_until_response_message(RequestId::Integer(normalized_resume_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadResumeResponse { thread, .. } =
|
||||
to_response::<ThreadResumeResponse>(normalized_resume_resp)?;
|
||||
assert_eq!(thread.id, thread_id);
|
||||
}
|
||||
|
||||
let stale_thread_id = Uuid::new_v4().to_string();
|
||||
let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &stale_thread_id);
|
||||
std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?;
|
||||
@@ -3091,10 +3121,10 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/requirements"))
|
||||
.and(path("/backend-api/wham/config/bundle"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401)
|
||||
.insert_header("content-type", "text/html")
|
||||
@@ -3171,7 +3201,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
assert_eq!(
|
||||
err.error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudRequirements",
|
||||
"reason": "cloudConfigBundle",
|
||||
"errorCode": "Auth",
|
||||
"action": "relogin",
|
||||
"statusCode": 401,
|
||||
|
||||
@@ -718,10 +718,10 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result<
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/requirements"))
|
||||
.and(path("/backend-api/wham/config/bundle"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401)
|
||||
.insert_header("content-type", "text/html")
|
||||
@@ -788,7 +788,7 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
assert_eq!(
|
||||
err.error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudRequirements",
|
||||
"reason": "cloudConfigBundle",
|
||||
"errorCode": "Auth",
|
||||
"action": "relogin",
|
||||
"statusCode": 401,
|
||||
|
||||
@@ -22,7 +22,7 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::find_archived_thread_path_by_id_str;
|
||||
@@ -247,7 +247,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
|
||||
@@ -855,6 +855,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
|
||||
url: "https://example.com/a.png".to_string(),
|
||||
detail: None,
|
||||
}],
|
||||
responsesapi_client_metadata: Some(HashMap::from([(
|
||||
"workspace_kind".to_string(),
|
||||
"projectless".to_string(),
|
||||
)])),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -882,6 +886,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
|
||||
assert_eq!(event["event_params"]["model"], "mock-model");
|
||||
assert_eq!(event["event_params"]["model_provider"], "mock_provider");
|
||||
assert_eq!(event["event_params"]["sandbox_policy"], "read_only");
|
||||
assert_eq!(event["event_params"]["workspace_kind"], "projectless");
|
||||
assert_eq!(event["event_params"]["ephemeral"], false);
|
||||
assert_eq!(event["event_params"]["thread_source"], "user");
|
||||
assert_eq!(event["event_params"]["initialization_mode"], "new");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::types::CodeTaskDetailsResponse;
|
||||
use crate::types::ConfigBundleResponse;
|
||||
use crate::types::ConfigFileResponse;
|
||||
use crate::types::PaginatedListTaskListItem;
|
||||
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
|
||||
use crate::types::RateLimitStatusPayload;
|
||||
@@ -393,23 +392,6 @@ impl Client {
|
||||
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
|
||||
}
|
||||
|
||||
/// Fetch the managed requirements file from codex-backend.
|
||||
///
|
||||
/// `GET /api/codex/config/requirements` (Codex API style) or
|
||||
/// `GET /wham/config/requirements` (ChatGPT backend-api style).
|
||||
pub async fn get_config_requirements_file(
|
||||
&self,
|
||||
) -> std::result::Result<ConfigFileResponse, RequestError> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url),
|
||||
};
|
||||
let req = self.http.get(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
|
||||
self.decode_json::<ConfigFileResponse>(&url, &ct, &body)
|
||||
.map_err(RequestError::from)
|
||||
}
|
||||
|
||||
/// Fetch the selected cloud-managed config bundle from codex-backend.
|
||||
///
|
||||
/// `GET /api/codex/config/bundle` (Codex API style) or
|
||||
|
||||
@@ -7,7 +7,6 @@ pub use client::RequestError;
|
||||
pub use types::CodeTaskDetailsResponse;
|
||||
pub use types::CodeTaskDetailsResponseExt;
|
||||
pub use types::ConfigBundleResponse;
|
||||
pub use types::ConfigFileResponse;
|
||||
pub use types::DeliveredConfigToml;
|
||||
pub use types::DeliveredRequirementsToml;
|
||||
pub use types::DeliveredTomlFragment;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub use codex_backend_openapi_models::models::ConfigBundleResponse;
|
||||
pub use codex_backend_openapi_models::models::ConfigFileResponse;
|
||||
pub use codex_backend_openapi_models::models::CreditStatusDetails;
|
||||
pub use codex_backend_openapi_models::models::DeliveredConfigToml;
|
||||
pub use codex_backend_openapi_models::models::DeliveredRequirementsToml;
|
||||
|
||||
@@ -8,7 +8,6 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
codex-backend-client = { workspace = true }
|
||||
@@ -17,14 +16,12 @@ codex-core = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
hmac = "0.12.1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "sync", "time"] }
|
||||
toml = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_backend_client::ConfigBundleResponse;
|
||||
use codex_backend_client::DeliveredTomlFragment;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigFragment;
|
||||
use codex_config::CloudConfigTomlBundle;
|
||||
use codex_config::CloudRequirementsFragment;
|
||||
use codex_config::CloudRequirementsTomlBundle;
|
||||
use codex_login::CodexAuth;
|
||||
use std::future::Future;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RetryableFailureKind {
|
||||
BackendClientInit,
|
||||
Request { status_code: Option<u16> },
|
||||
}
|
||||
|
||||
impl RetryableFailureKind {
|
||||
pub(crate) fn status_code(self) -> Option<u16> {
|
||||
match self {
|
||||
Self::BackendClientInit => None,
|
||||
Self::Request { status_code } => status_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum BundleRequestError {
|
||||
Retryable(RetryableFailureKind),
|
||||
Unauthorized {
|
||||
status_code: Option<u16>,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Retrieves one cloud config bundle from the backend.
|
||||
///
|
||||
/// Implementations should return the backend-selected bundle exactly as delivered and leave
|
||||
/// validation, caching, and config/requirements parsing decisions to the service layer.
|
||||
pub(crate) trait BundleClient: Send + Sync {
|
||||
fn get_bundle(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
) -> impl Future<Output = Result<CloudConfigBundle, BundleRequestError>> + Send;
|
||||
}
|
||||
|
||||
pub(crate) struct BackendBundleClient {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl BackendBundleClient {
|
||||
pub(crate) fn new(base_url: String) -> Self {
|
||||
Self { base_url }
|
||||
}
|
||||
}
|
||||
|
||||
impl BundleClient for BackendBundleClient {
|
||||
async fn get_bundle(&self, auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
|
||||
let client = BackendClient::from_auth(self.base_url.clone(), auth)
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to construct backend client for cloud config bundle"
|
||||
);
|
||||
})
|
||||
.map_err(|_| BundleRequestError::Retryable(RetryableFailureKind::BackendClientInit))?;
|
||||
|
||||
let response = client
|
||||
.get_config_bundle()
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(error = %err, "Failed to fetch cloud config bundle");
|
||||
})
|
||||
.map_err(|err| {
|
||||
let status_code = err.status().map(|status| status.as_u16());
|
||||
if err.is_unauthorized() {
|
||||
BundleRequestError::Unauthorized {
|
||||
status_code,
|
||||
message: err.to_string(),
|
||||
}
|
||||
} else {
|
||||
BundleRequestError::Retryable(RetryableFailureKind::Request { status_code })
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(bundle_from_response(response))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle {
|
||||
let config_toml = response
|
||||
.config_toml
|
||||
.flatten()
|
||||
.map(|config_toml| *config_toml)
|
||||
.and_then(|config_toml| config_toml.enterprise_managed.flatten())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(config_fragment_from_delivered)
|
||||
.collect();
|
||||
let requirements_toml = response
|
||||
.requirements_toml
|
||||
.flatten()
|
||||
.map(|requirements_toml| *requirements_toml)
|
||||
.and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(requirements_fragment_from_delivered)
|
||||
.collect();
|
||||
|
||||
CloudConfigBundle {
|
||||
config_toml: CloudConfigTomlBundle {
|
||||
enterprise_managed: config_toml,
|
||||
},
|
||||
requirements_toml: CloudRequirementsTomlBundle {
|
||||
enterprise_managed: requirements_toml,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment {
|
||||
CloudConfigFragment {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
contents: fragment.contents,
|
||||
}
|
||||
}
|
||||
|
||||
fn requirements_fragment_from_delivered(
|
||||
fragment: DeliveredTomlFragment,
|
||||
) -> CloudRequirementsFragment {
|
||||
CloudRequirementsFragment {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
contents: fragment.contents,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::backend::BackendBundleClient;
|
||||
use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT;
|
||||
use crate::service::CloudConfigBundleService;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthManager;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
fn refresher_task_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
|
||||
static REFRESHER_TASK: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
|
||||
REFRESHER_TASK.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
pub fn cloud_config_bundle_loader(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
codex_home: PathBuf,
|
||||
) -> CloudConfigBundleLoader {
|
||||
let service = CloudConfigBundleService::new(
|
||||
auth_manager,
|
||||
Arc::new(BackendBundleClient::new(chatgpt_base_url)),
|
||||
codex_home,
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
let refresh_service = service.clone();
|
||||
let task = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await });
|
||||
let refresh_task =
|
||||
tokio::spawn(async move { refresh_service.refresh_cache_in_background().await });
|
||||
let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| {
|
||||
tracing::warn!("cloud config bundle refresher task slot was poisoned");
|
||||
err.into_inner()
|
||||
});
|
||||
if let Some(existing_task) = refresher_guard.replace(refresh_task) {
|
||||
existing_task.abort();
|
||||
}
|
||||
CloudConfigBundleLoader::new(async move {
|
||||
task.await.map_err(|err| {
|
||||
tracing::error!(error = %err, "Cloud config bundle task failed");
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Internal,
|
||||
/*status_code*/ None,
|
||||
format!("cloud config bundle load failed: {err}"),
|
||||
)
|
||||
})?
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cloud_config_bundle_loader_for_storage(
|
||||
codex_home: PathBuf,
|
||||
enable_codex_api_key_env: bool,
|
||||
credentials_store_mode: AuthCredentialsStoreMode,
|
||||
chatgpt_base_url: String,
|
||||
) -> CloudConfigBundleLoader {
|
||||
let auth_manager = AuthManager::shared(
|
||||
codex_home.clone(),
|
||||
enable_codex_api_key_env,
|
||||
credentials_store_mode,
|
||||
Some(chatgpt_base_url.clone()),
|
||||
)
|
||||
.await;
|
||||
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Signed on-disk cache for cloud config bundles.
|
||||
//!
|
||||
//! The cache is scoped to the authenticated ChatGPT user and account, has a
|
||||
//! short TTL, and is HMAC-signed so malformed or edited files fail closed.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Utc;
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use hmac::Hmac;
|
||||
use hmac::Mac;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sha2::Sha256;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1;
|
||||
pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json";
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] =
|
||||
b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e";
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] =
|
||||
&[CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY];
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct CloudConfigBundleCache {
|
||||
path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleCache {
|
||||
pub(super) fn new(codex_home: AbsolutePathBuf) -> Self {
|
||||
Self {
|
||||
path: codex_home.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub(super) async fn load(
|
||||
&self,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<CloudConfigBundleCacheSignedPayload, CacheLoadStatus> {
|
||||
let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else {
|
||||
return Err(CacheLoadStatus::AuthIdentityIncomplete);
|
||||
};
|
||||
|
||||
let bytes = match fs::read(&self.path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(CacheLoadStatus::CacheReadFailed(err.to_string()));
|
||||
}
|
||||
return Err(CacheLoadStatus::CacheFileNotFound);
|
||||
}
|
||||
};
|
||||
|
||||
let cache_file: CloudConfigBundleCacheFile = match serde_json::from_slice(&bytes) {
|
||||
Ok(cache_file) => cache_file,
|
||||
Err(err) => {
|
||||
return Err(CacheLoadStatus::CacheParseFailed(err.to_string()));
|
||||
}
|
||||
};
|
||||
let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) {
|
||||
Some(payload_bytes) => payload_bytes,
|
||||
None => {
|
||||
return Err(CacheLoadStatus::CacheParseFailed(
|
||||
"failed to serialize cache payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !verify_cache_signature(&payload_bytes, &cache_file.signature) {
|
||||
return Err(CacheLoadStatus::CacheSignatureInvalid);
|
||||
}
|
||||
if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION {
|
||||
return Err(CacheLoadStatus::CacheVersionUnsupported(
|
||||
cache_file.signed_payload.version,
|
||||
));
|
||||
}
|
||||
|
||||
let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = (
|
||||
cache_file.signed_payload.chatgpt_user_id.as_deref(),
|
||||
cache_file.signed_payload.account_id.as_deref(),
|
||||
) else {
|
||||
return Err(CacheLoadStatus::CacheIdentityIncomplete);
|
||||
};
|
||||
|
||||
if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id {
|
||||
return Err(CacheLoadStatus::CacheIdentityMismatch);
|
||||
}
|
||||
|
||||
if cache_file.signed_payload.expires_at <= Utc::now() {
|
||||
return Err(CacheLoadStatus::CacheExpired);
|
||||
}
|
||||
|
||||
Ok(cache_file.signed_payload)
|
||||
}
|
||||
|
||||
pub(super) fn log_load_status(&self, status: &CacheLoadStatus) {
|
||||
if matches!(status, CacheLoadStatus::CacheFileNotFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
let warn = matches!(
|
||||
status,
|
||||
CacheLoadStatus::CacheReadFailed(_)
|
||||
| CacheLoadStatus::CacheParseFailed(_)
|
||||
| CacheLoadStatus::CacheSignatureInvalid
|
||||
);
|
||||
|
||||
if warn {
|
||||
tracing::warn!(path = %self.path.display(), "{status}");
|
||||
} else {
|
||||
tracing::info!(path = %self.path.display(), "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn save(
|
||||
&self,
|
||||
chatgpt_user_id: Option<String>,
|
||||
account_id: Option<String>,
|
||||
bundle: CloudConfigBundle,
|
||||
) -> Result<(), CloudConfigBundleCacheError> {
|
||||
let now = Utc::now();
|
||||
let expires_at = now
|
||||
.checked_add_signed(
|
||||
ChronoDuration::from_std(CLOUD_CONFIG_BUNDLE_CACHE_TTL)
|
||||
.map_err(|_| CloudConfigBundleCacheError)?,
|
||||
)
|
||||
.ok_or(CloudConfigBundleCacheError)?;
|
||||
let signed_payload = CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at: now,
|
||||
expires_at,
|
||||
chatgpt_user_id,
|
||||
account_id,
|
||||
bundle,
|
||||
};
|
||||
let payload_bytes =
|
||||
cache_payload_bytes(&signed_payload).ok_or(CloudConfigBundleCacheError)?;
|
||||
let serialized = serde_json::to_vec_pretty(&CloudConfigBundleCacheFile {
|
||||
signature: sign_cache_payload(&payload_bytes).ok_or(CloudConfigBundleCacheError)?,
|
||||
signed_payload,
|
||||
})
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
}
|
||||
|
||||
fs::write(&self.path, serialized)
|
||||
.await
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub(super) enum CacheLoadStatus {
|
||||
#[error("Skipping cloud config bundle cache read because auth identity is incomplete.")]
|
||||
AuthIdentityIncomplete,
|
||||
#[error("Cloud config bundle cache file not found.")]
|
||||
CacheFileNotFound,
|
||||
#[error("Failed to read cloud config bundle cache: {0}.")]
|
||||
CacheReadFailed(String),
|
||||
#[error("Failed to parse cloud config bundle cache: {0}.")]
|
||||
CacheParseFailed(String),
|
||||
#[error("Cloud config bundle cache failed signature verification.")]
|
||||
CacheSignatureInvalid,
|
||||
#[error("Ignoring cloud config bundle cache because cached identity is incomplete.")]
|
||||
CacheIdentityIncomplete,
|
||||
#[error("Ignoring cloud config bundle cache for different auth identity.")]
|
||||
CacheIdentityMismatch,
|
||||
#[error("Ignoring cloud config bundle cache with unsupported version {0}.")]
|
||||
CacheVersionUnsupported(u32),
|
||||
#[error("Cloud config bundle cache expired.")]
|
||||
CacheExpired,
|
||||
#[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")]
|
||||
CacheInvalidBundle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("failed to write cloud config bundle cache")]
|
||||
pub(super) struct CloudConfigBundleCacheError;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub(super) struct CloudConfigBundleCacheFile {
|
||||
pub(super) signed_payload: CloudConfigBundleCacheSignedPayload,
|
||||
pub(super) signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub(super) struct CloudConfigBundleCacheSignedPayload {
|
||||
pub(super) version: u32,
|
||||
pub(super) cached_at: DateTime<Utc>,
|
||||
pub(super) expires_at: DateTime<Utc>,
|
||||
pub(super) chatgpt_user_id: Option<String>,
|
||||
pub(super) account_id: Option<String>,
|
||||
pub(super) bundle: CloudConfigBundle,
|
||||
}
|
||||
|
||||
pub(super) fn cache_payload_bytes(
|
||||
payload: &CloudConfigBundleCacheSignedPayload,
|
||||
) -> Option<Vec<u8>> {
|
||||
serde_json::to_vec(&payload).ok()
|
||||
}
|
||||
|
||||
pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option<String> {
|
||||
let mut mac = HmacSha256::new_from_slice(CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY).ok()?;
|
||||
mac.update(payload_bytes);
|
||||
let signature = mac.finalize().into_bytes();
|
||||
Some(BASE64_STANDARD.encode(signature))
|
||||
}
|
||||
|
||||
pub(super) fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool {
|
||||
let signature_bytes = match BASE64_STANDARD.decode(signature) {
|
||||
Ok(signature_bytes) => signature_bytes,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS
|
||||
.iter()
|
||||
.any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key))
|
||||
}
|
||||
|
||||
fn verify_cache_signature_with_key(
|
||||
payload_bytes: &[u8],
|
||||
signature_bytes: &[u8],
|
||||
key: &[u8],
|
||||
) -> bool {
|
||||
let mut mac = match HmacSha256::new_from_slice(key) {
|
||||
Ok(mac) => mac,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(payload_bytes);
|
||||
mac.verify_slice(signature_bytes).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cache_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,206 @@
|
||||
use super::*;
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigFragment;
|
||||
use codex_config::CloudConfigTomlBundle;
|
||||
use codex_config::CloudRequirementsFragment;
|
||||
use codex_config::CloudRequirementsTomlBundle;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_bundle() -> CloudConfigBundle {
|
||||
CloudConfigBundle {
|
||||
config_toml: CloudConfigTomlBundle {
|
||||
enterprise_managed: vec![CloudConfigFragment {
|
||||
id: "cfg_1".to_string(),
|
||||
name: "Base config".to_string(),
|
||||
contents: "model = \"gpt-5\"".to_string(),
|
||||
}],
|
||||
},
|
||||
requirements_toml: CloudRequirementsTomlBundle {
|
||||
enterprise_managed: vec![CloudRequirementsFragment {
|
||||
id: "req_1".to_string(),
|
||||
name: "Base requirements".to_string(),
|
||||
contents: "allowed_approval_policies = [\"never\"]".to_string(),
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_cache_file(
|
||||
signed_payload: CloudConfigBundleCacheSignedPayload,
|
||||
) -> CloudConfigBundleCacheFile {
|
||||
let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes");
|
||||
CloudConfigBundleCacheFile {
|
||||
signature: sign_cache_payload(&payload_bytes).expect("signature"),
|
||||
signed_payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_signed_payload() -> CloudConfigBundleCacheSignedPayload {
|
||||
let cached_at = Utc::now();
|
||||
CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at,
|
||||
expires_at: cached_at + ChronoDuration::minutes(30),
|
||||
chatgpt_user_id: Some("user-12345".to_string()),
|
||||
account_id: Some("account-12345".to_string()),
|
||||
bundle: test_bundle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_cache_file(cache: &CloudConfigBundleCache, cache_file: &CloudConfigBundleCacheFile) {
|
||||
std::fs::write(
|
||||
cache.path(),
|
||||
serde_json::to_vec_pretty(cache_file).expect("serialize cache"),
|
||||
)
|
||||
.expect("write cache");
|
||||
}
|
||||
|
||||
fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache {
|
||||
CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_writes_signed_payload_and_loads_for_matching_identity() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let bundle = test_bundle();
|
||||
|
||||
cache
|
||||
.save(
|
||||
Some("user-12345".to_string()),
|
||||
Some("account-12345".to_string()),
|
||||
bundle.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("save cache");
|
||||
|
||||
let cache_file: CloudConfigBundleCacheFile =
|
||||
serde_json::from_slice(&std::fs::read(cache.path()).expect("read cache"))
|
||||
.expect("parse cache");
|
||||
assert!(
|
||||
cache_file.signed_payload.expires_at
|
||||
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30)
|
||||
);
|
||||
assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at);
|
||||
assert_eq!(
|
||||
cache_file,
|
||||
signed_cache_file(CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at: cache_file.signed_payload.cached_at,
|
||||
expires_at: cache_file.signed_payload.expires_at,
|
||||
chatgpt_user_id: Some("user-12345".to_string()),
|
||||
account_id: Some("account-12345".to_string()),
|
||||
bundle,
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Ok(cache_file.signed_payload)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_missing_request_identity_before_reading_cache_file() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
cache
|
||||
.load(/*chatgpt_user_id*/ None, Some("account-12345"))
|
||||
.await,
|
||||
Err(CacheLoadStatus::AuthIdentityIncomplete)
|
||||
);
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), /*account_id*/ None).await,
|
||||
Err(CacheLoadStatus::AuthIdentityIncomplete)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_reports_missing_and_malformed_cache_files() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheFileNotFound)
|
||||
);
|
||||
|
||||
std::fs::write(cache.path(), "{").expect("write malformed cache");
|
||||
assert!(matches!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheParseFailed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_tampered_payload() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let mut cache_file = signed_cache_file(valid_signed_payload());
|
||||
cache_file
|
||||
.signed_payload
|
||||
.bundle
|
||||
.requirements_toml
|
||||
.enterprise_managed[0]
|
||||
.contents = "allowed_approval_policies = [\"on-request\"]".to_string();
|
||||
write_cache_file(&cache, &cache_file);
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheSignatureInvalid)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_cache_for_incomplete_or_different_identity() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let cache_file = signed_cache_file(valid_signed_payload());
|
||||
write_cache_file(&cache, &cache_file);
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-99999"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheIdentityMismatch)
|
||||
);
|
||||
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.chatgpt_user_id = None;
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheIdentityIncomplete)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_expired_cache() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1);
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheExpired)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_unsupported_cache_version() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.version = 2;
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheVersionUnsupported(2))
|
||||
);
|
||||
}
|
||||
+13
-2488
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
use codex_config::CloudConfigBundle;
|
||||
|
||||
const CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt";
|
||||
const CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final";
|
||||
const CLOUD_CONFIG_BUNDLE_LOAD_METRIC: &str = "codex.cloud_config_bundle.load";
|
||||
|
||||
pub(crate) fn emit_fetch_attempt_metric(
|
||||
trigger: &str,
|
||||
attempt: usize,
|
||||
outcome: &str,
|
||||
status_code: Option<u16>,
|
||||
) {
|
||||
let attempt_tag = attempt.to_string();
|
||||
let status_code_tag = status_code_tag(status_code);
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("attempt", attempt_tag),
|
||||
("outcome", outcome.to_string()),
|
||||
("status_code", status_code_tag),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_fetch_final_metric(
|
||||
trigger: &str,
|
||||
outcome: &str,
|
||||
reason: &str,
|
||||
attempt_count: usize,
|
||||
status_code: Option<u16>,
|
||||
bundle: Option<&CloudConfigBundle>,
|
||||
) {
|
||||
let attempt_count_tag = attempt_count.to_string();
|
||||
let status_code_tag = status_code_tag(status_code);
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("outcome", outcome.to_string()),
|
||||
("reason", reason.to_string()),
|
||||
("attempt_count", attempt_count_tag),
|
||||
("status_code", status_code_tag),
|
||||
("bundle_shape", bundle_shape_tag(bundle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) {
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_LOAD_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("outcome", outcome.to_string()),
|
||||
("bundle_shape", bundle_shape_tag(bundle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String {
|
||||
let Some(bundle) = bundle else {
|
||||
return "none".to_string();
|
||||
};
|
||||
|
||||
let mut sources = Vec::new();
|
||||
if !bundle.config_toml.enterprise_managed.is_empty() {
|
||||
sources.push("enterprise_config");
|
||||
}
|
||||
if !bundle.requirements_toml.enterprise_managed.is_empty() {
|
||||
sources.push("enterprise_requirements");
|
||||
}
|
||||
|
||||
if sources.is_empty() {
|
||||
"empty".to_string()
|
||||
} else {
|
||||
sources.sort_unstable();
|
||||
sources.join(",")
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code_tag(status_code: Option<u16>) -> String {
|
||||
status_code
|
||||
.map(|status_code| status_code.to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
}
|
||||
|
||||
fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) {
|
||||
if let Some(metrics) = codex_otel::global() {
|
||||
let tag_refs = tags
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
//! Cloud config bundle lifecycle orchestration.
|
||||
//!
|
||||
//! Startup loads a single shared bundle from cache or backend, and a background
|
||||
//! refresher keeps the cache warm for future app starts without changing the
|
||||
//! already-snapshotted runtime config.
|
||||
|
||||
use crate::backend::BundleClient;
|
||||
use crate::backend::BundleRequestError;
|
||||
use crate::backend::RetryableFailureKind;
|
||||
use crate::cache::CacheLoadStatus;
|
||||
use crate::cache::CloudConfigBundleCache;
|
||||
use crate::metrics::emit_fetch_attempt_metric;
|
||||
use crate::metrics::emit_fetch_final_metric;
|
||||
use crate::metrics::emit_load_metric;
|
||||
use crate::validation::validate_bundle;
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_core::util::backoff;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::RefreshTokenError;
|
||||
use codex_login::UnauthorizedRecovery;
|
||||
use codex_protocol::account::PlanType;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5;
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str =
|
||||
"Failed to load cloud config bundle (workspace-managed policies).";
|
||||
const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!(
|
||||
"Your authentication session could not be refreshed automatically. ",
|
||||
"Please log out and sign in again."
|
||||
);
|
||||
|
||||
fn auth_identity(auth: &CodexAuth) -> (Option<String>, Option<String>) {
|
||||
(auth.get_chatgpt_user_id(), auth.get_account_id())
|
||||
}
|
||||
|
||||
fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
|
||||
let Some(plan_type) = auth.account_plan_type() else {
|
||||
return false;
|
||||
};
|
||||
auth.uses_codex_backend()
|
||||
&& (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
|
||||
}
|
||||
|
||||
fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
|
||||
if bundle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
enum CachedBundleLookup {
|
||||
Hit(Option<CloudConfigBundle>),
|
||||
Miss,
|
||||
}
|
||||
|
||||
enum UnauthorizedRecoveryAction {
|
||||
RetrySameAttempt,
|
||||
RetryNextAttempt,
|
||||
}
|
||||
|
||||
pub(crate) struct CloudConfigBundleService<C> {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
client: Arc<C>,
|
||||
cache: CloudConfigBundleCache,
|
||||
codex_home: AbsolutePathBuf,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl<C> Clone for CloudConfigBundleService<C> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
auth_manager: Arc::clone(&self.auth_manager),
|
||||
client: Arc::clone(&self.client),
|
||||
cache: self.cache.clone(),
|
||||
codex_home: self.codex_home.clone(),
|
||||
timeout: self.timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> CloudConfigBundleService<C>
|
||||
where
|
||||
C: BundleClient + 'static,
|
||||
{
|
||||
pub(crate) fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
client: Arc<C>,
|
||||
codex_home: PathBuf,
|
||||
timeout: Duration,
|
||||
) -> Self {
|
||||
let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/");
|
||||
Self {
|
||||
auth_manager,
|
||||
client,
|
||||
cache: CloudConfigBundleCache::new(codex_home.clone()),
|
||||
codex_home,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_startup_bundle_with_timeout(
|
||||
&self,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]);
|
||||
let started_at = Instant::now();
|
||||
let load_result = timeout(self.timeout, self.load_startup_bundle())
|
||||
.await
|
||||
.inspect_err(|_| {
|
||||
let message = format!(
|
||||
"Timed out waiting for cloud config bundle after {}s",
|
||||
self.timeout.as_secs()
|
||||
);
|
||||
tracing::error!("{message}");
|
||||
emit_load_metric("startup", "error", /*bundle*/ None);
|
||||
})
|
||||
.map_err(|_| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Timeout,
|
||||
/*status_code*/ None,
|
||||
format!(
|
||||
"timed out waiting for cloud config bundle after {}s",
|
||||
self.timeout.as_secs()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = match load_result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
emit_load_metric("startup", "error", /*bundle*/ None);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
match result.as_ref() {
|
||||
Some(bundle) => {
|
||||
tracing::info!(
|
||||
elapsed_ms = started_at.elapsed().as_millis(),
|
||||
config_fragments = bundle.config_toml.enterprise_managed.len(),
|
||||
requirements_fragments = bundle.requirements_toml.enterprise_managed.len(),
|
||||
"Cloud config bundle load completed"
|
||||
);
|
||||
emit_load_metric("startup", "success", Some(bundle));
|
||||
}
|
||||
None => {
|
||||
tracing::info!(
|
||||
elapsed_ms = started_at.elapsed().as_millis(),
|
||||
"Cloud config bundle load completed (none)"
|
||||
);
|
||||
emit_load_metric("startup", "success", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn load_startup_bundle(
|
||||
&self,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !cloud_config_eligible_auth(&auth) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Startup prefers a valid, identity-matched cache entry. The backend is
|
||||
// only consulted on cache miss or invalid cache contents.
|
||||
let (chatgpt_user_id, account_id) = auth_identity(&auth);
|
||||
match self
|
||||
.load_valid_cached_bundle(chatgpt_user_id.as_deref(), account_id.as_deref())
|
||||
.await
|
||||
{
|
||||
CachedBundleLookup::Hit(bundle) => return Ok(bundle),
|
||||
CachedBundleLookup::Miss => {}
|
||||
}
|
||||
|
||||
self.fetch_remote_bundle_and_update_cache_with_retries(auth, "startup")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_valid_cached_bundle(
|
||||
&self,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
) -> CachedBundleLookup {
|
||||
match self.cache.load(chatgpt_user_id, account_id).await {
|
||||
Ok(signed_payload) => {
|
||||
if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) {
|
||||
tracing::warn!(
|
||||
path = %self.cache.path().display(),
|
||||
error = %err,
|
||||
"Ignoring invalid cached cloud config bundle"
|
||||
);
|
||||
self.cache
|
||||
.log_load_status(&CacheLoadStatus::CacheInvalidBundle);
|
||||
CachedBundleLookup::Miss
|
||||
} else {
|
||||
tracing::info!(
|
||||
path = %self.cache.path().display(),
|
||||
"Using cached cloud config bundle"
|
||||
);
|
||||
CachedBundleLookup::Hit(optional_bundle(signed_payload.bundle))
|
||||
}
|
||||
}
|
||||
Err(cache_load_status) => {
|
||||
self.cache.log_load_status(&cache_load_status);
|
||||
CachedBundleLookup::Miss
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_remote_bundle_and_update_cache_with_retries(
|
||||
&self,
|
||||
mut auth: CodexAuth,
|
||||
trigger: &'static str,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let mut attempt = 1;
|
||||
let mut last_status_code: Option<u16> = None;
|
||||
let mut auth_recovery = self.auth_manager.unauthorized_recovery();
|
||||
|
||||
while attempt <= CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
match self.client.get_bundle(&auth).await {
|
||||
Ok(bundle) => {
|
||||
return self
|
||||
.validate_and_cache_remote_bundle(&auth, trigger, attempt, bundle)
|
||||
.await;
|
||||
}
|
||||
Err(BundleRequestError::Retryable(status)) => {
|
||||
last_status_code = status.status_code();
|
||||
if self
|
||||
.retry_after_request_failure(trigger, attempt, status)
|
||||
.await
|
||||
{
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(BundleRequestError::Unauthorized {
|
||||
status_code,
|
||||
message,
|
||||
}) => {
|
||||
last_status_code = status_code;
|
||||
match self
|
||||
.handle_unauthorized(
|
||||
&mut auth,
|
||||
&mut auth_recovery,
|
||||
trigger,
|
||||
attempt,
|
||||
status_code,
|
||||
&message,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
UnauthorizedRecoveryAction::RetrySameAttempt => continue,
|
||||
UnauthorizedRecoveryAction::RetryNextAttempt => {
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"request_retry_exhausted",
|
||||
CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
last_status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
tracing::error!(
|
||||
path = %self.cache.path().display(),
|
||||
"{CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE}"
|
||||
);
|
||||
Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::RequestFailed,
|
||||
last_status_code,
|
||||
CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE,
|
||||
))
|
||||
}
|
||||
|
||||
async fn validate_and_cache_remote_bundle(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
bundle: CloudConfigBundle,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
emit_fetch_attempt_metric(trigger, attempt, "success", /*status_code*/ None);
|
||||
if let Err(err) = validate_bundle(&bundle, &self.codex_home) {
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"invalid_bundle",
|
||||
attempt,
|
||||
/*status_code*/ None,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let (chatgpt_user_id, account_id) = auth_identity(auth);
|
||||
if let Err(err) = self
|
||||
.cache
|
||||
.save(chatgpt_user_id, account_id, bundle.clone())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to write cloud config bundle cache"
|
||||
);
|
||||
}
|
||||
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"success",
|
||||
"none",
|
||||
attempt,
|
||||
/*status_code*/ None,
|
||||
Some(&bundle),
|
||||
);
|
||||
Ok(optional_bundle(bundle))
|
||||
}
|
||||
|
||||
async fn retry_after_request_failure(
|
||||
&self,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
status: RetryableFailureKind,
|
||||
) -> bool {
|
||||
let status_code = status.status_code();
|
||||
emit_fetch_attempt_metric(trigger, attempt, "error", status_code);
|
||||
if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
status = ?status,
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Failed to fetch cloud config bundle; retrying"
|
||||
);
|
||||
sleep(backoff(attempt as u64)).await;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_unauthorized(
|
||||
&self,
|
||||
auth: &mut CodexAuth,
|
||||
auth_recovery: &mut UnauthorizedRecovery,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
status_code: Option<u16>,
|
||||
message: &str,
|
||||
) -> Result<UnauthorizedRecoveryAction, CloudConfigBundleLoadError> {
|
||||
emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code);
|
||||
if auth_recovery.has_next() {
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Cloud config bundle request was unauthorized; attempting auth recovery"
|
||||
);
|
||||
match auth_recovery.next().await {
|
||||
Ok(_) => {
|
||||
let Some(refreshed_auth) = self.auth_manager.auth().await else {
|
||||
tracing::error!(
|
||||
"Auth recovery succeeded but no auth is available for cloud config bundle"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_missing_auth",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
|
||||
));
|
||||
};
|
||||
*auth = refreshed_auth;
|
||||
return Ok(UnauthorizedRecoveryAction::RetrySameAttempt);
|
||||
}
|
||||
Err(RefreshTokenError::Permanent(failed)) => {
|
||||
tracing::warn!(
|
||||
error = %failed,
|
||||
"Failed to recover from unauthorized cloud config bundle request"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_unrecoverable",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
failed.message,
|
||||
));
|
||||
}
|
||||
Err(RefreshTokenError::Transient(recovery_err)) => {
|
||||
if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
error = %recovery_err,
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Failed to recover from unauthorized cloud config bundle request; retrying"
|
||||
);
|
||||
sleep(backoff(attempt as u64)).await;
|
||||
}
|
||||
return Ok(UnauthorizedRecoveryAction::RetryNextAttempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
error = %message,
|
||||
"Cloud config bundle request was unauthorized and no auth recovery is available"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_unavailable",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_cache_in_background(&self) {
|
||||
loop {
|
||||
sleep(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL).await;
|
||||
match timeout(self.timeout, self.refresh_cache_once()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
"Timed out refreshing cloud config bundle cache from remote; keeping existing cache"
|
||||
);
|
||||
emit_load_metric("refresh", "error", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_cache_once(&self) -> bool {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return false;
|
||||
};
|
||||
if !cloud_config_eligible_auth(&auth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self
|
||||
.fetch_remote_bundle_and_update_cache_with_retries(auth, "refresh")
|
||||
.await
|
||||
{
|
||||
Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
path = %self.cache.path().display(),
|
||||
error = %err,
|
||||
"Failed to refresh cloud config bundle cache from remote"
|
||||
);
|
||||
emit_load_metric("refresh", "error", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "service_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigBundleLayers;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::compose_requirements;
|
||||
|
||||
pub(crate) fn validate_bundle(
|
||||
bundle: &CloudConfigBundle,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<(), CloudConfigBundleLoadError> {
|
||||
let bundle_layers =
|
||||
CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
)
|
||||
})?;
|
||||
let CloudConfigBundleLayers {
|
||||
enterprise_managed_config: _,
|
||||
enterprise_managed_requirements,
|
||||
} = bundle_layers;
|
||||
|
||||
compose_requirements(enterprise_managed_requirements).map_err(|err| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Cloud config bundle domain model and shared in-memory loader.
|
||||
//!
|
||||
//! The backend bundle groups cloud-delivered config and requirements fragments
|
||||
//! by source bucket. `CloudConfigBundleLayers` converts those raw buckets into
|
||||
//! layer entries while preserving each bucket's insertion semantics.
|
||||
|
||||
use crate::CloudConfigFragment;
|
||||
use crate::ConfigLayerEntry;
|
||||
use crate::RequirementSource;
|
||||
use crate::RequirementsLayerEntry;
|
||||
use crate::cloud_config_layers::CloudConfigLayerError;
|
||||
use crate::cloud_config_layers::cloud_config_layers_from_fragments_strict;
|
||||
use crate::cloud_config_layers_from_fragments;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::future::FutureExt;
|
||||
use futures::future::Shared;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudConfigBundle {
|
||||
pub config_toml: CloudConfigTomlBundle,
|
||||
pub requirements_toml: CloudRequirementsTomlBundle,
|
||||
}
|
||||
|
||||
impl CloudConfigBundle {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let CloudConfigBundle {
|
||||
config_toml,
|
||||
requirements_toml,
|
||||
} = self;
|
||||
let CloudConfigTomlBundle {
|
||||
enterprise_managed: config_enterprise_managed,
|
||||
} = config_toml;
|
||||
let CloudRequirementsTomlBundle {
|
||||
enterprise_managed: requirements_enterprise_managed,
|
||||
} = requirements_toml;
|
||||
|
||||
config_enterprise_managed.is_empty() && requirements_enterprise_managed.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudConfigTomlBundle {
|
||||
pub enterprise_managed: Vec<CloudConfigFragment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudRequirementsTomlBundle {
|
||||
pub enterprise_managed: Vec<CloudRequirementsFragment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudRequirementsFragment {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
/// Cloud config bundle converted into semantic layer buckets.
|
||||
///
|
||||
/// This is not a final config stack. Callers still decide where each bucket is
|
||||
/// inserted relative to local/system/user layers.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CloudConfigBundleLayers {
|
||||
/// Enterprise-managed config layers in `ConfigLayerStack` order.
|
||||
pub enterprise_managed_config: Vec<ConfigLayerEntry>,
|
||||
/// Enterprise-managed requirements layers in requirements layer merge order.
|
||||
pub enterprise_managed_requirements: Vec<RequirementsLayerEntry>,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleLayers {
|
||||
pub fn from_bundle(
|
||||
bundle: CloudConfigBundle,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<Self, CloudConfigLayerError> {
|
||||
Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ false)
|
||||
}
|
||||
|
||||
pub fn from_bundle_strict_config(
|
||||
bundle: CloudConfigBundle,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<Self, CloudConfigLayerError> {
|
||||
Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ true)
|
||||
}
|
||||
|
||||
fn from_bundle_impl(
|
||||
bundle: CloudConfigBundle,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
strict_config: bool,
|
||||
) -> Result<Self, CloudConfigLayerError> {
|
||||
// Keep this destructuring exhaustive so adding a new bundle bucket forces
|
||||
// an explicit choice about how it becomes layer data.
|
||||
let CloudConfigBundle {
|
||||
config_toml:
|
||||
CloudConfigTomlBundle {
|
||||
enterprise_managed: config_enterprise_managed,
|
||||
},
|
||||
requirements_toml:
|
||||
CloudRequirementsTomlBundle {
|
||||
enterprise_managed: requirements_enterprise_managed,
|
||||
},
|
||||
} = bundle;
|
||||
|
||||
let enterprise_managed_config = if strict_config {
|
||||
cloud_config_layers_from_fragments_strict(config_enterprise_managed, base_dir)?
|
||||
} else {
|
||||
cloud_config_layers_from_fragments(config_enterprise_managed, base_dir)?
|
||||
};
|
||||
|
||||
let mut enterprise_managed_requirements = requirements_enterprise_managed
|
||||
.into_iter()
|
||||
.map(|fragment| {
|
||||
RequirementsLayerEntry::from_toml(
|
||||
RequirementSource::EnterpriseManaged {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
},
|
||||
fragment.contents,
|
||||
)
|
||||
.with_base_dir(base_dir.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// Bundle fragments arrive highest-priority first, while requirements
|
||||
// layers are merged lowest-priority to highest-priority.
|
||||
enterprise_managed_requirements.reverse();
|
||||
|
||||
Ok(Self {
|
||||
enterprise_managed_config,
|
||||
enterprise_managed_requirements,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CloudConfigBundleLoadErrorCode {
|
||||
Auth,
|
||||
Timeout,
|
||||
RequestFailed,
|
||||
InvalidBundle,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
#[error("{message}")]
|
||||
pub struct CloudConfigBundleLoadError {
|
||||
code: CloudConfigBundleLoadErrorCode,
|
||||
message: String,
|
||||
status_code: Option<u16>,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleLoadError {
|
||||
pub fn new(
|
||||
code: CloudConfigBundleLoadErrorCode,
|
||||
status_code: Option<u16>,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
status_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> CloudConfigBundleLoadErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn status_code(&self) -> Option<u16> {
|
||||
self.status_code
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CloudConfigBundleLoader {
|
||||
fut: Shared<BoxFuture<'static, Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>>,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleLoader {
|
||||
pub fn new<F>(fut: F) -> Self
|
||||
where
|
||||
F: Future<Output = Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
{
|
||||
Self {
|
||||
fut: fut.boxed().shared(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
self.fut.clone().await
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CloudConfigBundleLoader {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CloudConfigBundleLoader").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CloudConfigBundleLoader {
|
||||
fn default() -> Self {
|
||||
Self::new(async { Ok(None) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cloud_config_bundle_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
use super::*;
|
||||
use crate::ConfigLayerSource;
|
||||
use crate::ConfigRequirementsToml;
|
||||
use crate::compose_requirements;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_future_runs_once() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = Arc::clone(&counter);
|
||||
let loader = CloudConfigBundleLoader::new(async move {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Some(CloudConfigBundle::default()))
|
||||
});
|
||||
|
||||
let (first, second) = tokio::join!(loader.get(), loader.get());
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_layers_preserve_enterprise_managed_bucket_order() {
|
||||
let tempdir = tempdir().expect("tempdir");
|
||||
let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path");
|
||||
let layers = CloudConfigBundleLayers::from_bundle(
|
||||
CloudConfigBundle {
|
||||
config_toml: CloudConfigTomlBundle {
|
||||
enterprise_managed: vec![
|
||||
CloudConfigFragment {
|
||||
id: "cfg_high".to_string(),
|
||||
name: "High config".to_string(),
|
||||
contents: "model = \"high\"".to_string(),
|
||||
},
|
||||
CloudConfigFragment {
|
||||
id: "cfg_low".to_string(),
|
||||
name: "Low config".to_string(),
|
||||
contents: "model = \"low\"".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
requirements_toml: CloudRequirementsTomlBundle {
|
||||
enterprise_managed: vec![
|
||||
CloudRequirementsFragment {
|
||||
id: "req_high".to_string(),
|
||||
name: "High requirements".to_string(),
|
||||
contents: "allowed_approval_policies = [\"on-request\"]".to_string(),
|
||||
},
|
||||
CloudRequirementsFragment {
|
||||
id: "req_low".to_string(),
|
||||
name: "Low requirements".to_string(),
|
||||
contents: "allowed_approval_policies = [\"never\"]".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
&base_dir,
|
||||
)
|
||||
.expect("bundle should be converted into layers");
|
||||
|
||||
assert_eq!(
|
||||
layers
|
||||
.enterprise_managed_config
|
||||
.iter()
|
||||
.map(|layer| layer.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "cfg_low".to_string(),
|
||||
name: "Low config".to_string(),
|
||||
},
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "cfg_high".to_string(),
|
||||
name: "High config".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
compose_requirements(layers.enterprise_managed_requirements)
|
||||
.expect("requirements should compose")
|
||||
.expect("requirements should be present")
|
||||
.into_toml(),
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_layers_can_strict_validate_enterprise_managed_config() {
|
||||
let tempdir = tempdir().expect("tempdir");
|
||||
let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path");
|
||||
let err = CloudConfigBundleLayers::from_bundle_strict_config(
|
||||
CloudConfigBundle {
|
||||
config_toml: CloudConfigTomlBundle {
|
||||
enterprise_managed: vec![CloudConfigFragment {
|
||||
id: "cfg".to_string(),
|
||||
name: "Cloud config".to_string(),
|
||||
contents: "unknown_key = true".to_string(),
|
||||
}],
|
||||
},
|
||||
requirements_toml: CloudRequirementsTomlBundle {
|
||||
enterprise_managed: Vec::new(),
|
||||
},
|
||||
},
|
||||
&base_dir,
|
||||
)
|
||||
.expect_err("strict config should reject unknown fields");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
CloudConfigLayerError::Invalid {
|
||||
fragment: crate::CloudConfigFragmentSource {
|
||||
id: "cfg".to_string(),
|
||||
name: "Cloud config".to_string(),
|
||||
},
|
||||
message: "unknown configuration field `unknown_key`".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,13 @@
|
||||
use crate::ConfigLayerEntry;
|
||||
use crate::ConfigLayerSource;
|
||||
use crate::TomlValue;
|
||||
use crate::config_toml::ConfigToml;
|
||||
use crate::loader::resolve_relative_paths_in_config_toml;
|
||||
use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
@@ -18,7 +23,7 @@ use thiserror::Error;
|
||||
/// The bundle orders fragments from highest precedence to lowest precedence.
|
||||
/// This module returns config layers in stack order, so callers can append the
|
||||
/// result between system and user config without re-sorting.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudConfigFragment {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -63,6 +68,21 @@ pub enum CloudConfigLayerError {
|
||||
pub fn cloud_config_layers_from_fragments(
|
||||
fragments: impl IntoIterator<Item = CloudConfigFragment>,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
|
||||
cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ false)
|
||||
}
|
||||
|
||||
pub(crate) fn cloud_config_layers_from_fragments_strict(
|
||||
fragments: impl IntoIterator<Item = CloudConfigFragment>,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
|
||||
cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true)
|
||||
}
|
||||
|
||||
fn cloud_config_layers_from_fragments_impl(
|
||||
fragments: impl IntoIterator<Item = CloudConfigFragment>,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
strict_config: bool,
|
||||
) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
|
||||
let mut layers = Vec::new();
|
||||
for fragment in fragments {
|
||||
@@ -73,6 +93,9 @@ pub fn cloud_config_layers_from_fragments(
|
||||
fragment: source_ref.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
if strict_config {
|
||||
validate_fragment_strictly(&source_ref, &raw_toml, &value, base_dir)?;
|
||||
}
|
||||
let resolved =
|
||||
resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| {
|
||||
CloudConfigLayerError::Invalid {
|
||||
@@ -97,6 +120,26 @@ pub fn cloud_config_layers_from_fragments(
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
fn validate_fragment_strictly(
|
||||
source_ref: &CloudConfigFragmentSource,
|
||||
raw_toml: &str,
|
||||
value: &TomlValue,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<(), CloudConfigLayerError> {
|
||||
let _guard = AbsolutePathBufGuard::new(base_dir.as_path());
|
||||
if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::<
|
||||
ConfigToml,
|
||||
>(&source_ref.to_string(), raw_toml, value.clone())
|
||||
{
|
||||
return Err(CloudConfigLayerError::Invalid {
|
||||
fragment: source_ref.clone(),
|
||||
message: config_error.message,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl From<CloudConfigLayerError> for io::Error {
|
||||
fn from(error: CloudConfigLayerError) -> Self {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error)
|
||||
|
||||
@@ -58,6 +58,27 @@ fn layers_are_returned_in_stack_order() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_layers_reject_unknown_config_fields() {
|
||||
let base_dir = base_dir();
|
||||
let err = cloud_config_layers_from_fragments_strict(
|
||||
vec![fragment("strict", "Strict layer", "unknown_key = true")],
|
||||
&base_dir,
|
||||
)
|
||||
.expect_err("strict config should reject unknown fields");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
CloudConfigLayerError::Invalid {
|
||||
fragment: CloudConfigFragmentSource {
|
||||
id: "strict".to_string(),
|
||||
name: "Strict layer".to_string(),
|
||||
},
|
||||
message: "unknown configuration field `unknown_key`".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_layers_precede_user_and_override_system() {
|
||||
let base_dir = base_dir();
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
use crate::config_requirements::ConfigRequirementsToml;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::future::FutureExt;
|
||||
use futures::future::Shared;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CloudRequirementsLoadErrorCode {
|
||||
Auth,
|
||||
Timeout,
|
||||
Parse,
|
||||
RequestFailed,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
#[error("{message}")]
|
||||
pub struct CloudRequirementsLoadError {
|
||||
code: CloudRequirementsLoadErrorCode,
|
||||
message: String,
|
||||
status_code: Option<u16>,
|
||||
}
|
||||
|
||||
impl CloudRequirementsLoadError {
|
||||
pub fn new(
|
||||
code: CloudRequirementsLoadErrorCode,
|
||||
status_code: Option<u16>,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
status_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> CloudRequirementsLoadErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn status_code(&self) -> Option<u16> {
|
||||
self.status_code
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CloudRequirementsLoader {
|
||||
fut: Shared<
|
||||
BoxFuture<'static, Result<Option<ConfigRequirementsToml>, CloudRequirementsLoadError>>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl CloudRequirementsLoader {
|
||||
pub fn new<F>(fut: F) -> Self
|
||||
where
|
||||
F: Future<Output = Result<Option<ConfigRequirementsToml>, CloudRequirementsLoadError>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
{
|
||||
Self {
|
||||
fut: fut.boxed().shared(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> Result<Option<ConfigRequirementsToml>, CloudRequirementsLoadError> {
|
||||
self.fut.clone().await
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CloudRequirementsLoader {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CloudRequirementsLoader").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CloudRequirementsLoader {
|
||||
fn default() -> Self {
|
||||
Self::new(async { Ok(None) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_future_runs_once() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = Arc::clone(&counter);
|
||||
let loader = CloudRequirementsLoader::new(async move {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Some(ConfigRequirementsToml::default()))
|
||||
});
|
||||
|
||||
let (first, second) = tokio::join!(loader.get(), loader.get());
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ pub enum RequirementSource {
|
||||
domain: String,
|
||||
key: String,
|
||||
},
|
||||
CloudRequirements,
|
||||
/// Multiple requirements layers contributed to the final value. Sources are
|
||||
/// stored highest-priority first, matching the order surfaced in errors.
|
||||
Composite {
|
||||
@@ -87,9 +86,6 @@ impl fmt::Display for RequirementSource {
|
||||
RequirementSource::MdmManagedPreferences { domain, key } => {
|
||||
write!(f, "MDM {domain}:{key}")
|
||||
}
|
||||
RequirementSource::CloudRequirements => {
|
||||
write!(f, "cloud requirements")
|
||||
}
|
||||
RequirementSource::Composite { sources } => {
|
||||
write!(f, "requirements layers: ")?;
|
||||
for (index, source) in sources.iter().enumerate() {
|
||||
@@ -1639,22 +1635,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_requirement_source_display_lists_sources_in_priority_order() {
|
||||
let source = RequirementSource::composite([
|
||||
RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.openai.codex".to_string(),
|
||||
key: "requirements_toml_base64".to_string(),
|
||||
},
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
source.to_string(),
|
||||
"requirements layers: MDM com.openai.codex:requirements_toml_base64, MDM managed_config.toml (legacy)"
|
||||
);
|
||||
}
|
||||
|
||||
fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
|
||||
let ConfigRequirementsToml {
|
||||
allowed_approval_policies,
|
||||
@@ -2035,7 +2015,7 @@ mod tests {
|
||||
fn merge_unset_fields_ignores_blank_guardian_override() {
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
target.merge_unset_fields(
|
||||
RequirementSource::CloudRequirements,
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
ConfigRequirementsToml {
|
||||
guardian_policy_config: Some(" \n\t".to_string()),
|
||||
..Default::default()
|
||||
@@ -2435,8 +2415,11 @@ allowed_approvals_reviewers = ["user"]
|
||||
|
||||
#[test]
|
||||
fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() {
|
||||
let higher_source = RequirementSource::CloudRequirements;
|
||||
let lower_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let higher_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let lower_source = RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.openai.codex".to_string(),
|
||||
key: "requirements_toml_base64".to_string(),
|
||||
};
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
|
||||
target.merge_unset_fields(
|
||||
@@ -2477,7 +2460,7 @@ allowed_approvals_reviewers = ["user"]
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
|
||||
target.merge_unset_fields(
|
||||
RequirementSource::CloudRequirements,
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
ConfigRequirementsToml {
|
||||
apps: Some(apps_requirements(&[])),
|
||||
..Default::default()
|
||||
@@ -2598,7 +2581,7 @@ allowed_approvals_reviewers = ["user"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let source_location = RequirementSource::CloudRequirements;
|
||||
let source_location = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
target.merge_unset_fields(source_location.clone(), source);
|
||||
let requirements = ConfigRequirements::try_from(target)?;
|
||||
@@ -2987,7 +2970,7 @@ allowed_approvals_reviewers = ["user"]
|
||||
|
||||
#[test]
|
||||
fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> {
|
||||
let source = RequirementSource::CloudRequirements;
|
||||
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let mut requirements_toml: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
@@ -3078,7 +3061,7 @@ allowed_approvals_reviewers = ["user"]
|
||||
|
||||
#[test]
|
||||
fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> {
|
||||
let high_source = RequirementSource::CloudRequirements;
|
||||
let high_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let mut high_precedence: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
@@ -3265,7 +3248,7 @@ statusMessage = "checking"
|
||||
fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> {
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
target.merge_unset_fields(
|
||||
RequirementSource::CloudRequirements,
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
from_str::<ConfigRequirementsToml>(
|
||||
r#"
|
||||
[hooks]
|
||||
@@ -3309,7 +3292,7 @@ command = "python3 /system/hooks/pre.py"
|
||||
);
|
||||
assert_eq!(
|
||||
target.hooks.as_ref().map(|hooks| hooks.source.clone()),
|
||||
Some(RequirementSource::CloudRequirements)
|
||||
Some(RequirementSource::LegacyManagedConfigTomlFromMdm)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3373,7 +3356,7 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
"/tmp/blocked.sock" = "deny"
|
||||
"#;
|
||||
|
||||
let source = RequirementSource::CloudRequirements;
|
||||
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let mut requirements_with_sources = ConfigRequirementsWithSources::default();
|
||||
requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?);
|
||||
|
||||
@@ -3446,7 +3429,7 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
allow_local_binding = false
|
||||
"#;
|
||||
|
||||
let source = RequirementSource::CloudRequirements;
|
||||
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let mut requirements_with_sources = ConfigRequirementsWithSources::default();
|
||||
requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod cloud_config_bundle;
|
||||
mod cloud_config_layers;
|
||||
mod cloud_requirements;
|
||||
mod config_layer_source;
|
||||
mod config_requirements;
|
||||
pub mod config_toml;
|
||||
@@ -25,19 +25,25 @@ pub mod schema;
|
||||
mod skills_config;
|
||||
mod state;
|
||||
mod strict_config;
|
||||
pub mod test_support;
|
||||
mod thread_config;
|
||||
mod tui_keymap;
|
||||
pub mod types;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
pub use cloud_config_bundle::CloudConfigBundle;
|
||||
pub use cloud_config_bundle::CloudConfigBundleLayers;
|
||||
pub use cloud_config_bundle::CloudConfigBundleLoadError;
|
||||
pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode;
|
||||
pub use cloud_config_bundle::CloudConfigBundleLoader;
|
||||
pub use cloud_config_bundle::CloudConfigTomlBundle;
|
||||
pub use cloud_config_bundle::CloudRequirementsFragment;
|
||||
pub use cloud_config_bundle::CloudRequirementsTomlBundle;
|
||||
pub use cloud_config_layers::CloudConfigFragment;
|
||||
pub use cloud_config_layers::CloudConfigFragmentSource;
|
||||
pub use cloud_config_layers::CloudConfigLayerError;
|
||||
pub use cloud_config_layers::cloud_config_layers_from_fragments;
|
||||
pub use cloud_requirements::CloudRequirementsLoadError;
|
||||
pub use cloud_requirements::CloudRequirementsLoadErrorCode;
|
||||
pub use cloud_requirements::CloudRequirementsLoader;
|
||||
pub use codex_app_server_protocol::ConfigLayerSource;
|
||||
pub use codex_protocol::config_types::ProfileV2Name;
|
||||
pub use codex_protocol::config_types::ProfileV2NameParseError;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# `codex-config` loader
|
||||
|
||||
This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce:
|
||||
This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, cloud-managed config, managed config, and MDM-managed preferences) and to produce:
|
||||
|
||||
- An **effective merged** TOML config.
|
||||
- **Per-key origins** metadata (which layer “wins” for a given key).
|
||||
@@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la
|
||||
|
||||
Exported from `codex_config::loader`:
|
||||
|
||||
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack`
|
||||
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, thread_config_loader) -> ConfigLayerStack`
|
||||
- `ConfigLayerStack`
|
||||
- `effective_config() -> toml::Value`
|
||||
- `origins() -> HashMap<String, ConfigLayerMetadata>`
|
||||
@@ -25,13 +25,19 @@ Exported from `codex_config::loader`:
|
||||
|
||||
Precedence is **top overrides bottom**:
|
||||
|
||||
1. **MDM** managed preferences (macOS only)
|
||||
2. **System** managed config (e.g. `managed_config.toml`)
|
||||
3. **Session flags** (CLI overrides, applied as dotted-path TOML writes)
|
||||
4. **User** config (`config.toml`)
|
||||
1. `LegacyManagedConfigTomlFromMdm` (MDM-delivered `managed_config.toml`, while it is being phased out)
|
||||
2. `LegacyManagedConfigTomlFromFile` (`managed_config.toml`, while it is being phased out)
|
||||
3. `SessionFlags` (CLI overrides, applied as dotted-path TOML writes)
|
||||
4. `Project` config (`.codex/config.toml`)
|
||||
5. `User` profile config, when present
|
||||
6. `User` config (`config.toml`)
|
||||
7. `EnterpriseManaged` cloud-managed config bundle layers
|
||||
8. `System` config (`/etc/codex/config.toml` or the Windows system config path)
|
||||
|
||||
Thread config entries supplied by `thread_config_loader` are inserted according
|
||||
to their translated `ConfigLayerSource` precedence.
|
||||
`ConfigLayerStack` stores layers in the opposite order internally: lowest
|
||||
precedence first, highest precedence last, so later layers override earlier
|
||||
layers when folded. Thread config entries supplied by `thread_config_loader` are
|
||||
inserted according to their translated `ConfigLayerSource` precedence.
|
||||
|
||||
Layers with a `disabled_reason` are still surfaced for UI, but are ignored when
|
||||
computing the effective config and origins metadata. This is what
|
||||
@@ -42,9 +48,8 @@ computing the effective config and origins metadata. This is what
|
||||
Most callers want the effective config plus metadata:
|
||||
|
||||
```rust
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
use codex_config::loader::load_config_layers_state;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -58,7 +63,6 @@ let layers = load_config_layers_state(
|
||||
Some(cwd),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&NoopThreadConfigLoader,
|
||||
).await?;
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::merge_requirements_with_remote_sandbox_config;
|
||||
use crate::config_requirements::ConfigRequirementsToml;
|
||||
use crate::config_requirements::ConfigRequirementsWithSources;
|
||||
use crate::RequirementsLayerEntry;
|
||||
use crate::config_requirements::RequirementSource;
|
||||
use crate::config_toml::ConfigToml;
|
||||
use crate::diagnostics::ConfigDiagnosticSource;
|
||||
@@ -75,35 +73,27 @@ fn load_managed_admin_config(
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn load_managed_admin_requirements_toml(
|
||||
target: &mut ConfigRequirementsWithSources,
|
||||
pub(crate) async fn load_managed_admin_requirements_layer(
|
||||
override_base64: Option<&str>,
|
||||
) -> io::Result<()> {
|
||||
) -> io::Result<Option<RequirementsLayerEntry>> {
|
||||
if let Some(encoded) = override_base64 {
|
||||
let trimmed = encoded.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
merge_requirements_with_remote_sandbox_config(
|
||||
target,
|
||||
managed_preferences_requirements_source(),
|
||||
parse_managed_requirements_base64(trimmed)?,
|
||||
);
|
||||
return Ok(());
|
||||
return parse_managed_requirements_base64(trimmed).map(|contents| {
|
||||
Some(RequirementsLayerEntry::from_toml(
|
||||
managed_preferences_requirements_source(),
|
||||
contents,
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
match task::spawn_blocking(load_managed_admin_requirements).await {
|
||||
Ok(result) => {
|
||||
if let Some(requirements) = result? {
|
||||
merge_requirements_with_remote_sandbox_config(
|
||||
target,
|
||||
managed_preferences_requirements_source(),
|
||||
requirements,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(result) => Ok(result?.map(|contents| {
|
||||
RequirementsLayerEntry::from_toml(managed_preferences_requirements_source(), contents)
|
||||
})),
|
||||
Err(join_err) => {
|
||||
if join_err.is_cancelled() {
|
||||
tracing::error!("Managed requirements load task was cancelled");
|
||||
@@ -115,7 +105,7 @@ pub(crate) async fn load_managed_admin_requirements_toml(
|
||||
}
|
||||
}
|
||||
|
||||
fn load_managed_admin_requirements() -> io::Result<Option<ConfigRequirementsToml>> {
|
||||
fn load_managed_admin_requirements() -> io::Result<Option<String>> {
|
||||
load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)?
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
@@ -217,13 +207,8 @@ fn validate_managed_config_toml_strictly_if_requested(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_managed_requirements_base64(encoded: &str) -> io::Result<ConfigRequirementsToml> {
|
||||
toml::from_str::<ConfigRequirementsToml>(&decode_managed_preferences_base64(encoded)?).map_err(
|
||||
|err| {
|
||||
tracing::error!("Failed to parse managed requirements TOML: {err}");
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
},
|
||||
)
|
||||
fn parse_managed_requirements_base64(encoded: &str) -> io::Result<String> {
|
||||
decode_managed_preferences_base64(encoded)
|
||||
}
|
||||
|
||||
fn decode_managed_preferences_base64(encoded: &str) -> io::Result<String> {
|
||||
|
||||
+160
-138
@@ -6,10 +6,10 @@ mod tests;
|
||||
|
||||
use self::layer_io::LoadedConfigLayers;
|
||||
use crate::CONFIG_TOML_FILE;
|
||||
use crate::CloudConfigBundleLayers;
|
||||
use crate::ProfileV2Name;
|
||||
use crate::cloud_requirements::CloudRequirementsLoader;
|
||||
use crate::config_requirements::ConfigRequirementsToml;
|
||||
use crate::config_requirements::ConfigRequirementsWithSources;
|
||||
use crate::RequirementsLayerEntry;
|
||||
use crate::compose_requirements;
|
||||
use crate::config_requirements::RequirementSource;
|
||||
use crate::config_requirements::SandboxModeRequirement;
|
||||
use crate::config_toml::ConfigToml;
|
||||
@@ -75,14 +75,16 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O
|
||||
typed_first_layer_config_error_from_entries::<ConfigToml>(layers, CONFIG_TOML_FILE).await
|
||||
}
|
||||
|
||||
/// To build up the set of admin-enforced constraints, we build up from multiple
|
||||
/// configuration layers in the following order, but a constraint defined in an
|
||||
/// earlier layer cannot be overridden by a later layer:
|
||||
/// To build up the set of admin-enforced constraints, requirements layers are
|
||||
/// collected in ascending precedence order, matching config layers, and then
|
||||
/// composed with config-style TOML merging plus field-specific handling for
|
||||
/// hooks, rules, deny-read permissions, and remote sandbox config:
|
||||
///
|
||||
/// - cloud: managed cloud requirements
|
||||
/// - admin: managed preferences (*)
|
||||
/// - system `/etc/codex/requirements.toml` (Unix) or
|
||||
/// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows)
|
||||
/// - cloud: enterprise-managed cloud config bundle requirements
|
||||
/// - legacy: managed_config.toml reinterpreted as requirements.toml
|
||||
/// - admin: managed preferences (*)
|
||||
///
|
||||
/// For backwards compatibility, we also load from
|
||||
/// `managed_config.toml` and map it to `requirements.toml`.
|
||||
@@ -92,6 +94,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O
|
||||
/// - admin: managed preferences (*)
|
||||
/// - system `/etc/codex/config.toml` (Unix) or
|
||||
/// `%ProgramData%\OpenAI\Codex\config.toml` (Windows)
|
||||
/// - cloud enterprise-managed cloud config bundle fragments
|
||||
/// - user `${CODEX_HOME}/config.toml`
|
||||
/// - profile `${CODEX_HOME}/<name>.config.toml`, when selected
|
||||
/// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted)
|
||||
@@ -114,56 +117,77 @@ pub async fn load_config_layers_state(
|
||||
cwd: Option<AbsolutePathBuf>,
|
||||
cli_overrides: &[(String, TomlValue)],
|
||||
options: impl Into<ConfigLoadOptions>,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
thread_config_loader: &dyn ThreadConfigLoader,
|
||||
) -> io::Result<ConfigLayerStack> {
|
||||
let ConfigLoadOptions {
|
||||
loader_overrides: overrides,
|
||||
strict_config,
|
||||
cloud_config_bundle,
|
||||
} = options.into();
|
||||
let active_user_profile = overrides.user_config_profile.clone();
|
||||
let ignore_managed_requirements = overrides.ignore_managed_requirements;
|
||||
let ignore_user_config = overrides.ignore_user_config;
|
||||
let ignore_user_and_project_exec_policy_rules =
|
||||
overrides.ignore_user_and_project_exec_policy_rules;
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
let mut requirements_layers = Vec::new();
|
||||
let mut bundle_requirements_layers = Vec::new();
|
||||
let mut system_requirements_layer = None;
|
||||
let managed_preferences_requirements_layer;
|
||||
let mut cloud_config_layers = Vec::new();
|
||||
|
||||
if !ignore_managed_requirements {
|
||||
if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? {
|
||||
merge_requirements_with_remote_sandbox_config(
|
||||
&mut config_requirements_toml,
|
||||
RequirementSource::CloudRequirements,
|
||||
requirements,
|
||||
);
|
||||
if let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)? {
|
||||
let cloud_config_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
let bundle_layers = if strict_config {
|
||||
CloudConfigBundleLayers::from_bundle_strict_config(bundle, &cloud_config_base_dir)?
|
||||
} else {
|
||||
CloudConfigBundleLayers::from_bundle(bundle, &cloud_config_base_dir)?
|
||||
};
|
||||
let CloudConfigBundleLayers {
|
||||
enterprise_managed_config,
|
||||
enterprise_managed_requirements,
|
||||
} = bundle_layers;
|
||||
bundle_requirements_layers = enterprise_managed_requirements;
|
||||
cloud_config_layers = enterprise_managed_config;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::load_managed_admin_requirements_toml(
|
||||
&mut config_requirements_toml,
|
||||
overrides
|
||||
.macos_managed_config_requirements_base64
|
||||
.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer(
|
||||
overrides
|
||||
.macos_managed_config_requirements_base64
|
||||
.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
managed_preferences_requirements_layer = None;
|
||||
}
|
||||
|
||||
// Honor the system requirements.toml location.
|
||||
let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?;
|
||||
load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?;
|
||||
system_requirements_layer = load_requirements_toml(fs, &requirements_toml_file).await?;
|
||||
} else {
|
||||
managed_preferences_requirements_layer = None;
|
||||
}
|
||||
|
||||
// Make a best-effort to support the legacy `managed_config.toml` as a
|
||||
// requirements specification.
|
||||
let loaded_config_layers =
|
||||
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config)
|
||||
.await?;
|
||||
if !ignore_managed_requirements {
|
||||
load_requirements_from_legacy_scheme(
|
||||
&mut config_requirements_toml,
|
||||
requirements_layers.extend(system_requirements_layer);
|
||||
requirements_layers.extend(bundle_requirements_layers);
|
||||
// Continue to support the legacy `managed_config.toml` locations as
|
||||
// requirements layers for backwards compatibility.
|
||||
requirements_layers.extend(requirements_layers_from_legacy_scheme(
|
||||
loaded_config_layers.clone(),
|
||||
)
|
||||
.await?;
|
||||
)?);
|
||||
requirements_layers.extend(managed_preferences_requirements_layer);
|
||||
}
|
||||
|
||||
let config_requirements_toml = compose_requirements(requirements_layers)?.unwrap_or_default();
|
||||
|
||||
let thread_config_context = ThreadConfigContext {
|
||||
thread_id: None,
|
||||
cwd: cwd.clone(),
|
||||
@@ -210,6 +234,7 @@ pub async fn load_config_layers_state(
|
||||
)
|
||||
.await?;
|
||||
layers.push(system_layer);
|
||||
layers.extend(cloud_config_layers);
|
||||
|
||||
// Add the base user config layer. When profile-v2 is selected, add the
|
||||
// profile config as a second user layer on top so the profile only needs to
|
||||
@@ -535,15 +560,12 @@ fn validate_cli_overrides_strictly(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If available, apply requirements from the platform system
|
||||
/// `requirements.toml` location to `config_requirements_toml` by filling in
|
||||
/// any unset fields.
|
||||
#[doc(hidden)]
|
||||
/// If available, load requirements from the platform's system `requirements.toml`
|
||||
/// location as a requirements layer.
|
||||
pub async fn load_requirements_toml(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
config_requirements_toml: &mut ConfigRequirementsWithSources,
|
||||
requirements_toml_file: &AbsolutePathBuf,
|
||||
) -> io::Result<()> {
|
||||
) -> io::Result<Option<RequirementsLayerEntry>> {
|
||||
match fs
|
||||
.read_file_text(requirements_toml_file, /*sandbox*/ None)
|
||||
.await
|
||||
@@ -558,39 +580,31 @@ pub async fn load_requirements_toml(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let _guard = AbsolutePathBufGuard::new(requirements_parent.as_path());
|
||||
let requirements_config: ConfigRequirementsToml =
|
||||
toml::from_str(&contents).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Error parsing requirements file {}: {e}",
|
||||
requirements_toml_file.as_path().display(),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
merge_requirements_with_remote_sandbox_config(
|
||||
config_requirements_toml,
|
||||
RequirementSource::SystemRequirementsToml {
|
||||
file: requirements_toml_file.clone(),
|
||||
},
|
||||
requirements_config,
|
||||
);
|
||||
let base_dir = AbsolutePathBuf::from_absolute_path(requirements_parent)?;
|
||||
Ok(Some(
|
||||
RequirementsLayerEntry::from_toml(
|
||||
RequirementSource::SystemRequirementsToml {
|
||||
file: requirements_toml_file.clone(),
|
||||
},
|
||||
contents,
|
||||
)
|
||||
.with_base_dir(base_dir),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() != io::ErrorKind::NotFound {
|
||||
return Err(io::Error::new(
|
||||
Err(io::Error::new(
|
||||
e.kind(),
|
||||
format!(
|
||||
"Failed to read requirements file {}: {e}",
|
||||
requirements_toml_file.as_path().display(),
|
||||
),
|
||||
));
|
||||
))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -704,31 +718,33 @@ fn windows_program_data_dir_from_known_folder() -> io::Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn load_requirements_from_legacy_scheme(
|
||||
config_requirements_toml: &mut ConfigRequirementsWithSources,
|
||||
fn requirements_layers_from_legacy_scheme(
|
||||
loaded_config_layers: LoadedConfigLayers,
|
||||
) -> io::Result<()> {
|
||||
// In this implementation, earlier layers cannot be overwritten by later
|
||||
// layers, so list managed_config_from_mdm first because it has the highest
|
||||
// precedence.
|
||||
) -> io::Result<Vec<RequirementsLayerEntry>> {
|
||||
// List the file-backed legacy layer first because requirements layers are
|
||||
// composed lowest-precedence to highest-precedence, and MDM has higher
|
||||
// precedence than the legacy managed_config.toml file.
|
||||
let LoadedConfigLayers {
|
||||
managed_config,
|
||||
managed_config_from_mdm,
|
||||
} = loaded_config_layers;
|
||||
|
||||
for (source, config) in managed_config_from_mdm
|
||||
.map(|config| {
|
||||
(
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
config.managed_config,
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.chain(managed_config.map(|c| {
|
||||
let layer_count =
|
||||
usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some());
|
||||
let mut layers = Vec::with_capacity(layer_count);
|
||||
for (source, config) in managed_config
|
||||
.map(|c| {
|
||||
(
|
||||
RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file },
|
||||
c.managed_config,
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.chain(managed_config_from_mdm.map(|config| {
|
||||
(
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
config.managed_config,
|
||||
)
|
||||
}))
|
||||
{
|
||||
let legacy_config: LegacyManagedConfigToml =
|
||||
@@ -739,26 +755,56 @@ async fn load_requirements_from_legacy_scheme(
|
||||
)
|
||||
})?;
|
||||
|
||||
merge_requirements_with_remote_sandbox_config(
|
||||
config_requirements_toml,
|
||||
layers.push(RequirementsLayerEntry::from_toml_value(
|
||||
source,
|
||||
ConfigRequirementsToml::from(legacy_config),
|
||||
);
|
||||
legacy_requirements_to_toml_value(legacy_config)?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
pub(super) fn merge_requirements_with_remote_sandbox_config(
|
||||
target: &mut ConfigRequirementsWithSources,
|
||||
source: RequirementSource,
|
||||
mut requirements: ConfigRequirementsToml,
|
||||
) {
|
||||
if requirements.remote_sandbox_config.is_some() {
|
||||
let host_name = crate::host_name();
|
||||
requirements.apply_remote_sandbox_config(host_name.as_deref());
|
||||
fn legacy_requirements_to_toml_value(legacy: LegacyManagedConfigToml) -> io::Result<TomlValue> {
|
||||
let LegacyManagedConfigToml {
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_mode,
|
||||
} = legacy;
|
||||
let mut table = toml::map::Map::new();
|
||||
if let Some(approval_policy) = approval_policy {
|
||||
table.insert(
|
||||
"allowed_approval_policies".to_string(),
|
||||
toml_value_from_serializable(vec![approval_policy])?,
|
||||
);
|
||||
}
|
||||
target.merge_unset_fields(source, requirements);
|
||||
if let Some(approvals_reviewer) = approvals_reviewer {
|
||||
let mut allowed_reviewers = vec![approvals_reviewer];
|
||||
if approvals_reviewer == ApprovalsReviewer::AutoReview {
|
||||
allowed_reviewers.push(ApprovalsReviewer::User);
|
||||
}
|
||||
table.insert(
|
||||
"allowed_approvals_reviewers".to_string(),
|
||||
toml_value_from_serializable(allowed_reviewers)?,
|
||||
);
|
||||
}
|
||||
if let Some(sandbox_mode) = sandbox_mode {
|
||||
let required_mode: SandboxModeRequirement = sandbox_mode.into();
|
||||
// Allowing read-only is a requirement for Codex to function correctly.
|
||||
// So in this backfill path, we append read-only if it's not already specified.
|
||||
let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly];
|
||||
if required_mode != SandboxModeRequirement::ReadOnly {
|
||||
allowed_modes.push(required_mode);
|
||||
}
|
||||
table.insert(
|
||||
"allowed_sandbox_modes".to_string(),
|
||||
toml_value_from_serializable(allowed_modes)?,
|
||||
);
|
||||
}
|
||||
Ok(TomlValue::Table(table))
|
||||
}
|
||||
|
||||
fn toml_value_from_serializable<T: serde::Serialize>(value: T) -> io::Result<TomlValue> {
|
||||
TomlValue::try_from(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
|
||||
}
|
||||
|
||||
struct ProjectTrustContext {
|
||||
@@ -1342,39 +1388,6 @@ struct LegacyManagedConfigToml {
|
||||
sandbox_mode: Option<SandboxMode>,
|
||||
}
|
||||
|
||||
impl From<LegacyManagedConfigToml> for ConfigRequirementsToml {
|
||||
fn from(legacy: LegacyManagedConfigToml) -> Self {
|
||||
let mut config_requirements_toml = ConfigRequirementsToml::default();
|
||||
|
||||
let LegacyManagedConfigToml {
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_mode,
|
||||
} = legacy;
|
||||
if let Some(approval_policy) = approval_policy {
|
||||
config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]);
|
||||
}
|
||||
if let Some(approvals_reviewer) = approvals_reviewer {
|
||||
let mut allowed_reviewers = vec![approvals_reviewer];
|
||||
if approvals_reviewer == ApprovalsReviewer::AutoReview {
|
||||
allowed_reviewers.push(ApprovalsReviewer::User);
|
||||
}
|
||||
config_requirements_toml.allowed_approvals_reviewers = Some(allowed_reviewers);
|
||||
}
|
||||
if let Some(sandbox_mode) = sandbox_mode {
|
||||
let required_mode: SandboxModeRequirement = sandbox_mode.into();
|
||||
// Allowing read-only is a requirement for Codex to function correctly.
|
||||
// So in this backfill path, we append read-only if it's not already specified.
|
||||
let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly];
|
||||
if required_mode != SandboxModeRequirement::ReadOnly {
|
||||
allowed_modes.push(required_mode);
|
||||
}
|
||||
config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes);
|
||||
}
|
||||
config_requirements_toml
|
||||
}
|
||||
}
|
||||
|
||||
// Cannot name this `mod tests` because of tests.rs in this folder.
|
||||
#[cfg(test)]
|
||||
mod unit_tests {
|
||||
@@ -1421,54 +1434,63 @@ foo = "xyzzy"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() {
|
||||
fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() -> io::Result<()> {
|
||||
let legacy = LegacyManagedConfigToml {
|
||||
approval_policy: None,
|
||||
approvals_reviewer: None,
|
||||
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
|
||||
};
|
||||
|
||||
let requirements = ConfigRequirementsToml::from(legacy);
|
||||
|
||||
assert_eq!(
|
||||
requirements.allowed_sandbox_modes,
|
||||
Some(vec![
|
||||
SandboxModeRequirement::ReadOnly,
|
||||
SandboxModeRequirement::WorkspaceWrite
|
||||
])
|
||||
legacy_requirements_to_toml_value(legacy)?,
|
||||
TomlValue::Table(toml::map::Map::from_iter([(
|
||||
"allowed_sandbox_modes".to_string(),
|
||||
TomlValue::Array(vec![
|
||||
TomlValue::String("read-only".to_string()),
|
||||
TomlValue::String("workspace-write".to_string()),
|
||||
]),
|
||||
)]))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() {
|
||||
fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() -> io::Result<()> {
|
||||
let legacy = LegacyManagedConfigToml {
|
||||
approval_policy: None,
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
sandbox_mode: None,
|
||||
};
|
||||
|
||||
let requirements = ConfigRequirementsToml::from(legacy);
|
||||
|
||||
assert_eq!(
|
||||
requirements.allowed_approvals_reviewers,
|
||||
Some(vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User,])
|
||||
legacy_requirements_to_toml_value(legacy)?,
|
||||
TomlValue::Table(toml::map::Map::from_iter([(
|
||||
"allowed_approvals_reviewers".to_string(),
|
||||
TomlValue::Array(vec![
|
||||
TomlValue::String("guardian_subagent".to_string()),
|
||||
TomlValue::String("user".to_string()),
|
||||
]),
|
||||
)]))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() {
|
||||
fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() -> io::Result<()> {
|
||||
let legacy = LegacyManagedConfigToml {
|
||||
approval_policy: None,
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
sandbox_mode: None,
|
||||
};
|
||||
|
||||
let requirements = ConfigRequirementsToml::from(legacy);
|
||||
|
||||
assert_eq!(
|
||||
requirements.allowed_approvals_reviewers,
|
||||
Some(vec![ApprovalsReviewer::User])
|
||||
legacy_requirements_to_toml_value(legacy)?,
|
||||
TomlValue::Table(toml::map::Map::from_iter([(
|
||||
"allowed_approvals_reviewers".to_string(),
|
||||
TomlValue::Array(vec![TomlValue::String("user".to_string())]),
|
||||
)]))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -128,7 +128,6 @@ model = "gpt-work"
|
||||
/*cwd*/ None,
|
||||
&[],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&crate::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -187,7 +186,6 @@ model = "gpt-main"
|
||||
/*cwd*/ None,
|
||||
&[],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&crate::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -244,7 +242,6 @@ model = "gpt-dev"
|
||||
/*cwd*/ None,
|
||||
&[],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&crate::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -30,11 +30,18 @@ impl PermissionsToml {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Resolve `profile_name` and all of its `extends` ancestors into one TOML
|
||||
/// profile.
|
||||
///
|
||||
/// Parent profiles are merged before their children, so child keys override
|
||||
/// matching parent keys before callers compile the profile into runtime
|
||||
/// permissions. The returned profile keeps the selected profile's
|
||||
/// declaration metadata, such as `description` and `extends`.
|
||||
pub fn resolve_profile<F>(
|
||||
&self,
|
||||
profile_name: &str,
|
||||
mut parent_profile: F,
|
||||
) -> Result<ResolvedPermissionProfileToml, PermissionProfileResolutionError>
|
||||
) -> Result<PermissionProfileToml, PermissionProfileResolutionError>
|
||||
where
|
||||
F: FnMut(&str) -> Option<PermissionProfileToml>,
|
||||
{
|
||||
@@ -96,10 +103,7 @@ impl PermissionsToml {
|
||||
.into_iter()
|
||||
.rev()
|
||||
.try_fold(profile, merge_permission_profiles)?;
|
||||
return Ok(ResolvedPermissionProfileToml {
|
||||
profile,
|
||||
inherited_profile_names: profile_names.into_iter().skip(1).collect(),
|
||||
});
|
||||
return Ok(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,17 +118,6 @@ pub struct PermissionProfileToml {
|
||||
pub network: Option<NetworkToml>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedPermissionProfileToml {
|
||||
pub profile: PermissionProfileToml,
|
||||
/// Names of profiles inherited while resolving `profile`, ordered from the
|
||||
/// selected profile's direct parent to the farthest ancestor.
|
||||
///
|
||||
/// Callers use this to preserve which built-in baseline contributed the
|
||||
/// resolved permissions after the parent profiles have been merged away.
|
||||
pub inherited_profile_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PermissionProfileResolutionError {
|
||||
#[error("default_permissions refers to undefined profile `{profile_name}`")]
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::fingerprint::record_origins;
|
||||
use super::fingerprint::version_for_toml;
|
||||
use super::key_aliases::normalized_with_key_aliases;
|
||||
use super::merge::merge_toml_values;
|
||||
use crate::CloudConfigBundleLoader;
|
||||
use crate::ProfileV2Name;
|
||||
use codex_app_server_protocol::ConfigLayer;
|
||||
use codex_app_server_protocol::ConfigLayerMetadata;
|
||||
@@ -21,6 +22,7 @@ use toml::Value as TomlValue;
|
||||
pub struct ConfigLoadOptions {
|
||||
pub loader_overrides: LoaderOverrides,
|
||||
pub strict_config: bool,
|
||||
pub cloud_config_bundle: CloudConfigBundleLoader,
|
||||
}
|
||||
|
||||
impl From<LoaderOverrides> for ConfigLoadOptions {
|
||||
@@ -28,6 +30,7 @@ impl From<LoaderOverrides> for ConfigLoadOptions {
|
||||
Self {
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,8 +474,8 @@ impl ConfigLayerStack {
|
||||
|
||||
/// Returns the merged config-layer view.
|
||||
///
|
||||
/// This only merges ordinary config layers and does not apply requirements
|
||||
/// such as cloud requirements.
|
||||
/// This only merges ordinary config layers. Requirements are composed and
|
||||
/// tracked separately.
|
||||
pub fn effective_config(&self) -> TomlValue {
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in self.get_layers(
|
||||
|
||||
@@ -37,7 +37,6 @@ pub(crate) fn config_error_from_ignored_toml_value_fields<T: DeserializeOwned>(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name<T: DeserializeOwned>(
|
||||
source_name: &str,
|
||||
contents: &str,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Test-only helpers exposed for cross-crate integration tests.
|
||||
//!
|
||||
//! Production code should not depend on this module.
|
||||
|
||||
use crate::CloudConfigBundle;
|
||||
use crate::CloudConfigBundleLoader;
|
||||
use crate::CloudConfigFragment;
|
||||
use crate::CloudRequirementsFragment;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CloudConfigBundleFixture {
|
||||
bundle: CloudConfigBundle,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleFixture {
|
||||
pub fn enterprise_requirement(contents: impl Into<String>) -> Self {
|
||||
Self::default().add_enterprise_requirement(contents)
|
||||
}
|
||||
|
||||
pub fn enterprise_config(contents: impl Into<String>) -> Self {
|
||||
Self::default().add_enterprise_config(contents)
|
||||
}
|
||||
|
||||
pub fn loader_with_enterprise_requirement(
|
||||
contents: impl Into<String>,
|
||||
) -> CloudConfigBundleLoader {
|
||||
Self::enterprise_requirement(contents).into_loader()
|
||||
}
|
||||
|
||||
pub fn loader_with_enterprise_config(contents: impl Into<String>) -> CloudConfigBundleLoader {
|
||||
Self::enterprise_config(contents).into_loader()
|
||||
}
|
||||
|
||||
pub fn add_enterprise_requirement(mut self, contents: impl Into<String>) -> Self {
|
||||
let index = self.bundle.requirements_toml.enterprise_managed.len() + 1;
|
||||
self.bundle
|
||||
.requirements_toml
|
||||
.enterprise_managed
|
||||
.push(CloudRequirementsFragment {
|
||||
id: format!("req_{index}"),
|
||||
name: if index == 1 {
|
||||
"Base requirements".to_string()
|
||||
} else {
|
||||
format!("Requirements {index}")
|
||||
},
|
||||
contents: contents.into(),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_enterprise_config(mut self, contents: impl Into<String>) -> Self {
|
||||
let index = self.bundle.config_toml.enterprise_managed.len() + 1;
|
||||
self.bundle
|
||||
.config_toml
|
||||
.enterprise_managed
|
||||
.push(CloudConfigFragment {
|
||||
id: format!("cfg_{index}"),
|
||||
name: if index == 1 {
|
||||
"Base config".to_string()
|
||||
} else {
|
||||
format!("Config {index}")
|
||||
},
|
||||
contents: contents.into(),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn into_bundle(self) -> CloudConfigBundle {
|
||||
self.bundle
|
||||
}
|
||||
|
||||
pub fn into_loader(self) -> CloudConfigBundleLoader {
|
||||
let bundle = self.into_bundle();
|
||||
CloudConfigBundleLoader::new(async move { Ok(Some(bundle)) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "test_support_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,25 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn adds_enterprise_requirements_in_order() {
|
||||
let bundle = CloudConfigBundleFixture::enterprise_requirement("first")
|
||||
.add_enterprise_requirement("second")
|
||||
.into_bundle();
|
||||
|
||||
assert_eq!(
|
||||
bundle.requirements_toml.enterprise_managed,
|
||||
vec![
|
||||
CloudRequirementsFragment {
|
||||
id: "req_1".to_string(),
|
||||
name: "Base requirements".to_string(),
|
||||
contents: "first".to_string(),
|
||||
},
|
||||
CloudRequirementsFragment {
|
||||
id: "req_2".to_string(),
|
||||
name: "Requirements 2".to_string(),
|
||||
contents: "second".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ use std::path::Path;
|
||||
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::PluginsConfigInput;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
use codex_config::loader::load_config_layers_state;
|
||||
@@ -106,7 +105,6 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin
|
||||
Some(cwd),
|
||||
&[],
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -755,6 +755,7 @@ async fn handle_request_permissions(
|
||||
) {
|
||||
let call_id = event.call_id;
|
||||
let args = RequestPermissionsArgs {
|
||||
environment_id: event.environment_id,
|
||||
reason: event.reason,
|
||||
permissions: event.permissions,
|
||||
};
|
||||
|
||||
@@ -183,9 +183,11 @@ async fn run_codex_thread_interactive_respects_pre_cancelled_spawn() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
|
||||
let (parent_session, parent_ctx, rx_events) =
|
||||
let (parent_session, mut parent_ctx, rx_events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
*parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
let parent_ctx_mut = Arc::get_mut(&mut parent_ctx).expect("single turn context ref");
|
||||
parent_ctx_mut.environments.turn_environments[0].environment_id = "remote".to_string();
|
||||
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
@@ -228,6 +230,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
|
||||
RequestPermissionsEvent {
|
||||
call_id: request_call_id,
|
||||
turn_id: "child-turn-1".to_string(),
|
||||
environment_id: Some("remote".to_string()),
|
||||
started_at_ms: 0,
|
||||
reason: Some("need access".to_string()),
|
||||
permissions: RequestPermissionProfile {
|
||||
@@ -252,6 +255,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
|
||||
panic!("expected RequestPermissions event");
|
||||
};
|
||||
assert_eq!(request.call_id, call_id.clone());
|
||||
assert_eq!(request.environment_id.as_deref(), Some("remote"));
|
||||
assert_eq!(request.cwd, Some(delegated_cwd));
|
||||
|
||||
parent_session
|
||||
|
||||
@@ -240,6 +240,7 @@ async fn run_compact_task_inner_impl(
|
||||
continue;
|
||||
}
|
||||
sess.set_total_tokens_full(turn_context.as_ref()).await;
|
||||
sess.track_turn_codex_error(turn_context.as_ref(), &e);
|
||||
let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None));
|
||||
sess.send_event(&turn_context, event).await;
|
||||
return Err(e);
|
||||
@@ -257,6 +258,7 @@ async fn run_compact_task_inner_impl(
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
} else {
|
||||
sess.track_turn_codex_error(turn_context.as_ref(), &e);
|
||||
let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None));
|
||||
sess.send_event(&turn_context, event).await;
|
||||
return Err(e);
|
||||
|
||||
@@ -138,6 +138,7 @@ async fn run_remote_compact_task_inner(
|
||||
}
|
||||
attempt.track(sess.as_ref(), status, error.clone()).await;
|
||||
if let Err(err) = result {
|
||||
sess.track_turn_codex_error(turn_context, &err);
|
||||
let event = EventMsg::Error(
|
||||
err.to_error_event(Some("Error running remote compact task".to_string())),
|
||||
);
|
||||
|
||||
@@ -155,6 +155,7 @@ async fn run_remote_compact_task_inner(
|
||||
}
|
||||
attempt.track(sess.as_ref(), status, error.clone()).await;
|
||||
if let Err(err) = result {
|
||||
sess.track_turn_codex_error(turn_context, &err);
|
||||
let event = EventMsg::Error(
|
||||
err.to_error_event(Some("Error running remote compact task".to_string())),
|
||||
);
|
||||
|
||||
@@ -3,27 +3,31 @@ use crate::config::ConfigOverrides;
|
||||
use crate::config::ConstraintError;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::CloudRequirementsLoadError;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::ConfigError;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::ConfigLoadError;
|
||||
use codex_config::ConfigLoadOptions;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::ConfigRequirementsWithSources;
|
||||
use codex_config::FilesystemDenyReadPattern;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::RequirementSource;
|
||||
use codex_config::RequirementsLayerEntry;
|
||||
use codex_config::SessionThreadConfig;
|
||||
use codex_config::StaticThreadConfigLoader;
|
||||
use codex_config::ThreadConfigSource;
|
||||
use codex_config::compose_requirements;
|
||||
use codex_config::config_error_from_ignored_toml_fields;
|
||||
use codex_config::config_error_from_toml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::config_toml::ProjectConfig;
|
||||
use codex_config::loader::load_config_layers_state;
|
||||
use codex_config::loader::load_requirements_toml;
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
@@ -46,6 +50,22 @@ fn config_error_from_io(err: &std::io::Error) -> &ConfigError {
|
||||
.expect("expected ConfigLoadError")
|
||||
}
|
||||
|
||||
fn cloud_config_bundle_requirement_source() -> RequirementSource {
|
||||
RequirementSource::EnterpriseManaged {
|
||||
id: "req_1".to_string(),
|
||||
name: "Base requirements".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_single_requirements_toml(
|
||||
requirements_file: &AbsolutePathBuf,
|
||||
) -> anyhow::Result<ConfigRequirementsWithSources> {
|
||||
let layer = load_requirements_toml(LOCAL_FS.as_ref(), requirements_file)
|
||||
.await?
|
||||
.expect("requirements.toml should load");
|
||||
Ok(compose_requirements(vec![layer])?.expect("requirements should be present"))
|
||||
}
|
||||
|
||||
async fn make_config_for_test(
|
||||
codex_home: &Path,
|
||||
project_path: &Path,
|
||||
@@ -147,7 +167,6 @@ invalid = ["#;
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -179,7 +198,6 @@ invalid = ["#,
|
||||
ignore_user_config: true,
|
||||
..Default::default()
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -210,7 +228,6 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io:
|
||||
ignore_user_and_project_exec_policy_rules: true,
|
||||
..Default::default()
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -236,7 +253,6 @@ invalid = ["#;
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -288,7 +304,6 @@ async fn top_level_allow_managed_hooks_only_in_user_config_does_not_enable_requi
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -323,7 +338,6 @@ command = "python3 /tmp/user-hook.py"
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -533,7 +547,6 @@ extra = true
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -567,7 +580,6 @@ async fn returns_empty_when_all_layers_missing() {
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -644,7 +656,6 @@ approval_policy = "on-failure"
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -707,7 +718,6 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> {
|
||||
Some(cwd),
|
||||
&[("features.plugins".to_string(), TomlValue::Boolean(true))],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session(SessionThreadConfig {
|
||||
features: BTreeMap::from([("plugins".to_string(), false)]),
|
||||
..Default::default()
|
||||
@@ -787,7 +797,6 @@ flag = false
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -891,7 +900,6 @@ allowed_sandbox_modes = ["read-only"]
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -953,7 +961,6 @@ allowed_approval_policies = ["never"]
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -990,14 +997,8 @@ personality = true
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requirements_file = AbsolutePathBuf::try_from(requirements_file)?;
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
load_requirements_toml(
|
||||
LOCAL_FS.as_ref(),
|
||||
&mut config_requirements_toml,
|
||||
&requirements_file,
|
||||
)
|
||||
.await?;
|
||||
let config_requirements_toml =
|
||||
load_single_requirements_toml(&AbsolutePathBuf::try_from(requirements_file)?).await?;
|
||||
|
||||
assert_eq!(
|
||||
config_requirements_toml
|
||||
@@ -1073,7 +1074,7 @@ personality = true
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::Result<()> {
|
||||
async fn mdm_requirements_take_precedence_over_cloud_config_bundle() -> anyhow::Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir()?;
|
||||
@@ -1091,49 +1092,34 @@ allowed_approval_policies = ["on-request"]
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_permissions: None,
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
allow_appshots: None,
|
||||
computer_use: None,
|
||||
windows: None,
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
permissions: None,
|
||||
guardian_policy_config: None,
|
||||
}))
|
||||
}),
|
||||
ConfigLoadOptions {
|
||||
loader_overrides,
|
||||
cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approval_policies = ["never"]"#,
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
state.requirements().approval_policy.value(),
|
||||
AskForApproval::Never
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.requirements()
|
||||
.approval_policy
|
||||
.can_set(&AskForApproval::OnRequest),
|
||||
.can_set(&AskForApproval::Never),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "approval_policy",
|
||||
candidate: "OnRequest".into(),
|
||||
allowed: "[Never]".into(),
|
||||
requirement_source: RequirementSource::CloudRequirements,
|
||||
candidate: "Never".into(),
|
||||
allowed: "[OnRequest]".into(),
|
||||
requirement_source: RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.openai.codex".to_string(),
|
||||
key: "requirements_toml_base64".to_string(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1141,7 +1127,7 @@ allowed_approval_policies = ["on-request"]
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> {
|
||||
async fn cloud_config_bundle_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let requirements_file = tmp.path().join("requirements.toml");
|
||||
tokio::fs::write(
|
||||
@@ -1152,38 +1138,20 @@ allowed_approval_policies = ["on-request"]
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
config_requirements_toml.merge_unset_fields(
|
||||
RequirementSource::CloudRequirements,
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_permissions: None,
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
allow_appshots: None,
|
||||
computer_use: None,
|
||||
windows: None,
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
permissions: None,
|
||||
guardian_policy_config: None,
|
||||
},
|
||||
);
|
||||
load_requirements_toml(
|
||||
let system_layer = load_requirements_toml(
|
||||
LOCAL_FS.as_ref(),
|
||||
&mut config_requirements_toml,
|
||||
&AbsolutePathBuf::try_from(requirements_file)?,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
.expect("system requirements should load");
|
||||
let config_requirements_toml = compose_requirements(vec![
|
||||
system_layer,
|
||||
RequirementsLayerEntry::from_toml(
|
||||
cloud_config_bundle_requirement_source(),
|
||||
r#"allowed_approval_policies = ["never"]"#,
|
||||
),
|
||||
])?
|
||||
.expect("requirements should be present");
|
||||
|
||||
assert_eq!(
|
||||
config_requirements_toml
|
||||
@@ -1197,7 +1165,7 @@ allowed_approval_policies = ["on-request"]
|
||||
.allowed_approval_policies
|
||||
.as_ref()
|
||||
.map(|sourced| sourced.source.clone()),
|
||||
Some(RequirementSource::CloudRequirements)
|
||||
Some(cloud_config_bundle_requirement_source())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -1217,22 +1185,21 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cloud_source = RequirementSource::CloudRequirements;
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
config_requirements_toml.merge_unset_fields(
|
||||
cloud_source.clone(),
|
||||
toml::from_str(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#,
|
||||
)?,
|
||||
);
|
||||
load_requirements_toml(
|
||||
let cloud_source = cloud_config_bundle_requirement_source();
|
||||
let system_layer = load_requirements_toml(
|
||||
LOCAL_FS.as_ref(),
|
||||
&mut config_requirements_toml,
|
||||
&AbsolutePathBuf::try_from(requirements_file)?,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
.expect("system requirements should load");
|
||||
let config_requirements_toml = compose_requirements(vec![
|
||||
system_layer,
|
||||
RequirementsLayerEntry::from_toml(
|
||||
cloud_source.clone(),
|
||||
r#"allowed_sandbox_modes = ["read-only"]"#,
|
||||
),
|
||||
])?
|
||||
.expect("requirements should be present");
|
||||
let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
@@ -1266,13 +1233,7 @@ deny_read = ["./sensitive", "../shared/secret.txt"]
|
||||
.await?;
|
||||
|
||||
let requirements_file = AbsolutePathBuf::try_from(requirements_file)?;
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
load_requirements_toml(
|
||||
LOCAL_FS.as_ref(),
|
||||
&mut config_requirements_toml,
|
||||
&requirements_file,
|
||||
)
|
||||
.await?;
|
||||
let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?;
|
||||
|
||||
let permissions = config_requirements_toml
|
||||
.permissions
|
||||
@@ -1320,13 +1281,7 @@ deny_read = ["./sensitive/**/*.txt"]
|
||||
.await?;
|
||||
|
||||
let requirements_file = AbsolutePathBuf::try_from(requirements_file)?;
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
load_requirements_toml(
|
||||
LOCAL_FS.as_ref(),
|
||||
&mut config_requirements_toml,
|
||||
&requirements_file,
|
||||
)
|
||||
.await?;
|
||||
let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?;
|
||||
|
||||
let permissions = config_requirements_toml
|
||||
.permissions
|
||||
@@ -1360,44 +1315,26 @@ deny_read = ["./sensitive/**/*.txt"]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> {
|
||||
async fn load_config_layers_includes_cloud_config_bundle() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_permissions: None,
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
allow_appshots: None,
|
||||
computer_use: None,
|
||||
windows: None,
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
permissions: None,
|
||||
guardian_policy_config: None,
|
||||
};
|
||||
let expected = requirements.clone();
|
||||
let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) });
|
||||
let requirements = r#"allowed_approval_policies = ["never"]"#;
|
||||
let expected: ConfigRequirementsToml = toml::from_str(requirements)?;
|
||||
let cloud_config_bundle =
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements);
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
cloud_requirements,
|
||||
ConfigLoadOptions {
|
||||
cloud_config_bundle,
|
||||
..Default::default()
|
||||
},
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -1415,7 +1352,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
||||
field_name: "approval_policy",
|
||||
candidate: "OnRequest".into(),
|
||||
allowed: "[Never]".into(),
|
||||
requirement_source: RequirementSource::CloudRequirements,
|
||||
requirement_source: cloud_config_bundle_requirement_source(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1680,6 +1617,88 @@ extends = ":workspace"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_inserts_cloud_config_between_system_and_user() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"model = "user"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let system_config_path = tmp.path().join("system_config.toml");
|
||||
tokio::fs::write(
|
||||
&system_config_path,
|
||||
r#"model = "system"
|
||||
model_provider = "system-provider"
|
||||
review_model = "system-review"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
overrides.system_config_path = Some(system_config_path.clone());
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
ConfigLoadOptions {
|
||||
loader_overrides: overrides,
|
||||
cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config(
|
||||
r#"model = "cloud"
|
||||
model_provider = "cloud-provider"
|
||||
"#,
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let merged = layers.effective_config();
|
||||
let table = merged.as_table().expect("merged config should be a table");
|
||||
assert_eq!(table.get("model"), Some(&TomlValue::String("user".into())));
|
||||
assert_eq!(
|
||||
table.get("model_provider"),
|
||||
Some(&TomlValue::String("cloud-provider".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
table.get("review_model"),
|
||||
Some(&TomlValue::String("system-review".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
layers
|
||||
.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
)
|
||||
.iter()
|
||||
.map(|layer| layer.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
ConfigLayerSource::System {
|
||||
file: AbsolutePathBuf::from_absolute_path(&system_config_path)?,
|
||||
},
|
||||
ConfigLayerSource::EnterpriseManaged {
|
||||
id: "cfg_1".to_string(),
|
||||
name: "Base config".to_string(),
|
||||
},
|
||||
ConfigLayerSource::User {
|
||||
file: AbsolutePathBuf::from_absolute_path(codex_home.join(CONFIG_TOML_FILE))?,
|
||||
profile: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
@@ -1706,18 +1725,15 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<
|
||||
overrides.system_requirements_path = Some(system_requirements_path);
|
||||
overrides.ignore_managed_requirements = true;
|
||||
|
||||
let cloud_requirements = CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
..Default::default()
|
||||
}))
|
||||
});
|
||||
let cloud_config_bundle = CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approval_policies = ["never"]"#,
|
||||
);
|
||||
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.loader_overrides(overrides)
|
||||
.cloud_requirements(cloud_requirements)
|
||||
.cloud_config_bundle(cloud_config_bundle)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -1747,36 +1763,36 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result
|
||||
tokio::fs::create_dir_all(&managed_dir).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
hooks: Some(codex_config::ManagedHooksRequirementsToml {
|
||||
managed_dir: Some(managed_dir.clone()),
|
||||
windows_managed_dir: None,
|
||||
hooks: codex_config::HookEventsToml {
|
||||
pre_tool_use: vec![codex_config::MatcherGroup {
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
hooks: vec![codex_config::HookHandlerConfig::Command {
|
||||
command: format!("python3 {}/pre.py", managed_dir.display()),
|
||||
command_windows: None,
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
..ConfigRequirementsToml::default()
|
||||
};
|
||||
let expected = requirements.clone();
|
||||
let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) });
|
||||
let requirements = format!(
|
||||
r#"
|
||||
[hooks]
|
||||
managed_dir = '{}'
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "^Bash$"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = 'python3 {}/pre.py'
|
||||
timeout = 10
|
||||
statusMessage = "checking"
|
||||
"#,
|
||||
managed_dir.display(),
|
||||
managed_dir.display()
|
||||
);
|
||||
let expected: ConfigRequirementsToml = toml::from_str(&requirements)?;
|
||||
let cloud_config_bundle =
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements);
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
cloud_requirements,
|
||||
ConfigLoadOptions {
|
||||
cloud_config_bundle,
|
||||
..Default::default()
|
||||
},
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -1788,12 +1804,94 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result
|
||||
.managed_hooks
|
||||
.as_ref()
|
||||
.map(|hooks| hooks.source.clone()),
|
||||
Some(Some(RequirementSource::CloudRequirements))
|
||||
Some(Some(cloud_config_bundle_requirement_source()))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_resolves_relative_bundle_requirements_paths_against_codex_home()
|
||||
-> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
|
||||
let requirements = r#"
|
||||
[permissions.filesystem]
|
||||
deny_read = ["secrets/**"]
|
||||
"#;
|
||||
let cloud_config_bundle =
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements);
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
ConfigLoadOptions {
|
||||
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
|
||||
cloud_config_bundle,
|
||||
..Default::default()
|
||||
},
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let permissions = layers
|
||||
.requirements_toml()
|
||||
.permissions
|
||||
.clone()
|
||||
.expect("permissions requirements should load");
|
||||
let filesystem = permissions
|
||||
.filesystem
|
||||
.expect("filesystem requirements should load");
|
||||
|
||||
assert_eq!(
|
||||
filesystem.deny_read,
|
||||
Some(vec![
|
||||
FilesystemDenyReadPattern::from_input(&format!("{}/secrets/**", codex_home.display()))
|
||||
.expect("bundle requirements path should resolve against codex_home")
|
||||
])
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_config_rejects_unknown_cloud_config_key() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home)
|
||||
.await
|
||||
.expect("create codex home");
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("cwd");
|
||||
|
||||
let err = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
ConfigLoadOptions {
|
||||
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
|
||||
strict_config: true,
|
||||
cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config(
|
||||
"unknown_key = true",
|
||||
),
|
||||
},
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
.expect_err("strict config should reject unknown cloud config keys");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("unknown configuration field `unknown_key`"),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
@@ -1801,23 +1899,24 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
|
||||
let requirements: ConfigRequirementsToml = toml::from_str(
|
||||
r#"
|
||||
let requirements = r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["*"]
|
||||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
"#,
|
||||
)?;
|
||||
let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) });
|
||||
"#;
|
||||
let cloud_config_bundle =
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements);
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
cloud_requirements,
|
||||
ConfigLoadOptions {
|
||||
cloud_config_bundle,
|
||||
..Default::default()
|
||||
},
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -1841,7 +1940,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyhow::Result<()> {
|
||||
async fn load_config_layers_fails_when_cloud_config_bundle_loader_fails() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
@@ -1852,21 +1951,23 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::new(async {
|
||||
Err(CloudRequirementsLoadError::new(
|
||||
codex_config::CloudRequirementsLoadErrorCode::RequestFailed,
|
||||
/*status_code*/ None,
|
||||
"cloud requirements failed",
|
||||
))
|
||||
}),
|
||||
ConfigLoadOptions {
|
||||
cloud_config_bundle: CloudConfigBundleLoader::new(async {
|
||||
Err(CloudConfigBundleLoadError::new(
|
||||
codex_config::CloudConfigBundleLoadErrorCode::RequestFailed,
|
||||
/*status_code*/ None,
|
||||
"cloud config bundle failed",
|
||||
))
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
.expect_err("cloud requirements failure should fail closed");
|
||||
.expect_err("cloud config bundle failure should fail closed");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
assert!(err.to_string().contains("cloud requirements failed"));
|
||||
assert!(err.to_string().contains("cloud config bundle failed"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1909,7 +2010,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -1992,7 +2092,6 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2074,7 +2173,6 @@ async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_con
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2145,7 +2243,6 @@ async fn nested_project_root_markers_do_not_redirect_regular_repo_hooks() -> std
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2333,7 +2430,6 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2374,7 +2470,6 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2440,7 +2535,6 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2516,7 +2610,6 @@ profile = "ignored"
|
||||
Some(cwd.clone()),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2563,7 +2656,6 @@ profile = "ignored"
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2652,7 +2744,6 @@ wire_api = "responses"
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2754,7 +2845,6 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st
|
||||
Some(AbsolutePathBuf::from_absolute_path(&project_root)?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2920,7 +3010,6 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
|
||||
Some(cwd.clone()),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -2989,7 +3078,6 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown
|
||||
Some(cwd.clone()),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -3050,7 +3138,6 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io
|
||||
Some(cwd),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -3096,7 +3183,6 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -104,6 +104,7 @@ use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::FormElicitationCapability;
|
||||
use rmcp::model::UrlElicitationCapability;
|
||||
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -1389,15 +1390,14 @@ async fn experimental_network_requirements_enable_proxy_without_feature() -> std
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
network: Some(codex_config::NetworkRequirementsToml {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[experimental_network]
|
||||
enabled = true
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -1867,7 +1867,7 @@ async fn managed_unrestricted_permission_profile_still_enables_network_requireme
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
RequirementSource::CloudRequirements,
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
));
|
||||
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
|
||||
requirements_toml.network = Some(codex_config::NetworkRequirementsToml {
|
||||
@@ -2357,7 +2357,13 @@ async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io::
|
||||
description: None,
|
||||
extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
|
||||
workspace_roots: None,
|
||||
filesystem: None,
|
||||
filesystem: Some(FilesystemPermissionsToml {
|
||||
glob_scan_max_depth: None,
|
||||
entries: BTreeMap::from([(
|
||||
":tmpdir".to_string(),
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
|
||||
)]),
|
||||
}),
|
||||
network: Some(NetworkToml {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
@@ -2384,6 +2390,42 @@ async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io::
|
||||
!policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()),
|
||||
"expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}"
|
||||
);
|
||||
assert!(
|
||||
policy.entries.iter().any(|entry| matches!(
|
||||
entry,
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::SlashTmp,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
}
|
||||
)),
|
||||
"expected profile extending :workspace to keep inherited :slash_tmp writes, policy: {policy:?}"
|
||||
);
|
||||
assert!(
|
||||
policy.entries.iter().any(|entry| matches!(
|
||||
entry,
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Tmpdir,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
}
|
||||
)),
|
||||
"expected child :tmpdir read entry to replace the inherited write entry, policy: {policy:?}"
|
||||
);
|
||||
assert!(
|
||||
!policy.entries.iter().any(|entry| matches!(
|
||||
entry,
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Tmpdir,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
}
|
||||
)),
|
||||
"expected inherited :tmpdir write entry to be removed, policy: {policy:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
config.permissions.network_sandbox_policy(),
|
||||
NetworkSandboxPolicy::Enabled
|
||||
@@ -3890,7 +3932,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
|
||||
http_mcp("https://example.com/mcp"),
|
||||
),
|
||||
]);
|
||||
let source = RequirementSource::CloudRequirements;
|
||||
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let requirements = Sourced::new(
|
||||
BTreeMap::from([(
|
||||
"sample@test".to_string(),
|
||||
@@ -3940,7 +3982,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
|
||||
#[test]
|
||||
fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
|
||||
let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]);
|
||||
let source = RequirementSource::CloudRequirements;
|
||||
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let requirements = Sourced::new(
|
||||
BTreeMap::from([(
|
||||
"other@test".to_string(),
|
||||
@@ -4343,7 +4385,7 @@ enabled = true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> {
|
||||
async fn to_mcp_config_applies_plugin_mcp_cloud_config_bundle() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
@@ -4380,27 +4422,16 @@ enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let requirements = codex_config::ConfigRequirementsToml {
|
||||
plugins: Some(BTreeMap::from([(
|
||||
"sample@test".to_string(),
|
||||
codex_config::PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"sample".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://sample.example/mcp".to_string(),
|
||||
},
|
||||
},
|
||||
)])),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
};
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[plugins."sample@test".mcp_servers.sample.identity]
|
||||
url = "https://sample.example/mcp"
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
@@ -4421,7 +4452,10 @@ enabled = true
|
||||
Some((
|
||||
false,
|
||||
Some(McpServerDisabledReason::Requirements {
|
||||
source: RequirementSource::CloudRequirements,
|
||||
source: RequirementSource::EnterpriseManaged {
|
||||
id: "req_1".to_string(),
|
||||
name: "Base requirements".to_string(),
|
||||
},
|
||||
})
|
||||
))
|
||||
);
|
||||
@@ -4462,15 +4496,15 @@ enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let requirements = codex_config::ConfigRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[mcp_servers]
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
@@ -4484,7 +4518,10 @@ enabled = true
|
||||
Some((
|
||||
false,
|
||||
Some(McpServerDisabledReason::Requirements {
|
||||
source: RequirementSource::CloudRequirements,
|
||||
source: RequirementSource::EnterpriseManaged {
|
||||
id: "req_1".to_string(),
|
||||
name: "Base requirements".to_string(),
|
||||
},
|
||||
})
|
||||
))
|
||||
);
|
||||
@@ -5102,7 +5139,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
|
||||
Some(cwd),
|
||||
&Vec::new(),
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -5236,7 +5272,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> {
|
||||
Some(cwd),
|
||||
&[("model".to_string(), TomlValue::String("cli".to_string()))],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -8958,12 +8993,11 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_sandbox_modes = ["read-only"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
@@ -8982,35 +9016,14 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let requirements = codex_config::ConfigRequirementsToml {
|
||||
allowed_approval_policies: None,
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
|
||||
allowed_permissions: None,
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: None,
|
||||
allow_managed_hooks_only: None,
|
||||
allow_appshots: None,
|
||||
computer_use: None,
|
||||
windows: None,
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
permissions: None,
|
||||
guardian_policy_config: None,
|
||||
};
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_sandbox_modes = ["read-only"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
@@ -9030,21 +9043,16 @@ sandbox = "unelevated"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let requirements = codex_config::ConfigRequirementsToml {
|
||||
windows: Some(codex_config::WindowsRequirementsToml {
|
||||
allowed_sandbox_implementations: Some(vec![
|
||||
codex_config::types::WindowsSandboxModeToml::Elevated,
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"[windows]
|
||||
allowed_sandbox_implementations = ["elevated"]
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9075,12 +9083,11 @@ sandbox_mode = "danger-full-access"
|
||||
let err = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_sandbox_modes = ["read-only"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.expect_err("requirements-constrained yolo should require sandbox approval");
|
||||
@@ -9110,12 +9117,11 @@ default_permissions = "dev"
|
||||
let err = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_sandbox_modes = ["read-only"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.expect_err("requirements-constrained full-access profile should require sandbox approval");
|
||||
@@ -9132,11 +9138,6 @@ default_permissions = "dev"
|
||||
async fn permission_profile_override_falls_back_when_disallowed_by_requirements()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let requirements = codex_config::ConfigRequirementsToml {
|
||||
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
@@ -9144,9 +9145,11 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements(
|
||||
permission_profile: Some(PermissionProfile::Disabled),
|
||||
..Default::default()
|
||||
})
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_sandbox_modes = ["read-only"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9162,11 +9165,6 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements(
|
||||
#[tokio::test]
|
||||
async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let requirements = codex_config::ConfigRequirementsToml {
|
||||
allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
@@ -9174,9 +9172,11 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io
|
||||
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_sandbox_modes = ["read-only"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9286,14 +9286,11 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() ->
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_web_search_modes: Some(vec![
|
||||
codex_config::WebSearchModeRequirement::Cached,
|
||||
]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_web_search_modes = ["cached"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9327,12 +9324,11 @@ trust_level = "untrusted"
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(workspace.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approval_policies = ["on-request"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9356,12 +9352,11 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() -
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approval_policies = ["on-request"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
@@ -9377,17 +9372,15 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([
|
||||
("personality".to_string(), true),
|
||||
("shell_tool".to_string(), false),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[features]
|
||||
personality = true
|
||||
shell_tool = false
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9411,14 +9404,14 @@ async fn feature_requirements_auto_review_disables_guardian_approval() -> std::i
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("auto_review".to_string(), false)]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[features]
|
||||
auto_review = false
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9433,17 +9426,15 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> {
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([
|
||||
("in_app_browser".to_string(), false),
|
||||
("browser_use".to_string(), false),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[features]
|
||||
in_app_browser = false
|
||||
browser_use = false
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9539,17 +9530,15 @@ shell_tool = true
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([
|
||||
("personality".to_string(), true),
|
||||
("shell_tool".to_string(), false),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[features]
|
||||
personality = true
|
||||
shell_tool = false
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9659,12 +9648,11 @@ async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_requi
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approvals_reviewers = ["guardian_subagent"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9685,12 +9673,11 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() ->
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approvals_reviewers = ["guardian_subagent"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -9706,6 +9693,37 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let selected_config = codex_home.path().join("default.config.toml");
|
||||
std::fs::write(
|
||||
&selected_config,
|
||||
r#"approvals_reviewer = "user"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides {
|
||||
user_config_path: Some(selected_config.abs()),
|
||||
user_config_profile: Some("default".parse().expect("profile-v2 name")),
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
})
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approvals_reviewers = ["guardian_subagent"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements()
|
||||
-> std::io::Result<()> {
|
||||
@@ -9719,15 +9737,11 @@ async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirem
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![
|
||||
ApprovalsReviewer::User,
|
||||
ApprovalsReviewer::AutoReview,
|
||||
]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approvals_reviewers = ["user", "guardian_subagent"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -10213,17 +10227,15 @@ async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::
|
||||
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([
|
||||
("personality".to_string(), true),
|
||||
("shell_tool".to_string(), false),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[features]
|
||||
personality = true
|
||||
shell_tool = false
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -10249,14 +10261,14 @@ async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<(
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("collab".to_string(), true)]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[features]
|
||||
collab = true
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -10279,14 +10291,14 @@ async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Resu
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(codex_config::ConfigRequirementsToml {
|
||||
feature_requirements: Some(codex_config::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("made_up_feature".to_string(), true)]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[features]
|
||||
made_up_feature = true
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS;
|
||||
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use crate::windows_sandbox::resolve_windows_sandbox_mode;
|
||||
use crate::windows_sandbox::resolve_windows_sandbox_private_desktop;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
@@ -1112,7 +1112,7 @@ pub struct ConfigBuilder {
|
||||
harness_overrides: Option<ConfigOverrides>,
|
||||
loader_overrides: Option<LoaderOverrides>,
|
||||
strict_config: bool,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
cloud_config_bundle: CloudConfigBundleLoader,
|
||||
thread_config_loader: Option<Arc<dyn ThreadConfigLoader>>,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
}
|
||||
@@ -1143,8 +1143,8 @@ impl ConfigBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self {
|
||||
self.cloud_requirements = cloud_requirements;
|
||||
pub fn cloud_config_bundle(mut self, cloud_config_bundle: CloudConfigBundleLoader) -> Self {
|
||||
self.cloud_config_bundle = cloud_config_bundle;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -1173,7 +1173,7 @@ impl ConfigBuilder {
|
||||
harness_overrides,
|
||||
loader_overrides,
|
||||
strict_config,
|
||||
cloud_requirements,
|
||||
cloud_config_bundle,
|
||||
thread_config_loader,
|
||||
fallback_cwd,
|
||||
} = self;
|
||||
@@ -1198,8 +1198,8 @@ impl ConfigBuilder {
|
||||
ConfigLoadOptions {
|
||||
loader_overrides,
|
||||
strict_config,
|
||||
cloud_config_bundle,
|
||||
},
|
||||
cloud_requirements,
|
||||
thread_config_loader
|
||||
.as_deref()
|
||||
.unwrap_or(&codex_config::NoopThreadConfigLoader),
|
||||
@@ -1623,7 +1623,6 @@ pub async fn load_config_as_toml_with_cli_and_load_options(
|
||||
cwd.cloned(),
|
||||
&cli_overrides,
|
||||
options,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
@@ -1834,7 +1833,6 @@ pub async fn load_global_mcp_servers(
|
||||
cwd,
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
@@ -13,7 +14,6 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
|
||||
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
|
||||
use codex_config::permissions_toml::PermissionProfileToml;
|
||||
use codex_config::permissions_toml::PermissionsToml;
|
||||
use codex_config::permissions_toml::ResolvedPermissionProfileToml;
|
||||
use codex_config::permissions_toml::WorkspaceRootsToml;
|
||||
use codex_config::types::SandboxWorkspaceWrite;
|
||||
use codex_features::NetworkProxyConfigToml;
|
||||
@@ -194,27 +194,132 @@ pub(crate) fn apply_network_proxy_feature_config(
|
||||
pub(crate) fn resolve_permission_profile(
|
||||
permissions: &PermissionsToml,
|
||||
profile_name: &str,
|
||||
) -> io::Result<ResolvedPermissionProfileToml> {
|
||||
) -> io::Result<PermissionProfileToml> {
|
||||
permissions
|
||||
.resolve_profile(profile_name, extensible_builtin_parent_profile_marker)
|
||||
.resolve_profile(profile_name, extensible_builtin_parent_profile)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string()))
|
||||
}
|
||||
|
||||
/// Built-in parents provide their runtime permissions below. Resolution only
|
||||
/// needs an empty profile marker so inheritance can terminate while preserving
|
||||
/// the built-in parent id in `inherited_profile_names`.
|
||||
fn extensible_builtin_parent_profile_marker(profile_name: &str) -> Option<PermissionProfileToml> {
|
||||
matches!(
|
||||
profile_name,
|
||||
BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE
|
||||
)
|
||||
.then_some(PermissionProfileToml {
|
||||
fn extensible_builtin_parent_profile(profile_name: &str) -> Option<PermissionProfileToml> {
|
||||
let file_system = match profile_name {
|
||||
BUILT_IN_READ_ONLY_PROFILE => FileSystemSandboxPolicy::read_only(),
|
||||
BUILT_IN_WORKSPACE_PROFILE => FileSystemSandboxPolicy::workspace_write(
|
||||
&[],
|
||||
/*exclude_tmpdir_env_var*/ false,
|
||||
/*exclude_slash_tmp*/ false,
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some(permission_profile_toml_from_file_system_policy(file_system))
|
||||
}
|
||||
|
||||
fn permission_profile_toml_from_file_system_policy(
|
||||
file_system: FileSystemSandboxPolicy,
|
||||
) -> PermissionProfileToml {
|
||||
let mut filesystem = FilesystemPermissionsToml {
|
||||
glob_scan_max_depth: file_system.glob_scan_max_depth,
|
||||
entries: BTreeMap::new(),
|
||||
};
|
||||
for entry in file_system.entries {
|
||||
insert_filesystem_permission_toml(&mut filesystem.entries, entry);
|
||||
}
|
||||
PermissionProfileToml {
|
||||
description: None,
|
||||
extends: None,
|
||||
workspace_roots: None,
|
||||
filesystem: None,
|
||||
filesystem: Some(filesystem),
|
||||
network: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_filesystem_permission_toml(
|
||||
entries: &mut BTreeMap<String, FilesystemPermissionToml>,
|
||||
entry: FileSystemSandboxEntry,
|
||||
) {
|
||||
match entry.path {
|
||||
FileSystemPath::Path { path } => {
|
||||
entries.insert(
|
||||
path.into_path_buf().to_string_lossy().into_owned(),
|
||||
FilesystemPermissionToml::Access(entry.access),
|
||||
);
|
||||
}
|
||||
FileSystemPath::GlobPattern { pattern } => {
|
||||
entries.insert(pattern, FilesystemPermissionToml::Access(entry.access));
|
||||
}
|
||||
FileSystemPath::Special { value } => {
|
||||
insert_special_filesystem_permission_toml(entries, value, entry.access);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_special_filesystem_permission_toml(
|
||||
entries: &mut BTreeMap<String, FilesystemPermissionToml>,
|
||||
value: FileSystemSpecialPath,
|
||||
access: FileSystemAccessMode,
|
||||
) {
|
||||
match value {
|
||||
FileSystemSpecialPath::Root => {
|
||||
entries.insert(
|
||||
":root".to_string(),
|
||||
FilesystemPermissionToml::Access(access),
|
||||
);
|
||||
}
|
||||
FileSystemSpecialPath::Minimal => {
|
||||
entries.insert(
|
||||
":minimal".to_string(),
|
||||
FilesystemPermissionToml::Access(access),
|
||||
);
|
||||
}
|
||||
FileSystemSpecialPath::ProjectRoots { subpath } => {
|
||||
insert_scoped_filesystem_permission_toml(
|
||||
entries,
|
||||
":workspace_roots".to_string(),
|
||||
subpath.unwrap_or_else(|| PathBuf::from(".")),
|
||||
access,
|
||||
);
|
||||
}
|
||||
FileSystemSpecialPath::Tmpdir => {
|
||||
entries.insert(
|
||||
":tmpdir".to_string(),
|
||||
FilesystemPermissionToml::Access(access),
|
||||
);
|
||||
}
|
||||
FileSystemSpecialPath::SlashTmp => {
|
||||
entries.insert(
|
||||
":slash_tmp".to_string(),
|
||||
FilesystemPermissionToml::Access(access),
|
||||
);
|
||||
}
|
||||
FileSystemSpecialPath::Unknown { path, subpath } => {
|
||||
if let Some(subpath) = subpath {
|
||||
insert_scoped_filesystem_permission_toml(entries, path, subpath, access);
|
||||
} else {
|
||||
entries.insert(path, FilesystemPermissionToml::Access(access));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn insert_scoped_filesystem_permission_toml(
|
||||
entries: &mut BTreeMap<String, FilesystemPermissionToml>,
|
||||
path: String,
|
||||
subpath: PathBuf,
|
||||
access: FileSystemAccessMode,
|
||||
) {
|
||||
let permission = entries
|
||||
.entry(path)
|
||||
.or_insert_with(|| FilesystemPermissionToml::Scoped(BTreeMap::new()));
|
||||
match permission {
|
||||
FilesystemPermissionToml::Scoped(scoped_entries) => {
|
||||
scoped_entries.insert(subpath.to_string_lossy().into_owned(), access);
|
||||
}
|
||||
FilesystemPermissionToml::Access(_) => {
|
||||
*permission = FilesystemPermissionToml::Scoped(BTreeMap::from([(
|
||||
subpath.to_string_lossy().into_owned(),
|
||||
access,
|
||||
)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn network_proxy_config_for_profile_selection(
|
||||
@@ -232,7 +337,7 @@ pub(crate) fn network_proxy_config_for_profile_selection(
|
||||
"default_permissions requires a `[permissions]` table",
|
||||
)
|
||||
})?;
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?.profile;
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?;
|
||||
Ok(network_proxy_config_from_profile_network(
|
||||
profile.network.as_ref(),
|
||||
))
|
||||
@@ -244,25 +349,9 @@ pub(crate) fn compile_permission_profile(
|
||||
policy_cwd: &Path,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
|
||||
let ResolvedPermissionProfileToml {
|
||||
profile,
|
||||
inherited_profile_names,
|
||||
} = resolve_permission_profile(permissions, profile_name)?;
|
||||
let base_permissions = inherited_profile_names.iter().find_map(|name| {
|
||||
match name.as_str() {
|
||||
BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()),
|
||||
BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()),
|
||||
_ => None,
|
||||
}
|
||||
.map(|profile| profile.to_runtime_permissions())
|
||||
});
|
||||
let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
FileSystemSandboxPolicy::restricted(Vec::new()),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
)
|
||||
});
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?;
|
||||
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(Vec::new());
|
||||
let base_network_sandbox_policy = NetworkSandboxPolicy::Restricted;
|
||||
if let Some(filesystem) = profile.filesystem.as_ref() {
|
||||
if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() {
|
||||
push_warning(
|
||||
@@ -358,7 +447,7 @@ pub(crate) fn compile_permission_profile_workspace_roots(
|
||||
})?;
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?;
|
||||
Ok(compile_workspace_roots(
|
||||
profile.profile.workspace_roots.as_ref(),
|
||||
profile.workspace_roots.as_ref(),
|
||||
policy_cwd,
|
||||
))
|
||||
}
|
||||
@@ -693,6 +782,7 @@ fn parse_special_path(path: &str) -> Option<FileSystemSpecialPath> {
|
||||
":minimal" => Some(FileSystemSpecialPath::Minimal),
|
||||
":workspace_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)),
|
||||
":tmpdir" => Some(FileSystemSpecialPath::Tmpdir),
|
||||
":slash_tmp" => Some(FileSystemSpecialPath::SlashTmp),
|
||||
_ if path.starts_with(':') => {
|
||||
Some(FileSystemSpecialPath::unknown(path, /*subpath*/ None))
|
||||
}
|
||||
|
||||
@@ -326,8 +326,7 @@ allow_local_binding = true
|
||||
)
|
||||
.expect("expected profile should deserialize");
|
||||
|
||||
assert_eq!(resolved.profile, expected_profile);
|
||||
assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]);
|
||||
assert_eq!(resolved, expected_profile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,10 +5,10 @@ use codex_config::AppRequirementToml;
|
||||
use codex_config::AppToolRequirementToml;
|
||||
use codex_config::AppToolsRequirementsToml;
|
||||
use codex_config::AppsRequirementsToml;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_config::types::AppConfig;
|
||||
use codex_config::types::AppToolConfig;
|
||||
use codex_config::types::AppToolsConfig;
|
||||
@@ -455,15 +455,13 @@ approvals_reviewer = "user"
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
let requirements = ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
|
||||
..Default::default()
|
||||
};
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"allowed_approvals_reviewers = ["auto_review"]"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.expect("config should build");
|
||||
@@ -484,30 +482,19 @@ approvals_reviewer = "user"
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
let requirements = ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![
|
||||
ApprovalsReviewer::User,
|
||||
ApprovalsReviewer::AutoReview,
|
||||
]),
|
||||
apps: Some(AppsRequirementsToml {
|
||||
apps: BTreeMap::from([(
|
||||
"calendar".to_string(),
|
||||
AppRequirementToml {
|
||||
allowed_approvals_reviewers: Some(BTreeMap::from([
|
||||
(ApprovalsReviewer::User, false),
|
||||
(ApprovalsReviewer::AutoReview, true),
|
||||
])),
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
allowed_approvals_reviewers = ["user", "auto_review"]
|
||||
|
||||
[apps.calendar.allowed_approvals_reviewers]
|
||||
user = false
|
||||
auto_review = true
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.expect("config should build");
|
||||
@@ -553,23 +540,27 @@ approvals_reviewer = "{global}"
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
let requirements = ConfigRequirementsToml {
|
||||
apps: Some(AppsRequirementsToml {
|
||||
apps: BTreeMap::from([(
|
||||
"calendar".to_string(),
|
||||
AppRequirementToml {
|
||||
allowed_approvals_reviewers: Some(overrides),
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let (user, auto_review) = (
|
||||
overrides
|
||||
.get(&ApprovalsReviewer::User)
|
||||
.copied()
|
||||
.unwrap_or(false),
|
||||
overrides
|
||||
.get(&ApprovalsReviewer::AutoReview)
|
||||
.copied()
|
||||
.unwrap_or(false),
|
||||
);
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(&format!(
|
||||
r#"
|
||||
[apps.calendar.allowed_approvals_reviewers]
|
||||
user = {user}
|
||||
auto_review = {auto_review}
|
||||
"#
|
||||
)),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.expect("config should build");
|
||||
@@ -652,7 +643,7 @@ fn requirements_enabled_does_not_override_disabled_connector() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_requirements_disable_connector_overrides_user_apps_config() {
|
||||
async fn cloud_config_bundle_disable_connector_overrides_user_apps_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -663,25 +654,17 @@ enabled = true
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
apps: Some(AppsRequirementsToml {
|
||||
apps: BTreeMap::from([(
|
||||
"connector_123123".to_string(),
|
||||
AppRequirementToml {
|
||||
enabled: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[apps.connector_123123]
|
||||
enabled = false
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.expect("config should build");
|
||||
@@ -703,29 +686,21 @@ enabled = true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_requirements_disable_connector_applies_without_user_apps_table() {
|
||||
async fn cloud_config_bundle_disable_connector_applies_without_user_apps_table() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), "").expect("write config");
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
apps: Some(AppsRequirementsToml {
|
||||
apps: BTreeMap::from([(
|
||||
"connector_123123".to_string(),
|
||||
AppRequirementToml {
|
||||
enabled: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[apps.connector_123123]
|
||||
enabled = false
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.expect("config should build");
|
||||
@@ -981,7 +956,7 @@ fn managed_app_tool_approval_uses_raw_tool_name() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_requirements_tool_approval_overrides_user_apps_config() {
|
||||
async fn cloud_config_bundle_tool_approval_overrides_user_apps_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -992,21 +967,17 @@ approval_mode = "prompt"
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
apps: Some(app_tool_requirements(
|
||||
"connector_123123",
|
||||
"calendar/list_events",
|
||||
AppToolApproval::Approve,
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
[apps.connector_123123.tools."calendar/list_events"]
|
||||
approval_mode = "approve"
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.await
|
||||
.expect("config should build");
|
||||
|
||||
@@ -20,7 +20,6 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
@@ -719,9 +718,7 @@ async fn run_review_on_session(
|
||||
.total_token_usage()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// The legacy SandboxPolicy should match the PermissionProfile.
|
||||
let guardian_permission_profile = PermissionProfile::read_only();
|
||||
let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
let submit_result = run_before_review_deadline(
|
||||
deadline,
|
||||
@@ -736,7 +733,7 @@ async fn run_review_on_session(
|
||||
#[allow(deprecated)]
|
||||
cwd: Some(params.parent_turn.cwd.to_path_buf()),
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(legacy_sandbox_policy),
|
||||
sandbox_policy: None,
|
||||
permission_profile: Some(guardian_permission_profile),
|
||||
summary: Some(params.reasoning_summary),
|
||||
personality: params.personality,
|
||||
|
||||
@@ -10,7 +10,6 @@ use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::LoaderOverrides;
|
||||
@@ -60,7 +59,6 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec<LayerMtime
|
||||
/*cwd*/ None,
|
||||
&cli_overrides,
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
@@ -210,7 +208,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
|
||||
.context("default_permissions requires a `[permissions]` table for network settings")?;
|
||||
let profile = resolve_permission_profile(&permissions, &default_permissions)
|
||||
.map_err(anyhow::Error::from)?;
|
||||
Ok(profile.profile.network)
|
||||
Ok(profile.network)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -45,6 +45,7 @@ use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::SubAgentThreadStartedInput;
|
||||
use codex_analytics::TurnCodexErrorFact;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
@@ -1634,6 +1635,17 @@ impl Session {
|
||||
)
|
||||
}
|
||||
|
||||
/// Record a terminal CodexErr before the app-server completion notification is reduced.
|
||||
pub(crate) fn track_turn_codex_error(&self, turn_context: &TurnContext, error: &CodexErr) {
|
||||
self.services
|
||||
.analytics_events_client
|
||||
.track_turn_codex_error(TurnCodexErrorFact::from_codex_err(
|
||||
self.conversation_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
error,
|
||||
));
|
||||
}
|
||||
|
||||
/// Persist the event to rollout and send it to clients.
|
||||
pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) {
|
||||
let legacy_source = msg.clone();
|
||||
@@ -2100,34 +2112,16 @@ impl Session {
|
||||
rx_approve
|
||||
}
|
||||
|
||||
pub async fn request_permissions(
|
||||
self: &Arc<Self>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
call_id: String,
|
||||
args: RequestPermissionsArgs,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<RequestPermissionsResponse> {
|
||||
self.request_permissions_for_cwd(
|
||||
turn_context,
|
||||
call_id,
|
||||
args,
|
||||
#[allow(deprecated)]
|
||||
turn_context.cwd.clone(),
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub(crate) async fn request_permissions_for_cwd(
|
||||
pub(crate) async fn request_permissions_for_environment(
|
||||
self: &Arc<Self>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
call_id: String,
|
||||
args: RequestPermissionsArgs,
|
||||
cwd: AbsolutePathBuf,
|
||||
environment: TurnEnvironmentSelection,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<RequestPermissionsResponse> {
|
||||
match turn_context.as_ref().approval_policy.value() {
|
||||
@@ -2221,10 +2215,11 @@ impl Session {
|
||||
let response = Self::normalize_request_permissions_response(
|
||||
requested_permissions,
|
||||
response,
|
||||
cwd.as_path(),
|
||||
environment.cwd.as_path(),
|
||||
);
|
||||
self.record_granted_request_permissions_for_turn(
|
||||
&response,
|
||||
&environment.environment_id,
|
||||
originating_turn_state.as_ref(),
|
||||
)
|
||||
.await;
|
||||
@@ -2242,7 +2237,7 @@ impl Session {
|
||||
PendingRequestPermissions {
|
||||
tx_response,
|
||||
requested_permissions: requested_permissions.clone(),
|
||||
cwd: cwd.clone(),
|
||||
environment: environment.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2256,10 +2251,11 @@ impl Session {
|
||||
let event = EventMsg::RequestPermissions(RequestPermissionsEvent {
|
||||
call_id: call_id.clone(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
environment_id: Some(environment.environment_id.clone()),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
reason: args.reason,
|
||||
permissions: requested_permissions,
|
||||
cwd: Some(cwd),
|
||||
cwd: Some(environment.cwd),
|
||||
});
|
||||
self.send_event(turn_context.as_ref(), event).await;
|
||||
tokio::select! {
|
||||
@@ -2276,6 +2272,41 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn request_permissions_for_cwd(
|
||||
self: &Arc<Self>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
call_id: String,
|
||||
args: RequestPermissionsArgs,
|
||||
cwd: AbsolutePathBuf,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<RequestPermissionsResponse> {
|
||||
let turn_environment = match args.environment_id.as_deref() {
|
||||
Some(environment_id) => turn_context
|
||||
.environments
|
||||
.turn_environments
|
||||
.iter()
|
||||
.find(|environment| environment.environment_id == environment_id),
|
||||
None => turn_context.environments.primary(),
|
||||
};
|
||||
let Some(turn_environment) = turn_environment else {
|
||||
return Some(RequestPermissionsResponse {
|
||||
permissions: RequestPermissionProfile::default(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
});
|
||||
};
|
||||
let mut environment = turn_environment.selection();
|
||||
environment.cwd = cwd;
|
||||
self.request_permissions_for_environment(
|
||||
turn_context,
|
||||
call_id,
|
||||
args,
|
||||
environment,
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
@@ -2370,10 +2401,11 @@ impl Session {
|
||||
let response = Self::normalize_request_permissions_response(
|
||||
entry.requested_permissions,
|
||||
response,
|
||||
entry.cwd.as_path(),
|
||||
entry.environment.cwd.as_path(),
|
||||
);
|
||||
self.record_granted_request_permissions_for_turn(
|
||||
&response,
|
||||
&entry.environment.environment_id,
|
||||
originating_turn_state.as_ref(),
|
||||
)
|
||||
.await;
|
||||
@@ -2417,6 +2449,7 @@ impl Session {
|
||||
async fn record_granted_request_permissions_for_turn(
|
||||
&self,
|
||||
response: &RequestPermissionsResponse,
|
||||
environment_id: &str,
|
||||
originating_turn_state: Option<&Arc<Mutex<crate::state::TurnState>>>,
|
||||
) {
|
||||
if response.permissions.is_empty() {
|
||||
@@ -2428,7 +2461,7 @@ impl Session {
|
||||
let mut ts = turn_state.lock().await;
|
||||
let permissions: AdditionalPermissionProfile =
|
||||
response.permissions.clone().into();
|
||||
ts.record_granted_permissions(permissions);
|
||||
ts.record_granted_permissions(environment_id, permissions);
|
||||
if response.strict_auto_review {
|
||||
ts.enable_strict_auto_review();
|
||||
}
|
||||
@@ -2436,7 +2469,10 @@ impl Session {
|
||||
}
|
||||
PermissionGrantScope::Session => {
|
||||
let mut state = self.state.lock().await;
|
||||
state.record_granted_permissions(response.permissions.clone().into());
|
||||
state.record_granted_permissions(
|
||||
environment_id,
|
||||
response.permissions.clone().into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2445,11 +2481,14 @@ impl Session {
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn reads must stay consistent with the matching turn state"
|
||||
)]
|
||||
pub(crate) async fn granted_turn_permissions(&self) -> Option<AdditionalPermissionProfile> {
|
||||
pub(crate) async fn granted_turn_permissions(
|
||||
&self,
|
||||
environment_id: &str,
|
||||
) -> Option<AdditionalPermissionProfile> {
|
||||
let active = self.active_turn.lock().await;
|
||||
let active = active.as_ref()?;
|
||||
let ts = active.turn_state.lock().await;
|
||||
ts.granted_permissions()
|
||||
ts.granted_permissions(environment_id)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
@@ -2465,9 +2504,12 @@ impl Session {
|
||||
ts.strict_auto_review_enabled()
|
||||
}
|
||||
|
||||
pub(crate) async fn granted_session_permissions(&self) -> Option<AdditionalPermissionProfile> {
|
||||
pub(crate) async fn granted_session_permissions(
|
||||
&self,
|
||||
environment_id: &str,
|
||||
) -> Option<AdditionalPermissionProfile> {
|
||||
let state = self.state.lock().await;
|
||||
state.granted_permissions()
|
||||
state.granted_permissions(environment_id)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
|
||||
@@ -73,6 +73,7 @@ use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::CreateGoalHandler;
|
||||
use crate::tools::handlers::ExecCommandHandler;
|
||||
use crate::tools::handlers::RequestPermissionsHandler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::UpdateGoalHandler;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
@@ -1039,7 +1040,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
RequirementSource::CloudRequirements,
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
));
|
||||
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
|
||||
requirements_toml.network = Some(codex_config::NetworkRequirementsToml {
|
||||
@@ -5155,7 +5156,12 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(session.granted_turn_permissions().await, None);
|
||||
assert_eq!(
|
||||
session
|
||||
.granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
||||
.await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5182,16 +5188,84 @@ async fn record_granted_request_permissions_for_turn_uses_originating_turn() {
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
},
|
||||
codex_exec_server::LOCAL_ENVIRONMENT_ID,
|
||||
Some(&originating_turn_state),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
originating_turn_state.lock().await.granted_permissions(),
|
||||
originating_turn_state
|
||||
.lock()
|
||||
.await
|
||||
.granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID),
|
||||
Some(requested_permissions.into())
|
||||
);
|
||||
assert_eq!(current_turn_state.lock().await.granted_permissions(), None);
|
||||
assert_eq!(session.granted_turn_permissions().await, None);
|
||||
assert_eq!(
|
||||
current_turn_state
|
||||
.lock()
|
||||
.await
|
||||
.granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
||||
.await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_permission_grants_are_environment_keyed() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let originating_active_turn = ActiveTurn::default();
|
||||
let originating_turn_state = Arc::clone(&originating_active_turn.turn_state);
|
||||
*session.active_turn.lock().await = Some(originating_active_turn);
|
||||
|
||||
let requested_permissions = RequestPermissionProfile {
|
||||
network: Some(codex_protocol::models::NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..RequestPermissionProfile::default()
|
||||
};
|
||||
session
|
||||
.record_granted_request_permissions_for_turn(
|
||||
&codex_protocol::request_permissions::RequestPermissionsResponse {
|
||||
permissions: requested_permissions.clone(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
},
|
||||
"remote",
|
||||
Some(&originating_turn_state),
|
||||
)
|
||||
.await;
|
||||
|
||||
{
|
||||
let turn_state = originating_turn_state.lock().await;
|
||||
assert_eq!(
|
||||
turn_state.granted_permissions("remote"),
|
||||
Some(requested_permissions.clone().into())
|
||||
);
|
||||
assert_eq!(turn_state.granted_permissions("local"), None);
|
||||
}
|
||||
|
||||
session
|
||||
.record_granted_request_permissions_for_turn(
|
||||
&codex_protocol::request_permissions::RequestPermissionsResponse {
|
||||
permissions: requested_permissions.clone(),
|
||||
scope: PermissionGrantScope::Session,
|
||||
strict_auto_review: false,
|
||||
},
|
||||
"remote",
|
||||
/*originating_turn_state*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
session.granted_session_permissions("remote").await,
|
||||
Some(requested_permissions.into())
|
||||
);
|
||||
assert_eq!(session.granted_session_permissions("local").await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5214,6 +5288,7 @@ async fn enable_strict_auto_review_for_turn_uses_originating_turn() {
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: true,
|
||||
},
|
||||
codex_exec_server::LOCAL_ENVIRONMENT_ID,
|
||||
Some(&originating_turn_state),
|
||||
)
|
||||
.await;
|
||||
@@ -5290,11 +5365,17 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
|
||||
let turn_context = Arc::clone(&turn_context);
|
||||
let call_id = call_id.clone();
|
||||
async move {
|
||||
let environment = turn_context
|
||||
.environments
|
||||
.primary()
|
||||
.expect("primary environment")
|
||||
.selection();
|
||||
session
|
||||
.request_permissions(
|
||||
.request_permissions_for_environment(
|
||||
&turn_context,
|
||||
call_id,
|
||||
codex_protocol::request_permissions::RequestPermissionsArgs {
|
||||
environment_id: None,
|
||||
reason: Some("need network".to_string()),
|
||||
permissions: RequestPermissionProfile {
|
||||
network: Some(codex_protocol::models::NetworkPermissions {
|
||||
@@ -5303,6 +5384,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
|
||||
..RequestPermissionProfile::default()
|
||||
},
|
||||
},
|
||||
environment,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
@@ -5317,6 +5399,10 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
|
||||
panic!("expected request_permissions event");
|
||||
};
|
||||
assert_eq!(request.call_id, call_id);
|
||||
assert_eq!(
|
||||
request.environment_id.as_deref(),
|
||||
Some(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
||||
);
|
||||
#[allow(deprecated)]
|
||||
let turn_cwd = turn_context.cwd.clone();
|
||||
assert_eq!(request.cwd, Some(turn_cwd));
|
||||
@@ -5333,6 +5419,142 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
|
||||
assert_eq!(response, Some(expected_response));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_permissions_tool_resolves_relative_paths_against_selected_environment() {
|
||||
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let environment_cwd = {
|
||||
#[allow(deprecated)]
|
||||
let legacy_cwd = turn_context.cwd.clone();
|
||||
legacy_cwd.join("request-permissions-environment")
|
||||
};
|
||||
std::fs::create_dir_all(environment_cwd.as_path()).expect("create environment cwd");
|
||||
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref");
|
||||
turn_context_mut
|
||||
.approval_policy
|
||||
.set(AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}))
|
||||
.expect("test setup should allow updating approval policy");
|
||||
turn_context_mut.environments.turn_environments[0].environment_id = "remote".to_string();
|
||||
turn_context_mut.environments.turn_environments[0].cwd = environment_cwd.clone();
|
||||
|
||||
let call_id = "call-1".to_string();
|
||||
let handler = RequestPermissionsHandler;
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let handle = tokio::spawn({
|
||||
let session = Arc::clone(&session);
|
||||
let turn_context = Arc::clone(&turn_context);
|
||||
let tracker = Arc::clone(&tracker);
|
||||
let call_id = call_id.clone();
|
||||
async move {
|
||||
handler
|
||||
.handle(ToolInvocation {
|
||||
session,
|
||||
turn: turn_context,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
tracker,
|
||||
call_id,
|
||||
tool_name: codex_tools::ToolName::plain("request_permissions"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: json!({
|
||||
"environment_id": "remote",
|
||||
"reason": "need write",
|
||||
"permissions": {
|
||||
"file_system": {
|
||||
"entries": [{
|
||||
"path": {
|
||||
"type": "path",
|
||||
"path": "relative.txt",
|
||||
},
|
||||
"access": "write",
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("request_permissions event timed out")
|
||||
.expect("request_permissions event missing");
|
||||
let EventMsg::RequestPermissions(request) = request_event.msg else {
|
||||
panic!("expected request_permissions event");
|
||||
};
|
||||
let expected_permissions = RequestPermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
entries: vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: environment_cwd.join("relative.txt"),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
}],
|
||||
glob_scan_max_depth: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(request.environment_id.as_deref(), Some("remote"));
|
||||
assert_eq!(request.permissions, expected_permissions);
|
||||
|
||||
session
|
||||
.notify_request_permissions_response(
|
||||
&request.call_id,
|
||||
codex_protocol::request_permissions::RequestPermissionsResponse {
|
||||
permissions: request.permissions,
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
tokio::time::timeout(StdDuration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("request_permissions handler timed out")
|
||||
.expect("request_permissions handler join error")
|
||||
.expect("request_permissions handler should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_permissions_tool_rejects_unknown_environment_id() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let result = RequestPermissionsHandler
|
||||
.handle(ToolInvocation {
|
||||
session: Arc::new(session),
|
||||
turn: Arc::new(turn_context),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: codex_tools::ToolName::plain("request_permissions"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: json!({
|
||||
"environment_id": "missing",
|
||||
"permissions": {
|
||||
"network": {
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
})
|
||||
.await;
|
||||
|
||||
let Err(FunctionCallError::RespondToModel(output)) = result else {
|
||||
panic!("expected unknown environment id to be rejected");
|
||||
};
|
||||
assert_eq!(output, "unknown turn environment id `missing`");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_permissions_response_materializes_session_cwd_grants_before_recording() {
|
||||
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
@@ -5371,14 +5593,21 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec
|
||||
let call_id = call_id.clone();
|
||||
let requested_permissions = requested_permissions.clone();
|
||||
async move {
|
||||
let environment = turn_context
|
||||
.environments
|
||||
.primary()
|
||||
.expect("primary environment")
|
||||
.selection();
|
||||
session
|
||||
.request_permissions(
|
||||
.request_permissions_for_environment(
|
||||
&turn_context,
|
||||
call_id,
|
||||
codex_protocol::request_permissions::RequestPermissionsArgs {
|
||||
environment_id: None,
|
||||
reason: Some("need cwd write".to_string()),
|
||||
permissions: requested_permissions,
|
||||
},
|
||||
environment,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
@@ -5392,6 +5621,10 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec
|
||||
let EventMsg::RequestPermissions(request) = request_event.msg else {
|
||||
panic!("expected request_permissions event");
|
||||
};
|
||||
assert_eq!(
|
||||
request.environment_id.as_deref(),
|
||||
Some(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
||||
);
|
||||
let request_cwd = request.cwd.clone().expect("request cwd");
|
||||
|
||||
session
|
||||
@@ -5425,7 +5658,9 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec
|
||||
|
||||
assert_eq!(response, Some(expected_response));
|
||||
assert_eq!(
|
||||
session.granted_session_permissions().await,
|
||||
session
|
||||
.granted_session_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
||||
.await,
|
||||
Some(expected_permissions.into())
|
||||
);
|
||||
}
|
||||
@@ -5449,11 +5684,17 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let call_id = "call-1".to_string();
|
||||
let environment = turn_context
|
||||
.environments
|
||||
.primary()
|
||||
.expect("primary environment")
|
||||
.selection();
|
||||
let response = session
|
||||
.request_permissions(
|
||||
.request_permissions_for_environment(
|
||||
&turn_context,
|
||||
call_id,
|
||||
codex_protocol::request_permissions::RequestPermissionsArgs {
|
||||
environment_id: None,
|
||||
reason: Some("need network".to_string()),
|
||||
permissions: RequestPermissionProfile {
|
||||
network: Some(codex_protocol::models::NetworkPermissions {
|
||||
@@ -5462,6 +5703,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
|
||||
..RequestPermissionProfile::default()
|
||||
},
|
||||
},
|
||||
environment,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await;
|
||||
@@ -6859,7 +7101,7 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
RequirementSource::CloudRequirements,
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
));
|
||||
let layers = config
|
||||
.config_layer_stack
|
||||
@@ -10450,7 +10692,12 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
);
|
||||
|
||||
pretty_assertions::assert_eq!(output, expected);
|
||||
pretty_assertions::assert_eq!(session.granted_turn_permissions().await, None);
|
||||
pretty_assertions::assert_eq!(
|
||||
session
|
||||
.granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
||||
.await,
|
||||
None
|
||||
);
|
||||
|
||||
// The rejection should not poison the non-escalated path for the same
|
||||
// command. Force DangerFullAccess so this check stays focused on approval
|
||||
|
||||
@@ -124,15 +124,22 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
|
||||
}),
|
||||
..RequestPermissionProfile::default()
|
||||
};
|
||||
let environment = turn_context
|
||||
.environments
|
||||
.primary()
|
||||
.expect("primary environment")
|
||||
.selection();
|
||||
let response = tokio::time::timeout(
|
||||
Duration::from_secs(45),
|
||||
session.request_permissions(
|
||||
session.request_permissions_for_environment(
|
||||
&turn_context,
|
||||
"perm-call-1".to_string(),
|
||||
RequestPermissionsArgs {
|
||||
environment_id: None,
|
||||
reason: Some("need network".to_string()),
|
||||
permissions: requested_permissions.clone(),
|
||||
},
|
||||
environment,
|
||||
CancellationToken::new(),
|
||||
),
|
||||
)
|
||||
@@ -148,7 +155,9 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() {
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
session.granted_turn_permissions().await,
|
||||
session
|
||||
.granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
||||
.await,
|
||||
Some(requested_permissions.into())
|
||||
);
|
||||
|
||||
@@ -211,14 +220,21 @@ async fn request_permissions_guardian_review_stops_when_cancelled() {
|
||||
let requested_permissions = requested_permissions.clone();
|
||||
let cancellation_token = cancellation_token.clone();
|
||||
async move {
|
||||
let environment = turn_context
|
||||
.environments
|
||||
.primary()
|
||||
.expect("primary environment")
|
||||
.selection();
|
||||
session
|
||||
.request_permissions(
|
||||
.request_permissions_for_environment(
|
||||
&turn_context,
|
||||
"perm-call-cancelled".to_string(),
|
||||
RequestPermissionsArgs {
|
||||
environment_id: None,
|
||||
reason: Some("need network".to_string()),
|
||||
permissions: requested_permissions,
|
||||
},
|
||||
environment,
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
@@ -246,7 +262,12 @@ async fn request_permissions_guardian_review_stops_when_cancelled() {
|
||||
.expect("request_permissions should stop when cancelled")
|
||||
.expect("request_permissions task should not panic");
|
||||
assert_eq!(response, None);
|
||||
assert_eq!(session.granted_turn_permissions().await, None);
|
||||
assert_eq!(
|
||||
session
|
||||
.granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
||||
.await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -380,6 +401,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: true,
|
||||
},
|
||||
codex_exec_server::LOCAL_ENVIRONMENT_ID,
|
||||
Some(&originating_turn_state),
|
||||
)
|
||||
.await;
|
||||
@@ -564,12 +586,15 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per
|
||||
let mut active_turn = session.active_turn.lock().await;
|
||||
let active_turn = active_turn.as_mut().expect("active turn");
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
turn_state.record_granted_permissions(PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
turn_state.record_granted_permissions(
|
||||
codex_exec_server::LOCAL_ENVIRONMENT_ID,
|
||||
PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let session = Arc::new(session);
|
||||
|
||||
@@ -369,7 +369,7 @@ pub(crate) async fn run_turn(
|
||||
// Aborted turn is reported via a different event.
|
||||
break;
|
||||
}
|
||||
Err(CodexErr::InvalidImageRequest()) => {
|
||||
Err(codex_error @ CodexErr::InvalidImageRequest()) => {
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
error_or_panic(
|
||||
@@ -380,6 +380,7 @@ pub(crate) async fn run_turn(
|
||||
}
|
||||
}
|
||||
|
||||
sess.track_turn_codex_error(turn_context.as_ref(), &codex_error);
|
||||
let error = CodexErrorInfo::BadRequest;
|
||||
sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone())
|
||||
.await;
|
||||
@@ -405,6 +406,7 @@ pub(crate) async fn run_turn(
|
||||
{
|
||||
warn!("failed to usage-limit active goal after usage-limit error: {err}");
|
||||
}
|
||||
sess.track_turn_codex_error(turn_context.as_ref(), &e);
|
||||
let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None));
|
||||
sess.send_event(&turn_context, event).await;
|
||||
// let the user continue the conversation
|
||||
@@ -638,6 +640,7 @@ async fn track_turn_resolved_config_analytics(
|
||||
sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(),
|
||||
collaboration_mode: turn_context.collaboration_mode.mode,
|
||||
personality: turn_context.personality,
|
||||
workspace_kind: turn_context.turn_metadata_state.workspace_kind(),
|
||||
is_first_turn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
@@ -37,7 +38,7 @@ pub(crate) struct SessionState {
|
||||
pub(crate) startup_prewarm: Option<SessionStartupPrewarmHandle>,
|
||||
pub(crate) active_connector_selection: HashSet<String>,
|
||||
pub(crate) pending_session_start_sources: VecDeque<codex_hooks::SessionStartSource>,
|
||||
granted_permissions: Option<AdditionalPermissionProfile>,
|
||||
granted_permissions_by_environment_id: HashMap<String, AdditionalPermissionProfile>,
|
||||
next_turn_is_first: bool,
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ impl SessionState {
|
||||
startup_prewarm: None,
|
||||
active_connector_selection: HashSet::new(),
|
||||
pending_session_start_sources: VecDeque::new(),
|
||||
granted_permissions: None,
|
||||
granted_permissions_by_environment_id: HashMap::new(),
|
||||
next_turn_is_first: true,
|
||||
}
|
||||
}
|
||||
@@ -235,13 +236,29 @@ impl SessionState {
|
||||
self.pending_session_start_sources.pop_front()
|
||||
}
|
||||
|
||||
pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) {
|
||||
self.granted_permissions =
|
||||
merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions));
|
||||
pub(crate) fn record_granted_permissions(
|
||||
&mut self,
|
||||
environment_id: &str,
|
||||
permissions: AdditionalPermissionProfile,
|
||||
) {
|
||||
let granted_permissions = merge_permission_profiles(
|
||||
self.granted_permissions_by_environment_id
|
||||
.get(environment_id),
|
||||
Some(&permissions),
|
||||
);
|
||||
if let Some(granted_permissions) = granted_permissions {
|
||||
self.granted_permissions_by_environment_id
|
||||
.insert(environment_id.to_string(), granted_permissions);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn granted_permissions(&self) -> Option<AdditionalPermissionProfile> {
|
||||
self.granted_permissions.clone()
|
||||
pub(crate) fn granted_permissions(
|
||||
&self,
|
||||
environment_id: &str,
|
||||
) -> Option<AdditionalPermissionProfile> {
|
||||
self.granted_permissions_by_environment_id
|
||||
.get(environment_id)
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Turn-scoped state and active turn metadata scaffolding.
|
||||
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -10,11 +9,12 @@ use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
use rmcp::model::RequestId;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
@@ -90,7 +90,7 @@ pub(crate) struct TurnState {
|
||||
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
|
||||
pub(crate) pending_input: TurnInputQueue,
|
||||
mailbox_delivery_phase: MailboxDeliveryPhase,
|
||||
granted_permissions: Option<AdditionalPermissionProfile>,
|
||||
granted_permissions_by_environment_id: HashMap<String, AdditionalPermissionProfile>,
|
||||
strict_auto_review_enabled: bool,
|
||||
pub(crate) tool_calls: u64,
|
||||
pub(crate) has_memory_citation: bool,
|
||||
@@ -100,7 +100,7 @@ pub(crate) struct TurnState {
|
||||
pub(crate) struct PendingRequestPermissions {
|
||||
pub(crate) tx_response: oneshot::Sender<RequestPermissionsResponse>,
|
||||
pub(crate) requested_permissions: RequestPermissionProfile,
|
||||
pub(crate) cwd: AbsolutePathBuf,
|
||||
pub(crate) environment: TurnEnvironmentSelection,
|
||||
}
|
||||
|
||||
impl TurnState {
|
||||
@@ -204,13 +204,29 @@ impl TurnState {
|
||||
self.mailbox_delivery_phase = phase;
|
||||
}
|
||||
|
||||
pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) {
|
||||
self.granted_permissions =
|
||||
merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions));
|
||||
pub(crate) fn record_granted_permissions(
|
||||
&mut self,
|
||||
environment_id: &str,
|
||||
permissions: AdditionalPermissionProfile,
|
||||
) {
|
||||
let granted_permissions = merge_permission_profiles(
|
||||
self.granted_permissions_by_environment_id
|
||||
.get(environment_id),
|
||||
Some(&permissions),
|
||||
);
|
||||
if let Some(granted_permissions) = granted_permissions {
|
||||
self.granted_permissions_by_environment_id
|
||||
.insert(environment_id.to_string(), granted_permissions);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn granted_permissions(&self) -> Option<AdditionalPermissionProfile> {
|
||||
self.granted_permissions.clone()
|
||||
pub(crate) fn granted_permissions(
|
||||
&self,
|
||||
environment_id: &str,
|
||||
) -> Option<AdditionalPermissionProfile> {
|
||||
self.granted_permissions_by_environment_id
|
||||
.get(environment_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn enable_strict_auto_review(&mut self) {
|
||||
|
||||
@@ -131,6 +131,7 @@ pub(crate) async fn persist_image_generation_item(
|
||||
turn_context: &TurnContext,
|
||||
image_item: &mut ImageGenerationItem,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
image_item.saved_path = None;
|
||||
let session_id = sess.conversation_id.to_string();
|
||||
match save_image_generation_result(
|
||||
&turn_context.config.codex_home,
|
||||
@@ -163,15 +164,12 @@ pub(crate) async fn persist_image_generation_item(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn finalize_image_generation_item(
|
||||
async fn record_image_generation_instructions(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
image_item: &mut ImageGenerationItem,
|
||||
image_item: &ImageGenerationItem,
|
||||
) {
|
||||
if persist_image_generation_item(sess, turn_context, image_item)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
if image_item.saved_path.is_none() {
|
||||
return;
|
||||
}
|
||||
let session_id = sess.conversation_id.to_string();
|
||||
@@ -530,27 +528,16 @@ pub(crate) async fn handle_non_tool_response_item(
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. } => {
|
||||
let mut turn_item = parse_turn_item(item)?;
|
||||
if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy {
|
||||
apply_turn_item_contributors(sess, turn_store, &mut turn_item).await;
|
||||
}
|
||||
if let TurnItem::AgentMessage(agent_message) = &mut turn_item {
|
||||
let combined = agent_message
|
||||
.content
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(),
|
||||
})
|
||||
.collect::<String>();
|
||||
let (stripped, memory_citation) =
|
||||
strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode);
|
||||
agent_message.content =
|
||||
vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }];
|
||||
if agent_message.memory_citation.is_none() {
|
||||
agent_message.memory_citation = memory_citation;
|
||||
}
|
||||
}
|
||||
if let TurnItem::ImageGeneration(image_item) = &mut turn_item {
|
||||
finalize_image_generation_item(sess, turn_context, image_item).await;
|
||||
finalize_turn_item(
|
||||
sess,
|
||||
turn_context,
|
||||
contributor_policy,
|
||||
&mut turn_item,
|
||||
plan_mode,
|
||||
)
|
||||
.await;
|
||||
if let TurnItem::ImageGeneration(image_item) = &turn_item {
|
||||
record_image_generation_instructions(sess, turn_context, image_item).await;
|
||||
}
|
||||
Some(turn_item)
|
||||
}
|
||||
@@ -564,6 +551,37 @@ pub(crate) async fn handle_non_tool_response_item(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn finalize_turn_item(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
contributor_policy: TurnItemContributorPolicy<'_>,
|
||||
turn_item: &mut TurnItem,
|
||||
plan_mode: bool,
|
||||
) {
|
||||
if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy {
|
||||
apply_turn_item_contributors(sess, turn_store, turn_item).await;
|
||||
}
|
||||
if let TurnItem::AgentMessage(agent_message) = &mut *turn_item {
|
||||
let combined = agent_message
|
||||
.content
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(),
|
||||
})
|
||||
.collect::<String>();
|
||||
let (stripped, memory_citation) =
|
||||
strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode);
|
||||
agent_message.content =
|
||||
vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }];
|
||||
if agent_message.memory_citation.is_none() {
|
||||
agent_message.memory_citation = memory_citation;
|
||||
}
|
||||
}
|
||||
if let TurnItem::ImageGeneration(image_item) = &mut *turn_item {
|
||||
persist_image_generation_item(sess, turn_context, image_item).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn last_assistant_message_from_item(
|
||||
item: &ResponseItem,
|
||||
plan_mode: bool,
|
||||
|
||||
@@ -265,6 +265,7 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option<String> {
|
||||
async fn effective_patch_permissions(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
environment_id: &str,
|
||||
action: &ApplyPatchAction,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> (
|
||||
@@ -274,8 +275,14 @@ async fn effective_patch_permissions(
|
||||
) {
|
||||
let file_paths = file_paths_for_action(action);
|
||||
let granted_permissions = merge_permission_profiles(
|
||||
session.granted_session_permissions().await.as_ref(),
|
||||
session.granted_turn_permissions().await.as_ref(),
|
||||
session
|
||||
.granted_session_permissions(environment_id)
|
||||
.await
|
||||
.as_ref(),
|
||||
session
|
||||
.granted_turn_permissions(environment_id)
|
||||
.await
|
||||
.as_ref(),
|
||||
);
|
||||
let base_file_system_sandbox_policy = turn.file_system_sandbox_policy();
|
||||
let file_system_sandbox_policy = effective_file_system_sandbox_policy(
|
||||
@@ -284,6 +291,7 @@ async fn effective_patch_permissions(
|
||||
);
|
||||
let effective_additional_permissions = apply_granted_turn_permissions(
|
||||
session,
|
||||
environment_id,
|
||||
cwd.as_path(),
|
||||
crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd),
|
||||
@@ -353,8 +361,14 @@ impl ToolExecutor<ToolInvocation> for ApplyPatchHandler {
|
||||
{
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
|
||||
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, &cwd)
|
||||
.await;
|
||||
effective_patch_permissions(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&turn_environment.environment_id,
|
||||
&changes,
|
||||
&cwd,
|
||||
)
|
||||
.await;
|
||||
match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes)
|
||||
.await
|
||||
{
|
||||
@@ -506,7 +520,14 @@ pub(crate) async fn intercept_apply_patch(
|
||||
{
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) =
|
||||
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, cwd).await;
|
||||
effective_patch_permissions(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&turn_environment.environment_id,
|
||||
&changes,
|
||||
cwd,
|
||||
)
|
||||
.await;
|
||||
match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::sync::Weak;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_tools::ConversationHistory;
|
||||
use codex_tools::ExtensionTurnItem;
|
||||
use codex_tools::ImageGenerationCompletionFuture;
|
||||
use codex_tools::ToolCall as ExtensionToolCall;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSearchInfo;
|
||||
@@ -12,12 +11,11 @@ use codex_tools::ToolSpec;
|
||||
use codex_tools::TurnItemEmissionFuture;
|
||||
use codex_tools::TurnItemEmitter;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::ImageGenerationInstructions;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::stream_events_utils::persist_image_generation_item;
|
||||
use crate::stream_events_utils::TurnItemContributorPolicy;
|
||||
use crate::stream_events_utils::finalize_turn_item;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -76,6 +74,10 @@ struct CoreTurnItemEmitter {
|
||||
fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem {
|
||||
match item {
|
||||
ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item),
|
||||
ExtensionTurnItem::ImageGeneration(mut item) => {
|
||||
item.saved_path = None;
|
||||
TurnItem::ImageGeneration(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +87,9 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
|
||||
let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
|
||||
return;
|
||||
};
|
||||
let item = extension_turn_item(item);
|
||||
session.emit_turn_item_started(turn.as_ref(), &item).await;
|
||||
session
|
||||
.emit_turn_item_started(turn.as_ref(), &extension_turn_item(item))
|
||||
.await;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,54 +98,18 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
|
||||
let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
|
||||
return;
|
||||
};
|
||||
let item = extension_turn_item(item);
|
||||
let mut item = extension_turn_item(item);
|
||||
finalize_turn_item(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
TurnItemContributorPolicy::Run(turn.extension_data.as_ref()),
|
||||
&mut item,
|
||||
turn.collaboration_mode.mode == codex_protocol::config_types::ModeKind::Plan,
|
||||
)
|
||||
.await;
|
||||
session.emit_turn_item_completed(turn.as_ref(), item).await;
|
||||
})
|
||||
}
|
||||
|
||||
fn image_generation_completed<'a>(
|
||||
&'a self,
|
||||
call_id: String,
|
||||
prompt: String,
|
||||
result: String,
|
||||
) -> ImageGenerationCompletionFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
|
||||
return None;
|
||||
};
|
||||
let mut item = codex_protocol::items::ImageGenerationItem {
|
||||
id: call_id,
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some(prompt),
|
||||
result,
|
||||
saved_path: None,
|
||||
};
|
||||
let output_hint =
|
||||
persist_image_generation_item(session.as_ref(), turn.as_ref(), &mut item)
|
||||
.await
|
||||
.map(|saved_path| {
|
||||
let output_dir = saved_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| turn.config.codex_home.clone());
|
||||
ImageGenerationInstructions::new(output_dir.display(), saved_path.display())
|
||||
.body()
|
||||
});
|
||||
let started_item = codex_protocol::items::ImageGenerationItem {
|
||||
id: item.id.clone(),
|
||||
status: "in_progress".to_string(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
saved_path: None,
|
||||
};
|
||||
session
|
||||
.emit_turn_item_started(turn.as_ref(), &TurnItem::ImageGeneration(started_item))
|
||||
.await;
|
||||
session
|
||||
.emit_turn_item_completed(turn.as_ref(), TurnItem::ImageGeneration(item))
|
||||
.await;
|
||||
output_hint
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
|
||||
@@ -167,6 +134,8 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::TurnItemContributor;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::WebSearchItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
@@ -174,10 +143,13 @@ mod tests {
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_tools::ExtensionTurnItem;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::CoreTurnItemEmitter;
|
||||
use super::ExtensionToolAdapter;
|
||||
use crate::tools::context::ToolCallSource;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
@@ -409,8 +381,54 @@ mod tests {
|
||||
assert_eq!(end.action, expected.action);
|
||||
}
|
||||
|
||||
struct ImageGenerationExtensionExecutor {
|
||||
output_hint: Arc<Mutex<Option<String>>>,
|
||||
struct ImageGenerationExtensionExecutor;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ExtensionTurnItemContributorRan;
|
||||
|
||||
struct RecordExtensionTurnItemContributor;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TurnItemContributor for RecordExtensionTurnItemContributor {
|
||||
async fn contribute(
|
||||
&self,
|
||||
_thread_store: &ExtensionData,
|
||||
turn_store: &ExtensionData,
|
||||
_item: &mut TurnItem,
|
||||
) -> Result<(), String> {
|
||||
turn_store.insert(ExtensionTurnItemContributorRan);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension_completion_runs_turn_item_contributors() {
|
||||
let (mut session, turn) = crate::session::tests::make_session_and_context().await;
|
||||
let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();
|
||||
builder.turn_item_contributor(Arc::new(RecordExtensionTurnItemContributor));
|
||||
session.services.extensions = Arc::new(builder.build());
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let emitter = CoreTurnItemEmitter {
|
||||
session: Arc::downgrade(&session),
|
||||
turn: Arc::downgrade(&turn),
|
||||
};
|
||||
|
||||
codex_tools::TurnItemEmitter::emit_completed(
|
||||
&emitter,
|
||||
ExtensionTurnItem::WebSearch(WebSearchItem {
|
||||
id: "search-1".to_string(),
|
||||
query: "contributors".to_string(),
|
||||
action: WebSearchAction::Other,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
turn.extension_data
|
||||
.get::<ExtensionTurnItemContributorRan>()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -434,15 +452,28 @@ mod tests {
|
||||
&self,
|
||||
call: codex_tools::ToolCall,
|
||||
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
|
||||
let output_hint = call
|
||||
.turn_item_emitter
|
||||
.image_generation_completed(
|
||||
call.call_id,
|
||||
"A tiny blue square".to_string(),
|
||||
"cG5n".to_string(),
|
||||
)
|
||||
call.turn_item_emitter
|
||||
.emit_started(ExtensionTurnItem::ImageGeneration(
|
||||
codex_protocol::items::ImageGenerationItem {
|
||||
id: call.call_id.clone(),
|
||||
status: "in_progress".to_string(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
saved_path: None,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
call.turn_item_emitter
|
||||
.emit_completed(ExtensionTurnItem::ImageGeneration(
|
||||
codex_protocol::items::ImageGenerationItem {
|
||||
id: call.call_id,
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A tiny blue square".to_string()),
|
||||
result: "cG5n".to_string(),
|
||||
saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
*self.output_hint.lock().await = output_hint;
|
||||
Ok(Box::new(codex_tools::JsonToolOutput::new(
|
||||
json!({ "ok": true }),
|
||||
)))
|
||||
@@ -451,10 +482,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_generation_publication_is_finalized_by_core() {
|
||||
let output_hint = Arc::new(Mutex::new(None));
|
||||
let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor {
|
||||
output_hint: Arc::clone(&output_hint),
|
||||
}));
|
||||
let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor));
|
||||
let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
let expected_path = crate::stream_events_utils::image_generation_artifact_path(
|
||||
&turn.config.codex_home,
|
||||
@@ -521,17 +549,5 @@ mod tests {
|
||||
std::fs::read(&expected_path).expect("generated artifact should be saved"),
|
||||
b"png"
|
||||
);
|
||||
assert_eq!(
|
||||
*output_hint.lock().await,
|
||||
Some(format!(
|
||||
"Generated images are saved to {} as {} by default.\n\
|
||||
If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
|
||||
expected_path
|
||||
.parent()
|
||||
.expect("generated image path should have a parent")
|
||||
.display(),
|
||||
expected_path.display(),
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,8 @@ pub(super) fn implicit_granted_permissions(
|
||||
|
||||
pub(super) async fn apply_granted_turn_permissions(
|
||||
session: &Session,
|
||||
cwd: &std::path::Path,
|
||||
environment_id: &str,
|
||||
cwd: &Path,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
) -> EffectiveAdditionalPermissions {
|
||||
@@ -262,8 +263,8 @@ pub(super) async fn apply_granted_turn_permissions(
|
||||
};
|
||||
}
|
||||
|
||||
let granted_session_permissions = session.granted_session_permissions().await;
|
||||
let granted_turn_permissions = session.granted_turn_permissions().await;
|
||||
let granted_session_permissions = session.granted_session_permissions(environment_id).await;
|
||||
let granted_turn_permissions = session.granted_turn_permissions(environment_id).await;
|
||||
let granted_permissions = merge_permission_profiles(
|
||||
granted_session_permissions.as_ref(),
|
||||
granted_turn_permissions.as_ref(),
|
||||
|
||||
@@ -6,16 +6,25 @@ use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_tool_environment;
|
||||
use crate::tools::handlers::shell_spec::create_request_permissions_tool;
|
||||
use crate::tools::handlers::shell_spec::request_permissions_tool_description;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct RequestPermissionsHandler;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RequestPermissionsEnvironmentArgs {
|
||||
#[serde(default, rename = "environment_id", alias = "environmentId")]
|
||||
environment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
@@ -48,9 +57,16 @@ impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
|
||||
}
|
||||
};
|
||||
|
||||
#[allow(deprecated)]
|
||||
let environment_args: RequestPermissionsEnvironmentArgs = parse_arguments(&arguments)?;
|
||||
let Some(turn_environment) =
|
||||
resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())?
|
||||
else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"request_permissions requires a primary environment".to_string(),
|
||||
));
|
||||
};
|
||||
let mut args: RequestPermissionsArgs =
|
||||
parse_arguments_with_base_path(&arguments, &turn.cwd)?;
|
||||
parse_arguments_with_base_path(&arguments, &turn_environment.cwd)?;
|
||||
args.permissions = normalize_additional_permissions(args.permissions.into())
|
||||
.map(codex_protocol::request_permissions::RequestPermissionProfile::from)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
@@ -61,7 +77,13 @@ impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
|
||||
}
|
||||
|
||||
let response = session
|
||||
.request_permissions(&turn, call_id, args, cancellation_token)
|
||||
.request_permissions_for_environment(
|
||||
&turn,
|
||||
call_id,
|
||||
args,
|
||||
turn_environment.selection(),
|
||||
cancellation_token,
|
||||
)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
|
||||
@@ -84,10 +84,10 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
|
||||
let exec_permission_approvals_enabled =
|
||||
session.features().enabled(Feature::ExecPermissionApprovals);
|
||||
let requested_additional_permissions = additional_permissions.clone();
|
||||
#[allow(deprecated)]
|
||||
let effective_additional_permissions = apply_granted_turn_permissions(
|
||||
session.as_ref(),
|
||||
turn.cwd.as_path(),
|
||||
&turn_environment.environment_id,
|
||||
exec_params.cwd.as_path(),
|
||||
exec_params.sandbox_permissions,
|
||||
additional_permissions,
|
||||
)
|
||||
|
||||
@@ -229,6 +229,13 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec {
|
||||
"Optional short explanation for why additional permissions are needed.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"environment_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Environment id from <environment_context>. Omit to use the primary environment."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
("permissions".to_string(), permission_profile_schema()),
|
||||
]);
|
||||
|
||||
@@ -247,7 +254,7 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec {
|
||||
}
|
||||
|
||||
pub fn request_permissions_tool_description() -> String {
|
||||
"Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope."
|
||||
"Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Use environment_id to target a specific attached environment; omit it to use the primary environment. Relative filesystem paths resolve against the selected environment cwd. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,13 @@ fn request_permissions_tool_includes_full_permission_schema() {
|
||||
"Optional short explanation for why additional permissions are needed.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"environment_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Environment id from <environment_context>. Omit to use the primary environment."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
("permissions".to_string(), permission_profile_schema()),
|
||||
]);
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
|
||||
let requested_additional_permissions = additional_permissions.clone();
|
||||
let effective_additional_permissions = apply_granted_turn_permissions(
|
||||
context.session.as_ref(),
|
||||
&turn_environment.environment_id,
|
||||
cwd.as_path(),
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user