Merge 7669479964 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-02-24 22:29:30 -08:00
committed by GitHub
59 changed files with 2164 additions and 572 deletions
+12
View File
@@ -47,6 +47,11 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up Node.js for js_repl tests
uses: actions/setup-node@v6
with:
node-version-file: codex-rs/node-version.txt
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
@@ -156,6 +161,13 @@ jobs:
--build_metadata=VISIBILITY=PUBLIC
)
if [[ "${RUNNER_OS:-}" != "Windows" ]]; then
# Bazel test sandboxes on macOS may resolve an older Homebrew `node`
# before the `actions/setup-node` runtime on PATH.
node_bin="$(which node)"
bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}")
fi
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
echo "BuildBuddy API key is available; using remote Bazel configuration."
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
+4
View File
@@ -499,6 +499,10 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up Node.js for js_repl tests
uses: actions/setup-node@v6
with:
node-version-file: codex-rs/node-version.txt
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
+6
View File
@@ -178,6 +178,12 @@ jobs:
shell: bash
run: |
set -euo pipefail
# Avoid problematic aws-lc jitter entropy code path on musl builders.
echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV"
target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}"
target_no_jitter="${target_no_jitter//-/_}"
echo "${target_no_jitter}=1" >> "$GITHUB_ENV"
# Clear global Rust flags so host/proc-macro builds don't pull in UBSan.
echo "RUSTFLAGS=" >> "$GITHUB_ENV"
echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV"
@@ -1,6 +1,97 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AdditionalFileSystemPermissions": {
"properties": {
"read": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"write": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"AdditionalMacOsPermissions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
},
"calendar": {
"type": [
"boolean",
"null"
]
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AdditionalPermissionProfile": {
"properties": {
"fileSystem": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalFileSystemPermissions"
},
{
"type": "null"
}
]
},
"macos": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalMacOsPermissions"
},
{
"type": "null"
}
]
},
"network": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"CommandAction": {
"oneOf": [
{
@@ -111,6 +202,29 @@
}
]
},
"MacOsAutomationValue": {
"anyOf": [
{
"type": "boolean"
},
{
"items": {
"type": "string"
},
"type": "array"
}
]
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
},
"NetworkApprovalContext": {
"properties": {
"host": {
@@ -5,23 +5,6 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AdditionalPermissions": {
"properties": {
"fs_read": {
"items": {
"type": "string"
},
"type": "array"
},
"fs_write": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"AgentMessageContent": {
"oneOf": [
{
@@ -1633,7 +1616,7 @@
"additional_permissions": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalPermissions"
"$ref": "#/definitions/PermissionProfile"
},
{
"type": "null"
@@ -3330,6 +3313,29 @@
}
]
},
"FileSystemPermissions": {
"properties": {
"read": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"write": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"FunctionCallOutputBody": {
"anyOf": [
{
@@ -3523,6 +3529,66 @@
],
"type": "string"
},
"MacOsAutomationValue": {
"anyOf": [
{
"type": "boolean"
},
{
"items": {
"type": "string"
},
"type": "array"
}
]
},
"MacOsPermissions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
},
"calendar": {
"type": [
"boolean",
"null"
]
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
},
"McpAuthStatus": {
"enum": [
"unsupported",
@@ -3840,6 +3906,37 @@
],
"type": "string"
},
"PermissionProfile": {
"properties": {
"file_system": {
"anyOf": [
{
"$ref": "#/definitions/FileSystemPermissions"
},
{
"type": "null"
}
]
},
"macos": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPermissions"
},
{
"type": "null"
}
]
},
"network": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {
@@ -6945,7 +7042,7 @@
"additional_permissions": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalPermissions"
"$ref": "#/definitions/PermissionProfile"
},
{
"type": "null"
@@ -1,6 +1,97 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AdditionalFileSystemPermissions": {
"properties": {
"read": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"write": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"AdditionalMacOsPermissions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
},
"calendar": {
"type": [
"boolean",
"null"
]
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AdditionalPermissionProfile": {
"properties": {
"fileSystem": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalFileSystemPermissions"
},
{
"type": "null"
}
]
},
"macos": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalMacOsPermissions"
},
{
"type": "null"
}
]
},
"network": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"ApplyPatchApprovalParams": {
"properties": {
"callId": {
@@ -430,6 +521,29 @@
],
"type": "object"
},
"MacOsAutomationValue": {
"anyOf": [
{
"type": "boolean"
},
{
"items": {
"type": "string"
},
"type": "array"
}
]
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
},
"NetworkApprovalContext": {
"properties": {
"host": {
@@ -1,19 +1,93 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AdditionalPermissions": {
"AdditionalFileSystemPermissions": {
"properties": {
"fs_read": {
"read": {
"items": {
"type": "string"
},
"type": "array"
"type": [
"array",
"null"
]
},
"fs_write": {
"write": {
"items": {
"type": "string"
},
"type": "array"
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"AdditionalMacOsPermissions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
},
"calendar": {
"type": [
"boolean",
"null"
]
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AdditionalPermissionProfile": {
"properties": {
"fileSystem": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalFileSystemPermissions"
},
{
"type": "null"
}
]
},
"macos": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalMacOsPermissions"
},
{
"type": "null"
}
]
},
"network": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
@@ -2694,7 +2768,7 @@
"additional_permissions": {
"anyOf": [
{
"$ref": "#/definitions/AdditionalPermissions"
"$ref": "#/definitions/PermissionProfile"
},
{
"type": "null"
@@ -4534,6 +4608,29 @@
"title": "FileChangeRequestApprovalResponse",
"type": "object"
},
"FileSystemPermissions": {
"properties": {
"read": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"write": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"type": "object"
},
"FuzzyFileSearchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -4834,6 +4931,66 @@
"title": "JSONRPCResponse",
"type": "object"
},
"MacOsAutomationValue": {
"anyOf": [
{
"type": "boolean"
},
{
"items": {
"type": "string"
},
"type": "array"
}
]
},
"MacOsPermissions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
},
"calendar": {
"type": [
"boolean",
"null"
]
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
},
"McpInvocation": {
"properties": {
"arguments": {
@@ -5093,6 +5250,37 @@
}
]
},
"PermissionProfile": {
"properties": {
"file_system": {
"anyOf": [
{
"$ref": "#/definitions/FileSystemPermissions"
},
{
"type": "null"
}
]
},
"macos": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPermissions"
},
{
"type": "null"
}
]
},
"network": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {
@@ -1,11 +1,11 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AdditionalPermissions } from "./AdditionalPermissions";
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
import type { ParsedCommand } from "./ParsedCommand";
import type { PermissionProfile } from "./PermissionProfile";
export type ExecApprovalRequestEvent = {
/**
@@ -51,4 +51,4 @@ proposed_network_policy_amendments?: Array<NetworkPolicyAmendment>,
/**
* Optional additional filesystem permissions requested for this command.
*/
additional_permissions?: AdditionalPermissions, parsed_cmd: Array<ParsedCommand>, };
additional_permissions?: PermissionProfile, parsed_cmd: Array<ParsedCommand>, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FileSystemPermissions = { read: Array<string> | null, write: Array<string> | null, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AdditionalPermissions = { fs_read?: Array<string>, fs_write?: Array<string>, };
export type MacOsAutomationValue = boolean | Array<string>;
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MacOsAutomationValue } from "./MacOsAutomationValue";
import type { MacOsPreferencesValue } from "./MacOsPreferencesValue";
export type MacOsPermissions = { preferences: MacOsPreferencesValue | null, automations: MacOsAutomationValue | null, accessibility: boolean | null, calendar: boolean | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MacOsPreferencesValue = boolean | string;
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FileSystemPermissions } from "./FileSystemPermissions";
import type { MacOsPermissions } from "./MacOsPermissions";
export type PermissionProfile = { network: boolean | null, file_system: FileSystemPermissions | null, macos: MacOsPermissions | null, };
@@ -3,7 +3,6 @@
export type { AbsolutePathBuf } from "./AbsolutePathBuf";
export type { AddConversationListenerParams } from "./AddConversationListenerParams";
export type { AddConversationSubscriptionResponse } from "./AddConversationSubscriptionResponse";
export type { AdditionalPermissions } from "./AdditionalPermissions";
export type { AgentMessageContent } from "./AgentMessageContent";
export type { AgentMessageContentDeltaEvent } from "./AgentMessageContentDeltaEvent";
export type { AgentMessageDeltaEvent } from "./AgentMessageDeltaEvent";
@@ -72,6 +71,7 @@ export type { ExecOutputStream } from "./ExecOutputStream";
export type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
export type { ExitedReviewModeEvent } from "./ExitedReviewModeEvent";
export type { FileChange } from "./FileChange";
export type { FileSystemPermissions } from "./FileSystemPermissions";
export type { ForcedLoginMethod } from "./ForcedLoginMethod";
export type { ForkConversationParams } from "./ForkConversationParams";
export type { ForkConversationResponse } from "./ForkConversationResponse";
@@ -117,6 +117,9 @@ export type { LoginApiKeyResponse } from "./LoginApiKeyResponse";
export type { LoginChatGptCompleteNotification } from "./LoginChatGptCompleteNotification";
export type { LoginChatGptResponse } from "./LoginChatGptResponse";
export type { LogoutChatGptResponse } from "./LogoutChatGptResponse";
export type { MacOsAutomationValue } from "./MacOsAutomationValue";
export type { MacOsPermissions } from "./MacOsPermissions";
export type { MacOsPreferencesValue } from "./MacOsPreferencesValue";
export type { McpAuthStatus } from "./McpAuthStatus";
export type { McpInvocation } from "./McpInvocation";
export type { McpListToolsResponseEvent } from "./McpListToolsResponseEvent";
@@ -141,6 +144,7 @@ export type { ParsedCommand } from "./ParsedCommand";
export type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent";
export type { PatchApplyEndEvent } from "./PatchApplyEndEvent";
export type { PatchApplyStatus } from "./PatchApplyStatus";
export type { PermissionProfile } from "./PermissionProfile";
export type { Personality } from "./Personality";
export type { PlanDeltaEvent } from "./PlanDeltaEvent";
export type { PlanItem } from "./PlanItem";
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AdditionalFileSystemPermissions = { read: Array<string> | null, write: Array<string> | null, };
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MacOsAutomationValue } from "../MacOsAutomationValue";
import type { MacOsPreferencesValue } from "../MacOsPreferencesValue";
export type AdditionalMacOsPermissions = { preferences: MacOsPreferencesValue | null, automations: MacOsAutomationValue | null, accessibility: boolean | null, calendar: boolean | null, };
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
import type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions";
export type AdditionalPermissionProfile = { network: boolean | null, fileSystem: AdditionalFileSystemPermissions | null, macos: AdditionalMacOsPermissions | null, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
import type { CommandAction } from "./CommandAction";
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
@@ -36,6 +37,10 @@ cwd?: string | null,
* Best-effort parsed command actions for friendly display.
*/
commandActions?: Array<CommandAction> | null,
/**
* Optional additional permissions requested for this command.
*/
additionalPermissions?: AdditionalPermissionProfile | null,
/**
* Optional proposed execpolicy amendment to allow similar commands without prompting.
*/
@@ -4,6 +4,9 @@ export type { Account } from "./Account";
export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification";
export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification";
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
export type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions";
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification";
export type { AnalyticsConfig } from "./AnalyticsConfig";
export type { AppBranding } from "./AppBranding";
@@ -1947,6 +1947,15 @@ mod tests {
let thread_start_ts =
fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?;
assert_eq!(thread_start_ts.contains("mockExperimentalField"), true);
let command_execution_request_approval_ts = fs::read_to_string(
output_dir
.join("v2")
.join("CommandExecutionRequestApprovalParams.ts"),
)?;
assert_eq!(
command_execution_request_approval_ts.contains("additionalPermissions"),
true
);
Ok(())
}
@@ -2083,6 +2092,12 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k
let thread_start_json =
fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.json"))?;
assert_eq!(thread_start_json.contains("mockExperimentalField"), false);
let command_execution_request_approval_json =
fs::read_to_string(output_dir.join("CommandExecutionRequestApprovalParams.json"))?;
assert_eq!(
command_execution_request_approval_json.contains("additionalPermissions"),
false
);
let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?;
assert_eq!(
@@ -2093,6 +2108,7 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k
let bundle_json =
fs::read_to_string(output_dir.join("codex_app_server_protocol.schemas.json"))?;
assert_eq!(bundle_json.contains("mockExperimentalField"), false);
assert_eq!(bundle_json.contains("additionalPermissions"), false);
assert_eq!(bundle_json.contains("MockExperimentalMethodParams"), false);
assert_eq!(
bundle_json.contains("MockExperimentalMethodResponse"),
@@ -1371,4 +1371,33 @@ mod tests {
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("mock/experimentalMethod"));
}
#[test]
fn command_execution_request_approval_additional_permissions_is_marked_experimental() {
let params = v2::CommandExecutionRequestApprovalParams {
thread_id: "thr_123".to_string(),
turn_id: "turn_123".to_string(),
item_id: "call_123".to_string(),
approval_id: None,
reason: None,
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: None,
command_actions: None,
additional_permissions: Some(v2::AdditionalPermissionProfile {
network: None,
file_system: Some(v2::AdditionalFileSystemPermissions {
read: Some(vec![std::path::PathBuf::from("/tmp/allowed")]),
write: None,
}),
macos: None,
}),
proposed_execpolicy_amendment: None,
};
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&params);
assert_eq!(
reason,
Some("item/commandExecution/requestApproval.additionalPermissions")
);
}
}
@@ -20,7 +20,12 @@ use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::mcp::Resource as McpResource;
use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate;
use codex_protocol::mcp::Tool as McpTool;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::MacOsAutomationValue as CoreMacOsAutomationValue;
use codex_protocol::models::MacOsPermissions as CoreMacOsPermissions;
use codex_protocol::models::MacOsPreferencesValue as CoreMacOsPreferencesValue;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ReasoningEffort;
@@ -713,6 +718,63 @@ impl From<CoreNetworkApprovalContext> for NetworkApprovalContext {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AdditionalFileSystemPermissions {
pub read: Option<Vec<PathBuf>>,
pub write: Option<Vec<PathBuf>>,
}
impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions {
fn from(value: CoreFileSystemPermissions) -> Self {
Self {
read: value.read,
write: value.write,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AdditionalMacOsPermissions {
pub preferences: Option<CoreMacOsPreferencesValue>,
pub automations: Option<CoreMacOsAutomationValue>,
pub accessibility: Option<bool>,
pub calendar: Option<bool>,
}
impl From<CoreMacOsPermissions> for AdditionalMacOsPermissions {
fn from(value: CoreMacOsPermissions) -> Self {
Self {
preferences: value.preferences,
automations: value.automations,
accessibility: value.accessibility,
calendar: value.calendar,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AdditionalPermissionProfile {
pub network: Option<bool>,
pub file_system: Option<AdditionalFileSystemPermissions>,
pub macos: Option<AdditionalMacOsPermissions>,
}
impl From<CorePermissionProfile> for AdditionalPermissionProfile {
fn from(value: CorePermissionProfile) -> Self {
Self {
network: value.network,
file_system: value.file_system.map(AdditionalFileSystemPermissions::from),
macos: value.macos.map(AdditionalMacOsPermissions::from),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3359,7 +3421,7 @@ pub struct ContextCompactedNotification {
pub turn_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct CommandExecutionRequestApprovalParams {
@@ -3396,12 +3458,26 @@ pub struct CommandExecutionRequestApprovalParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub command_actions: Option<Vec<CommandAction>>,
/// Optional additional permissions requested for this command.
#[experimental("item/commandExecution/requestApproval.additionalPermissions")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub additional_permissions: Option<AdditionalPermissionProfile>,
/// Optional proposed execpolicy amendment to allow similar commands without prompting.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
}
impl CommandExecutionRequestApprovalParams {
pub fn strip_experimental_fields(&mut self) {
// TODO: Avoid hardcoding individual experimental fields here.
// We need a generic outbound compatibility design for stripping or
// otherwise handling experimental server->client payloads.
self.additional_permissions = None;
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+44 -5
View File
@@ -171,6 +171,9 @@ enum CliCommand {
},
/// Send a user message through the app-server V2 thread/turn APIs.
SendMessageV2 {
/// Opt into experimental app-server methods and fields.
#[arg(long)]
experimental_api: bool,
/// User message to send to Codex.
user_message: String,
},
@@ -260,9 +263,18 @@ pub fn run() -> Result<()> {
let endpoint = resolve_endpoint(codex_bin, url)?;
send_message(&endpoint, &config_overrides, user_message)
}
CliCommand::SendMessageV2 { user_message } => {
CliCommand::SendMessageV2 {
experimental_api,
user_message,
} => {
let endpoint = resolve_endpoint(codex_bin, url)?;
send_message_v2_endpoint(&endpoint, &config_overrides, user_message, &dynamic_tools)
send_message_v2_endpoint(
&endpoint,
&config_overrides,
user_message,
experimental_api,
&dynamic_tools,
)
}
CliCommand::ResumeMessageV2 {
thread_id,
@@ -508,19 +520,31 @@ pub fn send_message_v2(
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let endpoint = Endpoint::SpawnCodex(codex_bin.to_path_buf());
send_message_v2_endpoint(&endpoint, config_overrides, user_message, dynamic_tools)
send_message_v2_endpoint(
&endpoint,
config_overrides,
user_message,
true,
dynamic_tools,
)
}
fn send_message_v2_endpoint(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: String,
experimental_api: bool,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
if dynamic_tools.is_some() && !experimental_api {
bail!("--dynamic-tools requires --experimental-api for send-message-v2");
}
send_message_v2_with_policies(
endpoint,
config_overrides,
user_message,
experimental_api,
None,
None,
dynamic_tools,
@@ -690,6 +714,7 @@ fn trigger_cmd_approval(
endpoint,
config_overrides,
message,
true,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
@@ -711,6 +736,7 @@ fn trigger_patch_approval(
endpoint,
config_overrides,
message,
true,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
@@ -729,6 +755,7 @@ fn no_trigger_cmd_approval(
endpoint,
config_overrides,
prompt.to_string(),
true,
None,
None,
dynamic_tools,
@@ -739,13 +766,14 @@ fn send_message_v2_with_policies(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: String,
experimental_api: bool,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
let initialize = client.initialize_with_experimental_api(experimental_api)?;
println!("< initialize response: {initialize:?}");
let thread_response = client.thread_start(ThreadStartParams {
@@ -1032,6 +1060,13 @@ impl CodexClient {
}
fn initialize(&mut self) -> Result<InitializeResponse> {
self.initialize_with_experimental_api(true)
}
fn initialize_with_experimental_api(
&mut self,
experimental_api: bool,
) -> Result<InitializeResponse> {
let request_id = self.request_id();
let request = ClientRequest::Initialize {
request_id: request_id.clone(),
@@ -1042,7 +1077,7 @@ impl CodexClient {
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
experimental_api,
opt_out_notification_methods: Some(
NOTIFICATIONS_TO_OPT_OUT
.iter()
@@ -1501,6 +1536,7 @@ impl CodexClient {
command,
cwd,
command_actions,
additional_permissions,
proposed_execpolicy_amendment,
} = params;
@@ -1527,6 +1563,9 @@ impl CodexClient {
{
println!("< command actions: {command_actions:?}");
}
if let Some(additional_permissions) = additional_permissions.as_ref() {
println!("< additional permissions: {additional_permissions:?}");
}
if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() {
println!("< proposed execpolicy amendment: {execpolicy_amendment:?}");
}
+3 -1
View File
@@ -667,7 +667,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap
Order of messages:
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead.
3. Client response — `{ "decision": "accept", "acceptSettings": { "forSession": false } }` or `{ "decision": "decline" }`.
4. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result.
@@ -1072,6 +1072,8 @@ At runtime, clients must send `initialize` with `capabilities.experimentalApi =
3. In `app-server-protocol/src/protocol/common.rs`, keep the method stable and use `inspect_params: true` when only some fields are experimental (like `thread/start`). If the entire method is experimental, annotate the method variant with `#[experimental("method/name")]`.
For server-initiated request payloads, annotate the field the same way so schema generation treats it as experimental, and make sure app-server omits that field when the client did not opt into `experimentalApi`.
4. Regenerate protocol fixtures:
```bash
@@ -11,6 +11,7 @@ use crate::thread_state::TurnSummary;
use crate::thread_status::ThreadWatchActiveGuard;
use crate::thread_status::ThreadWatchManager;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::ApplyPatchApprovalParams;
use codex_app_server_protocol::ApplyPatchApprovalResponse;
@@ -267,6 +268,7 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
network_approval_context,
proposed_execpolicy_amendment,
additional_permissions,
parsed_cmd,
..
} = ev;
@@ -329,6 +331,8 @@ pub(crate) async fn apply_bespoke_event_handling(
};
let proposed_execpolicy_amendment_v2 =
proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from);
let additional_permissions =
additional_permissions.map(V2AdditionalPermissionProfile::from);
let params = CommandExecutionRequestApprovalParams {
thread_id: conversation_id.to_string(),
@@ -340,6 +344,7 @@ pub(crate) async fn apply_bespoke_event_handling(
command,
cwd,
command_actions,
additional_permissions,
proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2,
};
let rx = outgoing
+15
View File
@@ -94,6 +94,7 @@ enum OutboundControlEvent {
writer: mpsc::Sender<crate::outgoing_message::OutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
},
/// Remove state for a closed/disconnected connection.
@@ -503,6 +504,7 @@ pub async fn run_main_with_transport(
writer,
disconnect_sender,
initialized,
experimental_api_enabled,
opted_out_notification_methods,
} => {
outbound_connections.insert(
@@ -510,6 +512,7 @@ pub async fn run_main_with_transport(
OutboundConnectionState::new(
writer,
initialized,
experimental_api_enabled,
opted_out_notification_methods,
disconnect_sender,
),
@@ -609,6 +612,8 @@ pub async fn run_main_with_transport(
disconnect_sender,
} => {
let outbound_initialized = Arc::new(AtomicBool::new(false));
let outbound_experimental_api_enabled =
Arc::new(AtomicBool::new(false));
let outbound_opted_out_notification_methods =
Arc::new(RwLock::new(HashSet::new()));
if outbound_control_tx
@@ -617,6 +622,9 @@ pub async fn run_main_with_transport(
writer,
disconnect_sender,
initialized: Arc::clone(&outbound_initialized),
experimental_api_enabled: Arc::clone(
&outbound_experimental_api_enabled,
),
opted_out_notification_methods: Arc::clone(
&outbound_opted_out_notification_methods,
),
@@ -630,6 +638,7 @@ pub async fn run_main_with_transport(
connection_id,
ConnectionState::new(
outbound_initialized,
outbound_experimental_api_enabled,
outbound_opted_out_notification_methods,
),
);
@@ -679,6 +688,12 @@ pub async fn run_main_with_transport(
"failed to update outbound opted-out notifications"
);
}
connection_state
.outbound_experimental_api_enabled
.store(
connection_state.session.experimental_api_enabled,
std::sync::atomic::Ordering::Release,
);
if !was_initialized && connection_state.session.initialized {
processor.send_initialize_notifications().await;
}
+1 -1
View File
@@ -133,7 +133,7 @@ pub(crate) struct MessageProcessor {
#[derive(Clone, Debug, Default)]
pub(crate) struct ConnectionSessionState {
pub(crate) initialized: bool,
experimental_api_enabled: bool,
pub(crate) experimental_api_enabled: bool,
pub(crate) opted_out_notification_methods: HashSet<String>,
}
+167
View File
@@ -6,6 +6,7 @@ use crate::outgoing_message::OutgoingError;
use crate::outgoing_message::OutgoingMessage;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::ServerRequest;
use futures::SinkExt;
use futures::StreamExt;
use owo_colors::OwoColorize;
@@ -144,6 +145,7 @@ pub(crate) enum TransportEvent {
pub(crate) struct ConnectionState {
pub(crate) outbound_initialized: Arc<AtomicBool>,
pub(crate) outbound_experimental_api_enabled: Arc<AtomicBool>,
pub(crate) outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) session: ConnectionSessionState,
}
@@ -151,10 +153,12 @@ pub(crate) struct ConnectionState {
impl ConnectionState {
pub(crate) fn new(
outbound_initialized: Arc<AtomicBool>,
outbound_experimental_api_enabled: Arc<AtomicBool>,
outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
) -> Self {
Self {
outbound_initialized,
outbound_experimental_api_enabled,
outbound_opted_out_notification_methods,
session: ConnectionSessionState::default(),
}
@@ -163,6 +167,7 @@ impl ConnectionState {
pub(crate) struct OutboundConnectionState {
pub(crate) initialized: Arc<AtomicBool>,
pub(crate) experimental_api_enabled: Arc<AtomicBool>,
pub(crate) opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) writer: mpsc::Sender<OutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
@@ -172,11 +177,13 @@ impl OutboundConnectionState {
pub(crate) fn new(
writer: mpsc::Sender<OutgoingMessage>,
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
disconnect_sender: Option<CancellationToken>,
) -> Self {
Self {
initialized,
experimental_api_enabled,
opted_out_notification_methods,
writer,
disconnect_sender,
@@ -577,6 +584,7 @@ async fn send_message_to_connection(
warn!("dropping message for disconnected connection: {connection_id:?}");
return false;
};
let message = filter_outgoing_message_for_connection(connection_state, message);
if should_skip_notification_for_connection(connection_state, &message) {
return false;
}
@@ -602,6 +610,30 @@ async fn send_message_to_connection(
}
}
fn filter_outgoing_message_for_connection(
connection_state: &OutboundConnectionState,
message: OutgoingMessage,
) -> OutgoingMessage {
let experimental_api_enabled = connection_state
.experimental_api_enabled
.load(Ordering::Acquire);
match message {
OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
request_id,
mut params,
}) => {
if !experimental_api_enabled {
params.strip_experimental_fields();
}
OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
request_id,
params,
})
}
_ => message,
}
}
pub(crate) async fn route_outgoing_envelope(
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
envelope: OutgoingEnvelope,
@@ -641,6 +673,7 @@ mod tests {
use crate::error_code::OVERLOADED_ERROR_CODE;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
use tokio::time::Duration;
use tokio::time::timeout;
@@ -880,6 +913,7 @@ mod tests {
OutboundConnectionState::new(
writer_tx,
initialized,
Arc::new(AtomicBool::new(true)),
opted_out_notification_methods,
None,
),
@@ -905,6 +939,136 @@ mod tests {
);
}
#[tokio::test]
async fn command_execution_request_approval_strips_experimental_fields_without_capability() {
let connection_id = ConnectionId(8);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(false)),
Arc::new(RwLock::new(HashSet::new())),
None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
request_id: codex_app_server_protocol::RequestId::Integer(1),
params: codex_app_server_protocol::CommandExecutionRequestApprovalParams {
thread_id: "thr_123".to_string(),
turn_id: "turn_123".to_string(),
item_id: "call_123".to_string(),
approval_id: None,
reason: Some("Need extra read access".to_string()),
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: Some(PathBuf::from("/tmp")),
command_actions: None,
additional_permissions: Some(
codex_app_server_protocol::AdditionalPermissionProfile {
network: None,
file_system: Some(
codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![PathBuf::from("/tmp/allowed")]),
write: None,
},
),
macos: None,
},
),
proposed_execpolicy_amendment: None,
},
}),
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("request should be delivered to the connection");
let json = serde_json::to_value(message).expect("request should serialize");
assert_eq!(json["params"].get("additionalPermissions"), None);
}
#[tokio::test]
async fn command_execution_request_approval_keeps_experimental_fields_with_capability() {
let connection_id = ConnectionId(9);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval {
request_id: codex_app_server_protocol::RequestId::Integer(1),
params: codex_app_server_protocol::CommandExecutionRequestApprovalParams {
thread_id: "thr_123".to_string(),
turn_id: "turn_123".to_string(),
item_id: "call_123".to_string(),
approval_id: None,
reason: Some("Need extra read access".to_string()),
network_approval_context: None,
command: Some("cat file".to_string()),
cwd: Some(PathBuf::from("/tmp")),
command_actions: None,
additional_permissions: Some(
codex_app_server_protocol::AdditionalPermissionProfile {
network: None,
file_system: Some(
codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![PathBuf::from("/tmp/allowed")]),
write: None,
},
),
macos: None,
},
),
proposed_execpolicy_amendment: None,
},
}),
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("request should be delivered to the connection");
let json = serde_json::to_value(message).expect("request should serialize");
assert_eq!(
json["params"]["additionalPermissions"],
json!({
"network": null,
"fileSystem": {
"read": ["/tmp/allowed"],
"write": null,
},
"macos": null,
})
);
}
#[tokio::test]
async fn broadcast_does_not_block_on_slow_connection() {
let fast_connection_id = ConnectionId(1);
@@ -921,6 +1085,7 @@ mod tests {
OutboundConnectionState::new(
fast_writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
Some(fast_disconnect_token.clone()),
),
@@ -930,6 +1095,7 @@ mod tests {
OutboundConnectionState::new(
slow_writer_tx.clone(),
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
Some(slow_disconnect_token.clone()),
),
@@ -1006,6 +1172,7 @@ mod tests {
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
None,
),
+18 -9
View File
@@ -70,8 +70,8 @@ use codex_protocol::items::PlanItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::FileChange;
@@ -2571,7 +2571,7 @@ impl Session {
reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
additional_permissions: Option<AdditionalPermissions>,
additional_permissions: Option<PermissionProfile>,
) -> ReviewDecision {
// command-level approvals use `call_id`.
// `approval_id` is only present for subcommand callbacks (execve intercept)
@@ -6361,6 +6361,8 @@ use crate::memories::prompts::build_memory_tool_developer_instructions;
#[cfg(test)]
pub(crate) use tests::make_session_and_context;
#[cfg(test)]
pub(crate) use tests::make_session_and_context_with_dynamic_tools_and_rx;
#[cfg(test)]
pub(crate) use tests::make_session_and_context_with_rx;
#[cfg(test)]
pub(crate) use tests::make_session_configuration_for_tests;
@@ -8273,9 +8275,9 @@ mod tests {
(session, turn_context)
}
// Like make_session_and_context, but returns Arc<Session> and the event receiver
// so tests can assert on emitted events.
pub(crate) async fn make_session_and_context_with_rx() -> (
pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
dynamic_tools: Vec<DynamicToolSpec>,
) -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
@@ -8327,7 +8329,7 @@ mod tests {
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
dynamic_tools,
persist_extended_history: false,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
@@ -8429,6 +8431,16 @@ mod tests {
(session, turn_context, rx_event)
}
// Like make_session_and_context, but returns Arc<Session> and the event receiver
// so tests can assert on emitted events.
pub(crate) async fn make_session_and_context_with_rx() -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
) {
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await
}
#[tokio::test]
async fn refresh_mcp_servers_is_deferred_until_next_turn() {
let (session, turn_context) = make_session_and_context().await;
@@ -9273,7 +9285,6 @@ mod tests {
})
.to_string(),
},
source: ToolCallSource::Direct,
})
.await;
@@ -9313,7 +9324,6 @@ mod tests {
})
.to_string(),
},
source: ToolCallSource::Direct,
})
.await;
@@ -9373,7 +9383,6 @@ mod tests {
})
.to_string(),
},
source: ToolCallSource::Direct,
})
.await;
+18 -5
View File
@@ -41,11 +41,20 @@ pub enum RefreshStrategy {
OnlineIfUncached,
}
/// How the manager's base catalog is sourced for the lifetime of the process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CatalogMode {
/// Start from bundled `models.json` and allow cache/network refresh updates.
Default,
/// Use a caller-provided catalog as authoritative and do not mutate it via refresh.
Custom,
}
/// Coordinates remote model discovery plus cached metadata on disk.
#[derive(Debug)]
pub struct ModelsManager {
remote_models: RwLock<Vec<ModelInfo>>,
has_custom_model_catalog: bool,
catalog_mode: CatalogMode,
auth_manager: Arc<AuthManager>,
etag: RwLock<Option<String>>,
cache_manager: ModelsCacheManager,
@@ -65,7 +74,11 @@ impl ModelsManager {
) -> Self {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
let has_custom_model_catalog = model_catalog.is_some();
let catalog_mode = if model_catalog.is_some() {
CatalogMode::Custom
} else {
CatalogMode::Default
};
let remote_models = model_catalog
.map(|catalog| catalog.models)
.unwrap_or_else(|| {
@@ -74,7 +87,7 @@ impl ModelsManager {
});
Self {
remote_models: RwLock::new(remote_models),
has_custom_model_catalog,
catalog_mode,
auth_manager,
etag: RwLock::new(None),
cache_manager,
@@ -217,7 +230,7 @@ impl ModelsManager {
/// Refresh available models according to the specified strategy.
async fn refresh_available_models(&self, refresh_strategy: RefreshStrategy) -> CoreResult<()> {
// don't override the custom model catalog if one was provided by the user
if self.has_custom_model_catalog {
if matches!(self.catalog_mode, CatalogMode::Custom) {
return Ok(());
}
@@ -364,7 +377,7 @@ impl ModelsManager {
Self::load_remote_models_from_file()
.unwrap_or_else(|err| panic!("failed to load bundled models.json: {err}")),
),
has_custom_model_catalog: false,
catalog_mode: CatalogMode::Default,
auth_manager,
etag: RwLock::new(None),
cache_manager,
+36 -13
View File
@@ -24,7 +24,8 @@ use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
pub use codex_protocol::models::SandboxPermissions;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -41,7 +42,7 @@ pub struct CommandSpec {
pub env: HashMap<String, String>,
pub expiration: ExecExpiration,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissions>,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
}
@@ -95,14 +96,24 @@ pub(crate) enum SandboxTransformError {
}
pub(crate) fn normalize_additional_permissions(
additional_permissions: AdditionalPermissions,
additional_permissions: PermissionProfile,
command_cwd: &Path,
) -> Result<AdditionalPermissions, String> {
let fs_read =
normalize_permission_paths(additional_permissions.fs_read, command_cwd, "fs_read")?;
let fs_write =
normalize_permission_paths(additional_permissions.fs_write, command_cwd, "fs_write")?;
Ok(AdditionalPermissions { fs_read, fs_write })
) -> Result<PermissionProfile, String> {
let Some(file_system) = additional_permissions.file_system else {
return Ok(PermissionProfile::default());
};
let read = file_system
.read
.map(|paths| normalize_permission_paths(paths, command_cwd, "file_system.read"))
.transpose()?;
let write = file_system
.write
.map(|paths| normalize_permission_paths(paths, command_cwd, "file_system.write"))
.transpose()?;
Ok(PermissionProfile {
file_system: Some(FileSystemPermissions { read, write }),
..Default::default()
})
}
fn normalize_permission_paths(
@@ -162,7 +173,7 @@ fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
}
fn additional_permission_roots(
additional_permissions: &AdditionalPermissions,
additional_permissions: &PermissionProfile,
) -> Result<(Vec<AbsolutePathBuf>, Vec<AbsolutePathBuf>), SandboxTransformError> {
let to_abs = |paths: &[PathBuf]| {
let mut out = Vec::with_capacity(paths.len());
@@ -179,8 +190,20 @@ fn additional_permission_roots(
};
Ok((
to_abs(&additional_permissions.fs_read)?,
to_abs(&additional_permissions.fs_write)?,
to_abs(
additional_permissions
.file_system
.as_ref()
.and_then(|file_system| file_system.read.as_deref())
.unwrap_or_default(),
)?,
to_abs(
additional_permissions
.file_system
.as_ref()
.and_then(|file_system| file_system.write.as_deref())
.unwrap_or_default(),
)?,
))
}
@@ -206,7 +229,7 @@ fn merge_read_only_access_with_additional_reads(
fn sandbox_policy_with_additional_permissions(
sandbox_policy: &SandboxPolicy,
additional_permissions: &AdditionalPermissions,
additional_permissions: &PermissionProfile,
) -> Result<SandboxPolicy, SandboxTransformError> {
if additional_permissions.is_empty() {
return Ok(sandbox_policy.clone());
+2 -2
View File
@@ -12,10 +12,10 @@ use crate::skills::model::SkillLoadOutcome;
use crate::skills::model::SkillMetadata;
use crate::skills::model::SkillPolicy;
use crate::skills::model::SkillToolDependency;
use crate::skills::permissions::SkillManifestPermissions;
use crate::skills::permissions::compile_permission_profile;
use crate::skills::system::system_cache_root_dir;
use codex_app_server_protocol::ConfigLayerSource;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SkillScope;
use dirs::home_dir;
use dunce::canonicalize as canonicalize_path;
@@ -54,7 +54,7 @@ struct SkillMetadataFile {
#[serde(default)]
policy: Option<Policy>,
#[serde(default)]
permissions: Option<SkillManifestPermissions>,
permissions: Option<PermissionProfile>,
}
#[derive(Debug, Default, Deserialize)]
+71 -98
View File
@@ -3,10 +3,15 @@ use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsAutomationValue;
use codex_protocol::models::MacOsPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsPreferencesValue;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
use dunce::canonicalize as canonicalize_path;
use serde::Deserialize;
use tracing::warn;
use crate::config::Constrained;
@@ -20,63 +25,24 @@ use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
#[cfg(not(target_os = "macos"))]
type MacOsSeatbeltProfileExtensions = ();
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
pub(crate) struct SkillManifestPermissions {
#[serde(default)]
pub(crate) network: bool,
#[serde(default)]
pub(crate) file_system: SkillManifestFileSystemPermissions,
#[serde(default)]
pub(crate) macos: SkillManifestMacOsPermissions,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
pub(crate) struct SkillManifestFileSystemPermissions {
#[serde(default)]
pub(crate) read: Vec<String>,
#[serde(default)]
pub(crate) write: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
pub(crate) struct SkillManifestMacOsPermissions {
#[serde(default)]
pub(crate) preferences: Option<MacOsPreferencesValue>,
#[serde(default)]
pub(crate) automations: Option<MacOsAutomationValue>,
#[serde(default)]
pub(crate) accessibility: bool,
#[serde(default)]
pub(crate) calendar: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
pub(crate) enum MacOsPreferencesValue {
Bool(bool),
Mode(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
pub(crate) enum MacOsAutomationValue {
Bool(bool),
BundleIds(Vec<String>),
}
pub(crate) fn compile_permission_profile(
skill_dir: &Path,
permissions: Option<SkillManifestPermissions>,
permissions: Option<PermissionProfile>,
) -> Option<Permissions> {
let permissions = permissions?;
let PermissionProfile {
network,
file_system,
macos,
} = permissions?;
let file_system = file_system.unwrap_or_default();
let fs_read = normalize_permission_paths(
skill_dir,
&permissions.file_system.read,
file_system.read.as_deref().unwrap_or_default(),
"permissions.file_system.read",
);
let fs_write = normalize_permission_paths(
skill_dir,
&permissions.file_system.write,
file_system.write.as_deref().unwrap_or_default(),
"permissions.file_system.write",
);
let sandbox_policy = if !fs_write.is_empty() {
@@ -90,7 +56,7 @@ pub(crate) fn compile_permission_profile(
readable_roots: fs_read,
}
},
network_access: permissions.network,
network_access: network.unwrap_or_default(),
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
@@ -105,8 +71,9 @@ pub(crate) fn compile_permission_profile(
// Default sandbox policy
SandboxPolicy::new_read_only_policy()
};
let macos_permissions = macos.unwrap_or_default();
let macos_seatbelt_profile_extensions =
build_macos_seatbelt_profile_extensions(&permissions.macos);
build_macos_seatbelt_profile_extensions(&macos_permissions);
Some(Permissions {
approval_policy: Constrained::allow_any(AskForApproval::Never),
@@ -121,7 +88,7 @@ pub(crate) fn compile_permission_profile(
fn normalize_permission_paths(
skill_dir: &Path,
values: &[String],
values: &[PathBuf],
field: &str,
) -> Vec<AbsolutePathBuf> {
let mut paths = Vec::new();
@@ -141,9 +108,10 @@ fn normalize_permission_paths(
fn normalize_permission_path(
skill_dir: &Path,
value: &str,
value: &Path,
field: &str,
) -> Option<AbsolutePathBuf> {
let value = value.to_string_lossy();
let trimmed = value.trim();
if trimmed.is_empty() {
warn!("ignoring {field}: value is empty");
@@ -151,11 +119,10 @@ fn normalize_permission_path(
}
let expanded = expand_home(trimmed);
let path = PathBuf::from(expanded);
let absolute = if path.is_absolute() {
path
let absolute = if expanded.is_absolute() {
expanded
} else {
skill_dir.join(path)
skill_dir.join(expanded)
};
let normalized = normalize_lexically(&absolute);
let canonicalized = canonicalize_path(&normalized).unwrap_or(normalized);
@@ -168,24 +135,24 @@ fn normalize_permission_path(
}
}
fn expand_home(path: &str) -> String {
fn expand_home(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = home_dir() {
return home.to_string_lossy().to_string();
return home;
}
return path.to_string();
return PathBuf::from(path);
}
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = home_dir()
{
return home.join(rest).to_string_lossy().to_string();
return home.join(rest);
}
path.to_string()
PathBuf::from(path)
}
#[cfg(target_os = "macos")]
fn build_macos_seatbelt_profile_extensions(
permissions: &SkillManifestMacOsPermissions,
permissions: &MacOsPermissions,
) -> Option<MacOsSeatbeltProfileExtensions> {
let defaults = MacOsSeatbeltProfileExtensions::default();
@@ -198,8 +165,10 @@ fn build_macos_seatbelt_profile_extensions(
permissions.automations.as_ref(),
defaults.macos_automation,
),
macos_accessibility: permissions.accessibility,
macos_calendar: permissions.calendar,
macos_accessibility: permissions
.accessibility
.unwrap_or(defaults.macos_accessibility),
macos_calendar: permissions.calendar.unwrap_or(defaults.macos_calendar),
};
Some(extensions)
}
@@ -262,7 +231,7 @@ fn resolve_macos_automation_permission(
#[cfg(not(target_os = "macos"))]
fn build_macos_seatbelt_profile_extensions(
_: &SkillManifestMacOsPermissions,
_: &MacOsPermissions,
) -> Option<MacOsSeatbeltProfileExtensions> {
None
}
@@ -285,10 +254,6 @@ fn normalize_lexically(path: &Path) -> PathBuf {
#[cfg(test)]
mod tests {
use super::SkillManifestFileSystemPermissions;
#[cfg(target_os = "macos")]
use super::SkillManifestMacOsPermissions;
use super::SkillManifestPermissions;
use super::compile_permission_profile;
use crate::config::Constrained;
use crate::config::Permissions;
@@ -296,9 +261,18 @@ mod tests {
use crate::protocol::AskForApproval;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use codex_protocol::models::FileSystemPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsAutomationValue;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsPreferencesValue;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
#[test]
fn compile_permission_profile_normalizes_paths() {
@@ -310,16 +284,16 @@ mod tests {
let profile = compile_permission_profile(
&skill_dir,
Some(SkillManifestPermissions {
network: true,
file_system: SkillManifestFileSystemPermissions {
read: vec![
"./data".to_string(),
"./data".to_string(),
"scripts/../data".to_string(),
],
write: vec!["./output".to_string()],
},
Some(PermissionProfile {
network: Some(true),
file_system: Some(FileSystemPermissions {
read: Some(vec![
PathBuf::from("./data"),
PathBuf::from("./data"),
PathBuf::from("scripts/../data"),
]),
write: Some(vec![PathBuf::from("./output")]),
}),
..Default::default()
}),
)
@@ -380,8 +354,8 @@ mod tests {
let profile = compile_permission_profile(
&skill_dir,
Some(SkillManifestPermissions {
network: true,
Some(PermissionProfile {
network: Some(true),
..Default::default()
}),
)
@@ -415,12 +389,12 @@ mod tests {
let profile = compile_permission_profile(
&skill_dir,
Some(SkillManifestPermissions {
network: true,
file_system: SkillManifestFileSystemPermissions {
read: vec!["./data".to_string()],
write: Vec::new(),
},
Some(PermissionProfile {
network: Some(true),
file_system: Some(FileSystemPermissions {
read: Some(vec![PathBuf::from("./data")]),
write: Some(Vec::new()),
}),
..Default::default()
}),
)
@@ -464,15 +438,15 @@ mod tests {
let profile = compile_permission_profile(
&skill_dir,
Some(SkillManifestPermissions {
macos: SkillManifestMacOsPermissions {
preferences: Some(super::MacOsPreferencesValue::Mode("readwrite".to_string())),
automations: Some(super::MacOsAutomationValue::BundleIds(vec![
Some(PermissionProfile {
macos: Some(MacOsPermissions {
preferences: Some(MacOsPreferencesValue::Mode("readwrite".to_string())),
automations: Some(MacOsAutomationValue::BundleIds(vec![
"com.apple.Notes".to_string(),
])),
accessibility: true,
calendar: true,
},
accessibility: Some(true),
calendar: Some(true),
}),
..Default::default()
}),
)
@@ -502,9 +476,8 @@ mod tests {
let skill_dir = tempdir.path().join("skill");
fs::create_dir_all(&skill_dir).expect("skill dir");
let profile =
compile_permission_profile(&skill_dir, Some(SkillManifestPermissions::default()))
.expect("profile");
let profile = compile_permission_profile(&skill_dir, Some(PermissionProfile::default()))
.expect("profile");
assert_eq!(
profile.macos_seatbelt_profile_extensions,
-1
View File
@@ -30,7 +30,6 @@ pub struct ToolInvocation {
pub call_id: String,
pub tool_name: String,
pub payload: ToolPayload,
pub source: ToolCallSource,
}
#[derive(Clone, Debug)]
+5 -5
View File
@@ -24,7 +24,7 @@ use crate::function_tool::FunctionCallError;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::normalize_additional_permissions;
pub use apply_patch::ApplyPatchHandler;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
pub use dynamic::DynamicToolHandler;
pub use grep_files::GrepFilesHandler;
@@ -62,9 +62,9 @@ pub(super) fn normalize_and_validate_additional_permissions(
request_permission_enabled: bool,
approval_policy: AskForApproval,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<AdditionalPermissions>,
additional_permissions: Option<PermissionProfile>,
cwd: &Path,
) -> Result<Option<AdditionalPermissions>, String> {
) -> Result<Option<PermissionProfile>, String> {
let uses_additional_permissions = matches!(
sandbox_permissions,
SandboxPermissions::WithAdditionalPermissions
@@ -87,14 +87,14 @@ pub(super) fn normalize_and_validate_additional_permissions(
}
let Some(additional_permissions) = additional_permissions else {
return Err(
"missing `additional_permissions`; provide `fs_read` and/or `fs_write` when using `with_additional_permissions`"
"missing `additional_permissions`; provide `file_system.read` and/or `file_system.write` when using `with_additional_permissions`"
.to_string(),
);
};
let normalized = normalize_additional_permissions(additional_permissions, cwd)?;
if normalized.is_empty() {
return Err(
"`additional_permissions` must include at least one path in `fs_read` or `fs_write`"
"`additional_permissions` must include at least one path in `file_system.read` or `file_system.write`"
.to_string(),
);
}
@@ -1004,7 +1004,6 @@ mod tests {
call_id: "call-1".to_string(),
tool_name: tool_name.to_string(),
payload,
source: crate::tools::router::ToolCallSource::Direct,
}
}
+2 -2
View File
@@ -33,7 +33,7 @@ use crate::tools::runtimes::shell::ShellRuntime;
use crate::tools::runtimes::shell::ShellRuntimeBackend;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::spec::ShellCommandBackendConfig;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::PermissionProfile;
pub struct ShellHandler;
@@ -50,7 +50,7 @@ pub struct ShellCommandHandler {
struct RunExecLikeArgs {
tool_name: String,
exec_params: ExecParams,
additional_permissions: Option<AdditionalPermissions>,
additional_permissions: Option<PermissionProfile>,
prefix_rule: Option<Vec<String>>,
session: Arc<crate::codex::Session>,
turn: Arc<TurnContext>,
@@ -21,8 +21,8 @@ use crate::unified_exec::UnifiedExecProcessManager;
use crate::unified_exec::UnifiedExecResponse;
use crate::unified_exec::WriteStdinRequest;
use async_trait::async_trait;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::PermissionProfile;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
@@ -47,7 +47,7 @@ pub(crate) struct ExecCommandArgs {
#[serde(default)]
sandbox_permissions: SandboxPermissions,
#[serde(default)]
additional_permissions: Option<AdditionalPermissions>,
additional_permissions: Option<PermissionProfile>,
#[serde(default)]
justification: Option<String>,
#[serde(default)]
@@ -8,7 +8,6 @@ use tokio::fs;
use crate::function_tool::FunctionCallError;
use crate::protocol::EventMsg;
use crate::protocol::ViewImageToolCallEvent;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -16,7 +15,6 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::local_image_content_items_with_label_number;
pub struct ViewImageHandler;
@@ -52,7 +50,6 @@ impl ToolHandler for ViewImageHandler {
turn,
payload,
call_id,
source,
..
} = invocation;
@@ -85,24 +82,6 @@ impl ToolHandler for ViewImageHandler {
let event_path = abs_path.clone();
let content = local_image_content_items_with_label_number(&abs_path, None);
if source == ToolCallSource::JsRepl
&& content
.iter()
.any(|item| matches!(item, ContentItem::InputImage { .. }))
{
let input_item = ResponseInputItem::Message {
role: "user".to_string(),
content: content.clone(),
};
if session
.inject_response_items(vec![input_item])
.await
.is_err()
{
tracing::warn!("view_image could not find an active turn to attach image input");
}
}
let content = content
.into_iter()
.map(|item| match item {
+165 -213
View File
@@ -9,6 +9,9 @@ use std::sync::Arc;
use std::time::Duration;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ResponseInputItem;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -1050,36 +1053,78 @@ impl JsReplManager {
}
};
let tool_name = req.tool_name.clone();
let call = crate::tools::router::ToolCall {
tool_name: req.tool_name,
tool_name: tool_name.clone(),
call_id: req.id.clone(),
payload,
};
let session = Arc::clone(&exec.session);
let turn = Arc::clone(&exec.turn);
let tracker = Arc::clone(&exec.tracker);
match router
.dispatch_tool_call(
exec.session,
exec.turn,
exec.tracker,
session.clone(),
turn,
tracker,
call,
crate::tools::router::ToolCallSource::JsRepl,
)
.await
{
Ok(response) => match serde_json::to_value(response) {
Ok(value) => RunToolResult {
id: req.id,
ok: true,
response: Some(value),
error: None,
},
Err(err) => RunToolResult {
id: req.id,
ok: false,
response: None,
error: Some(format!("failed to serialize tool output: {err}")),
},
},
Ok(response) => {
if let ResponseInputItem::FunctionCallOutput { output, .. } = &response
&& let Some(items) = output.content_items()
{
let mut has_image = false;
let mut content = Vec::with_capacity(items.len());
for item in items {
match item {
FunctionCallOutputContentItem::InputText { text } => {
content.push(ContentItem::InputText { text: text.clone() });
}
FunctionCallOutputContentItem::InputImage { image_url } => {
has_image = true;
content.push(ContentItem::InputImage {
image_url: image_url.clone(),
});
}
}
}
if has_image
&& session
.inject_response_items(vec![ResponseInputItem::Message {
role: "user".to_string(),
content,
}])
.await
.is_err()
{
warn!(
tool_name = %tool_name,
"js_repl tool call returned image content but there was no active turn to attach it to"
);
}
}
match serde_json::to_value(response) {
Ok(value) => RunToolResult {
id: req.id,
ok: true,
response: Some(value),
error: None,
},
Err(err) => RunToolResult {
id: req.id,
ok: false,
response: None,
error: Some(format!("failed to serialize tool output: {err}")),
},
}
}
Err(err) => RunToolResult {
id: req.id,
ok: false,
@@ -1301,9 +1346,14 @@ pub(crate) fn resolve_node(config_path: Option<&Path>) -> Option<PathBuf> {
mod tests {
use super::*;
use crate::codex::make_session_and_context;
use crate::codex::make_session_and_context_with_dynamic_tools_and_rx;
use crate::protocol::AskForApproval;
use crate::protocol::EventMsg;
use crate::protocol::SandboxPolicy;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::openai_models::InputModality;
@@ -1586,6 +1636,12 @@ mod tests {
}
async fn can_run_js_repl_runtime_tests() -> bool {
// These white-box runtime tests rely on the unit-test harness and are
// only required on macOS. Linux uses the codex-linux-sandbox arg0
// dispatch path, which is exercised in integration tests instead.
if !cfg!(target_os = "macos") {
return false;
}
if std::env::var_os("CODEX_SANDBOX").is_some() {
return false;
}
@@ -1619,47 +1675,6 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn js_repl_persists_top_level_bindings_and_supports_tla() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn) = make_session_and_context().await;
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let first = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "let x = await Promise.resolve(41); console.log(x);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(first.output.contains("41"));
let second = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "console.log(x + 1);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(second.output.contains("42"));
Ok(())
}
#[tokio::test]
async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
@@ -1915,108 +1930,9 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn js_repl_can_call_tools() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, mut turn) = make_session_and_context().await;
turn.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let shell = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "const shellOut = await codex.tool(\"shell_command\", { command: \"printf js_repl_shell_ok\" }); console.log(JSON.stringify(shellOut));".to_string(),
timeout_ms: Some(15_000),
},
)
.await?;
assert!(shell.output.contains("js_repl_shell_ok"));
let tool = manager
.execute(
Arc::clone(&session),
Arc::clone(&turn),
Arc::clone(&tracker),
JsReplArgs {
code: "const toolOut = await codex.tool(\"list_mcp_resources\", {}); console.log(toolOut.type);".to_string(),
timeout_ms: Some(15_000),
},
)
.await?;
assert!(tool.output.contains("function_call_output"));
Ok(())
}
#[tokio::test]
async fn js_repl_tool_call_rejects_recursive_js_repl_invocation() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, mut turn) = make_session_and_context().await;
turn.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
turn.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("test setup should allow updating sandbox policy");
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let result = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: r#"
try {
await codex.tool("js_repl", "console.log('recursive')");
console.log("unexpected-success");
} catch (err) {
console.log(String(err));
}
"#
.to_string(),
timeout_ms: Some(15_000),
},
)
.await?;
assert!(
result.output.contains("js_repl cannot invoke itself"),
"expected recursion guard message, got output: {}",
result.output
);
assert!(
!result.output.contains("unexpected-success"),
"recursive js_repl tool call unexpectedly succeeded: {}",
result.output
);
Ok(())
}
#[tokio::test]
async fn js_repl_waits_for_unawaited_tool_calls_before_completion() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await || cfg!(windows) {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
@@ -2116,81 +2032,117 @@ console.log(out.output?.body?.text ?? "");
assert!(result.output.contains("function_call_output"));
let pending_input = session.get_pending_input().await;
let image_url = pending_input
.iter()
.find_map(|item| match item {
ResponseInputItem::Message { content, .. } => {
content.iter().find_map(|content_item| match content_item {
ContentItem::InputImage { image_url } => Some(image_url.as_str()),
_ => None,
})
}
_ => None,
})
.expect("view_image should inject an input_image message for the active turn");
let [ResponseInputItem::Message { role, content }] = pending_input.as_slice() else {
panic!(
"view_image should inject exactly one pending input message, got {pending_input:?}"
);
};
assert_eq!(role, "user");
let [ContentItem::InputImage { image_url }] = content.as_slice() else {
panic!(
"view_image should inject exactly one input_image content item, got {content:?}"
);
};
assert!(image_url.starts_with("data:image/png;base64,"));
Ok(())
}
#[tokio::test]
async fn js_repl_does_not_expose_process_global() -> anyhow::Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_can_attach_image_via_dynamic_tool_with_mixed_content() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn) = make_session_and_context().await;
let session = Arc::new(session);
let turn = Arc::new(turn);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let result = manager
.execute(
session,
turn,
tracker,
JsReplArgs {
code: "console.log(typeof process);".to_string(),
timeout_ms: Some(10_000),
},
)
.await?;
assert!(result.output.contains("undefined"));
Ok(())
}
#[tokio::test]
async fn js_repl_blocks_sensitive_builtin_imports() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
let (session, turn, rx_event) =
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
name: "inline_image".to_string(),
description: "Returns inline text and image content.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
}])
.await;
if !turn
.model_info
.input_modalities
.contains(&InputModality::Image)
{
return Ok(());
}
let (session, turn) = make_session_and_context().await;
let session = Arc::new(session);
let turn = Arc::new(turn);
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let code = r#"
const out = await codex.tool("inline_image", {});
console.log(out.type);
"#;
let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
let err = manager
.execute(
session,
turn,
let session_for_response = Arc::clone(&session);
let response_watcher = async move {
loop {
let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv()).await??;
if let EventMsg::DynamicToolCallRequest(request) = event.msg {
session_for_response
.notify_dynamic_tool_response(
&request.call_id,
DynamicToolResponse {
content_items: vec![
DynamicToolCallOutputContentItem::InputText {
text: "inline image note".to_string(),
},
DynamicToolCallOutputContentItem::InputImage {
image_url: image_url.to_string(),
},
],
success: true,
},
)
.await;
return Ok::<(), anyhow::Error>(());
}
}
};
let (result, response_watcher_result) = tokio::join!(
manager.execute(
Arc::clone(&session),
Arc::clone(&turn),
tracker,
JsReplArgs {
code: "await import(\"node:process\");".to_string(),
timeout_ms: Some(10_000),
code: code.to_string(),
timeout_ms: Some(15_000),
},
)
.await
.expect_err("node:process import should be blocked");
assert!(
err.to_string()
.contains("Importing module \"node:process\" is not allowed in js_repl")
),
response_watcher,
);
response_watcher_result?;
let result = result?;
assert!(result.output.contains("function_call_output"));
let pending_input = session.get_pending_input().await;
assert_eq!(
pending_input,
vec![ResponseInputItem::Message {
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "inline image note".to_string(),
},
ContentItem::InputImage {
image_url: image_url.to_string(),
},
],
}]
);
Ok(())
}
#[tokio::test]
async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
-1
View File
@@ -175,7 +175,6 @@ impl ToolRouter {
call_id,
tool_name,
payload,
source,
};
match self.registry.dispatch(invocation).await {
+2 -2
View File
@@ -10,7 +10,7 @@ use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
use crate::tools::sandboxing::ToolError;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::PermissionProfile;
use std::collections::HashMap;
use std::path::Path;
@@ -26,7 +26,7 @@ pub(crate) fn build_command_spec(
env: &HashMap<String, String>,
expiration: ExecExpiration,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<AdditionalPermissions>,
additional_permissions: Option<PermissionProfile>,
justification: Option<String>,
) -> Result<CommandSpec, ToolError> {
let (program, args) = command
+3 -3
View File
@@ -31,7 +31,7 @@ use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
use std::collections::HashMap;
@@ -46,7 +46,7 @@ pub struct ShellRequest {
pub explicit_env_overrides: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissions>,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
pub exec_approval_requirement: ExecApprovalRequirement,
}
@@ -89,7 +89,7 @@ pub(crate) struct ApprovalKey {
command: Vec<String>,
cwd: PathBuf,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<AdditionalPermissions>,
additional_permissions: Option<PermissionProfile>,
}
impl ShellRuntime {
@@ -8,6 +8,7 @@ use crate::exec::is_likely_sandbox_denied;
use crate::features::Feature;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::skills::SkillMetadata;
use crate::tools::runtimes::build_command_spec;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
@@ -174,11 +175,12 @@ impl CoreShellActionProvider {
async fn prompt(
&self,
command: &[String],
program: &Path,
argv: &[String],
workdir: &Path,
stopwatch: &Stopwatch,
) -> anyhow::Result<ReviewDecision> {
let command = command.to_vec();
let command = join_program_and_argv(program, argv);
let workdir = workdir.to_path_buf();
let session = self.session.clone();
let turn = self.turn.clone();
@@ -202,49 +204,41 @@ impl CoreShellActionProvider {
})
.await)
}
}
#[async_trait::async_trait]
impl EscalationPolicy for CoreShellActionProvider {
async fn determine_action(
/// Because we should be intercepting execve(2) calls, `program` should be
/// an absolute path. The idea is that we check to see whether it matches
/// any skills.
async fn find_skill(&self, program: &Path) -> Option<SkillMetadata> {
let force_reload = false;
let skills_outcome = self
.session
.services
.skills_manager
.skills_for_cwd(&self.turn.cwd, force_reload)
.await;
for skill in skills_outcome.skills {
// We intentionally ignore "enabled" status here for now.
let Some(skill_root) = skill.path_to_skills_md.parent() else {
continue;
};
if program.starts_with(skill_root.join("scripts")) {
return Some(skill);
}
}
None
}
async fn process_decision(
&self,
file: &Path,
decision: Decision,
needs_escalation: bool,
program: &Path,
argv: &[String],
workdir: &Path,
) -> anyhow::Result<EscalateAction> {
let command = std::iter::once(file.to_string_lossy().to_string())
.chain(argv.iter().cloned())
.collect::<Vec<_>>();
let (commands, used_complex_parsing) =
if let Some(commands) = parse_shell_lc_plain_commands(&command) {
(commands, false)
} else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) {
(vec![single_command], true)
} else {
(vec![command.clone()], false)
};
let fallback = |cmd: &[String]| {
crate::exec_policy::render_decision_for_unmatched_command(
self.approval_policy,
&self.sandbox_policy,
cmd,
self.sandbox_permissions,
used_complex_parsing,
)
};
let evaluation = {
let policy = self.policy.read().await;
policy.check_multiple(commands.iter(), &fallback)
};
// When true, means the Evaluation was due to *.rules, not the
// fallback function.
let decision_driven_by_policy =
Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision);
let needs_escalation =
self.sandbox_permissions.requires_escalated_permissions() || decision_driven_by_policy;
Ok(match evaluation.decision {
let action = match decision {
Decision::Forbidden => EscalateAction::Deny {
reason: Some("Execution forbidden by policy".to_string()),
},
@@ -258,7 +252,7 @@ impl EscalationPolicy for CoreShellActionProvider {
reason: Some("Execution forbidden by policy".to_string()),
}
} else {
match self.prompt(&command, workdir, &self.stopwatch).await? {
match self.prompt(program, argv, workdir, &self.stopwatch).await? {
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {
@@ -291,8 +285,88 @@ impl EscalationPolicy for CoreShellActionProvider {
}
}
}
Decision::Allow => EscalateAction::Escalate,
})
Decision::Allow => {
if needs_escalation {
EscalateAction::Escalate
} else {
EscalateAction::Run
}
}
};
tracing::info!(
"Policy decision for command {program:?} is {decision:?}, leading to escalation action {action:?}",
);
Ok(action)
}
}
#[async_trait::async_trait]
impl EscalationPolicy for CoreShellActionProvider {
async fn determine_action(
&self,
program: &Path,
argv: &[String],
workdir: &Path,
) -> anyhow::Result<EscalateAction> {
tracing::info!(
"Determining escalation action for command {program:?} with args {argv:?} in {workdir:?}"
);
// In the usual case, the execve wrapper reports the command being
// executed in `program`, so a direct skill lookup is sufficient.
if let Some(skill) = self.find_skill(program).await {
// For now, we always prompt for scripts that look like they belong
// to skills, which means we ignore exec policy rules for those
// scripts.
tracing::info!("Matched {program:?} to skill {skill:?}, prompting for approval");
// TODO(mbolin): We should read the permissions associated with the
// skill and use those specific permissions in the
// EscalateAction::Run case, rather than always escalating when a
// skill matches.
let needs_escalation = true;
return self
.process_decision(Decision::Prompt, needs_escalation, program, argv, workdir)
.await;
}
let command = join_program_and_argv(program, argv);
let (commands, used_complex_parsing) =
if let Some(commands) = parse_shell_lc_plain_commands(&command) {
(commands, false)
} else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) {
(vec![single_command], true)
} else {
(vec![command.clone()], false)
};
let fallback = |cmd: &[String]| {
crate::exec_policy::render_decision_for_unmatched_command(
self.approval_policy,
&self.sandbox_policy,
cmd,
self.sandbox_permissions,
used_complex_parsing,
)
};
let evaluation = {
let policy = self.policy.read().await;
policy.check_multiple(commands.iter(), &fallback)
};
// When true, means the Evaluation was due to *.rules, not the
// fallback function.
let decision_driven_by_policy =
Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision);
let needs_escalation =
self.sandbox_permissions.requires_escalated_permissions() || decision_driven_by_policy;
self.process_decision(
evaluation.decision,
needs_escalation,
program,
argv,
workdir,
)
.await
}
}
@@ -403,6 +477,13 @@ fn map_exec_result(
Ok(output)
}
/// Some functions need the full command as a Vec<String>.
fn join_program_and_argv(program: &Path, argv: &[String]) -> Vec<String> {
std::iter::once(program.to_string_lossy().to_string())
.chain(argv.iter().cloned())
.collect::<Vec<_>>()
}
#[cfg(test)]
mod tests {
use super::ParsedShellCommand;
@@ -32,7 +32,7 @@ use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
use std::collections::HashMap;
@@ -47,7 +47,7 @@ pub struct UnifiedExecRequest {
pub network: Option<NetworkProxy>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissions>,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
pub exec_approval_requirement: ExecApprovalRequirement,
}
@@ -58,7 +58,7 @@ pub struct UnifiedExecApprovalKey {
pub cwd: PathBuf,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissions>,
pub additional_permissions: Option<PermissionProfile>,
}
pub struct UnifiedExecRuntime<'a> {
+28 -21
View File
@@ -256,29 +256,36 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap<Stri
properties.insert(
"additional_permissions".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"fs_read".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant read access for this command."
.to_string(),
properties: BTreeMap::from([(
"file_system".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"read".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant read access for this command."
.to_string(),
),
},
),
},
),
(
"fs_write".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant write access for this command."
.to_string(),
(
"write".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant write access for this command."
.to_string(),
),
},
),
},
),
]),
required: None,
]),
required: None,
additional_properties: Some(false.into()),
},
)]),
required: Some(vec!["file_system".to_string()]),
additional_properties: Some(false.into()),
},
);
+2 -2
View File
@@ -29,7 +29,7 @@ use std::sync::Weak;
use std::time::Duration;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::PermissionProfile;
use rand::Rng;
use rand::rng;
use tokio::sync::Mutex;
@@ -90,7 +90,7 @@ pub(crate) struct ExecCommandRequest {
pub network: Option<NetworkProxy>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissions>,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
pub prefix_rule: Option<Vec<String>>,
}
+267
View File
@@ -0,0 +1,267 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Result;
use codex_core::features::Feature;
use core_test_support::responses;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use which::which;
use wiremock::MockServer;
fn custom_tool_output_text_and_success(
req: &ResponsesRequest,
call_id: &str,
) -> (String, Option<bool>) {
let (output, success) = req
.custom_tool_call_output_content_and_success(call_id)
.expect("custom tool output should be present");
(output.unwrap_or_default(), success)
}
async fn run_js_repl_turn(
server: &MockServer,
prompt: &str,
calls: &[(&str, &str)],
) -> Result<ResponseMock> {
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::JsRepl);
});
let test = builder.build(server).await?;
let mut first_events = vec![ev_response_created("resp-1")];
for (call_id, js_input) in calls {
first_events.push(ev_custom_tool_call(call_id, "js_repl", js_input));
}
first_events.push(ev_completed("resp-1"));
responses::mount_sse_once(server, sse(first_events)).await;
let second_mock = responses::mount_sse_once(
server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.submit_turn(prompt).await?;
Ok(second_mock)
}
fn can_run_js_repl_runtime_tests() -> bool {
// In the base CI-stabilization commit, skip these integration tests only
// when the js_repl Node runtime is not available. A later stacked commit
// removes this automatic skip and requires them everywhere.
if let Some(path) = std::env::var_os("CODEX_JS_REPL_NODE_PATH")
&& std::path::Path::new(&path).exists()
{
return true;
}
which("node").is_ok()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_persists_top_level_bindings_and_supports_tla() -> Result<()> {
skip_if_no_network!(Ok(()));
if !can_run_js_repl_runtime_tests() {
return Ok(());
}
let server = responses::start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::JsRepl);
});
let test = builder.build(&server).await?;
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(
"call-1",
"js_repl",
"let x = await Promise.resolve(41); console.log(x);",
),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_custom_tool_call("call-2", "js_repl", "console.log(x + 1);"),
ev_completed("resp-2"),
]),
)
.await;
let third_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-3"),
]),
)
.await;
test.submit_turn("run js_repl twice").await?;
let req2 = second_mock.single_request();
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
assert_ne!(
first_success,
Some(false),
"first js_repl call failed unexpectedly: {first_output}"
);
assert!(first_output.contains("41"));
let req3 = third_mock.single_request();
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
assert_ne!(
second_success,
Some(false),
"second js_repl call failed unexpectedly: {second_output}"
);
assert!(second_output.contains("42"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_can_invoke_builtin_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
if !can_run_js_repl_runtime_tests() {
return Ok(());
}
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"use js_repl to call a tool",
&[(
"call-1",
"const toolOut = await codex.tool(\"list_mcp_resources\", {}); console.log(toolOut.type);",
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"js_repl call failed unexpectedly: {output}"
);
assert!(output.contains("function_call_output"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_tool_call_rejects_recursive_js_repl_invocation() -> Result<()> {
skip_if_no_network!(Ok(()));
if !can_run_js_repl_runtime_tests() {
return Ok(());
}
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"use js_repl recursively",
&[(
"call-1",
r#"
try {
await codex.tool("js_repl", "console.log('recursive')");
console.log("unexpected-success");
} catch (err) {
console.log(String(err));
}
"#,
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"js_repl call failed unexpectedly: {output}"
);
assert!(
output.contains("js_repl cannot invoke itself"),
"expected recursion guard message, got output: {output}"
);
assert!(
!output.contains("unexpected-success"),
"recursive js_repl call unexpectedly succeeded: {output}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_does_not_expose_process_global() -> Result<()> {
skip_if_no_network!(Ok(()));
if !can_run_js_repl_runtime_tests() {
return Ok(());
}
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"check process visibility",
&[("call-1", "console.log(typeof process);")],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"js_repl call failed unexpectedly: {output}"
);
assert!(output.contains("undefined"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_blocks_sensitive_builtin_imports() -> Result<()> {
skip_if_no_network!(Ok(()));
if !can_run_js_repl_runtime_tests() {
return Ok(());
}
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"import a blocked module",
&[("call-1", "await import(\"node:process\");")],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(true),
"blocked import unexpectedly succeeded: {output}"
);
assert!(output.contains("Importing module \"node:process\" is not allowed in js_repl"));
Ok(())
}
+1
View File
@@ -78,6 +78,7 @@ mod grep_files;
mod hierarchical_agents;
mod image_rollout;
mod items;
mod js_repl;
mod json_result;
mod list_dir;
mod live_cli;
@@ -5,7 +5,8 @@ use codex_core::config::Constrained;
use codex_core::features::Feature;
use codex_core::sandboxing::SandboxPermissions;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
@@ -79,7 +80,7 @@ fn parse_result(item: &Value) -> CommandResult {
fn shell_event_with_request_permissions(
call_id: &str,
command: &str,
additional_permissions: &AdditionalPermissions,
additional_permissions: &PermissionProfile,
) -> Result<Value> {
let args = json!({
"command": command,
@@ -184,9 +185,12 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
let _ = fs::remove_file(&requested_write);
let call_id = "request_permissions_skip_approval";
let command = "touch requested-but-unused.txt";
let requested_permissions = AdditionalPermissions {
fs_read: vec![],
fs_write: vec![requested_write.clone()],
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![requested_write.clone()]),
}),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, command, &requested_permissions)?;
@@ -266,9 +270,12 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write()
"printf {:?} > {:?} && cat {:?}",
"cwd-widened", unrequested_write, unrequested_write
);
let requested_permissions = AdditionalPermissions {
fs_read: vec![],
fs_write: vec![requested_write.clone()],
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![requested_write.clone()]),
}),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?;
@@ -354,9 +361,12 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_tmp_write()
"printf {:?} > {:?} && cat {:?}",
"tmp-widened", tmp_write, tmp_write
);
let requested_permissions = AdditionalPermissions {
fs_read: vec![],
fs_write: vec![requested_write.clone()],
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![requested_write.clone()]),
}),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?;
@@ -442,13 +452,19 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() ->
"printf {:?} > {:?} && cat {:?}",
"outside-cwd-ok", outside_write, outside_write
);
let requested_permissions = AdditionalPermissions {
fs_read: vec![],
fs_write: vec![outside_dir.path().to_path_buf()],
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![outside_dir.path().to_path_buf()]),
}),
..Default::default()
};
let normalized_requested_permissions = AdditionalPermissions {
fs_read: vec![],
fs_write: vec![outside_dir.path().canonicalize()?],
let normalized_requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![outside_dir.path().canonicalize()?]),
}),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?;
@@ -530,13 +546,19 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul
"printf {:?} > {:?} && cat {:?}",
"should-not-write", outside_write, outside_write
);
let requested_permissions = AdditionalPermissions {
fs_read: vec![],
fs_write: vec![outside_dir.path().to_path_buf()],
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![outside_dir.path().to_path_buf()]),
}),
..Default::default()
};
let normalized_requested_permissions = AdditionalPermissions {
fs_read: vec![],
fs_write: vec![outside_dir.path().canonicalize()?],
let normalized_requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![outside_dir.path().canonicalize()?]),
}),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?;
+195 -2
View File
@@ -2,8 +2,10 @@
use anyhow::Result;
use codex_core::features::Feature;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::skill_approval::SkillApprovalResponse;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -59,6 +61,21 @@ fn command_for_script(script_path: &Path) -> Result<String> {
}
async fn submit_turn(test: &TestCodex, prompt: &str) -> Result<()> {
submit_turn_with_policies(
test,
prompt,
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await
}
async fn submit_turn_with_policies(
test: &TestCodex,
prompt: &str,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
) -> Result<()> {
let session_model = test.session_configured.model.clone();
test.codex
.submit(Op::UserTurn {
@@ -68,8 +85,8 @@ async fn submit_turn(test: &TestCodex, prompt: &str) -> Result<()> {
}],
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: codex_protocol::protocol::AskForApproval::Never,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
approval_policy,
sandbox_policy,
model: session_model,
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
@@ -91,6 +108,65 @@ async fn wait_for_turn_complete_without_skill_approval(test: &TestCodex) {
.await;
}
#[cfg(unix)]
fn write_skill_with_shell_script(home: &Path, name: &str, script_name: &str) -> Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let skill_dir = home.join("skills").join(name);
let scripts_dir = skill_dir.join("scripts");
fs::create_dir_all(&scripts_dir)?;
fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {name} skill\n---\n"),
)?;
let script_path = scripts_dir.join(script_name);
fs::write(
&script_path,
"#!/bin/sh\necho 'zsh-fork-stdout'\necho 'zsh-fork-stderr' >&2\n",
)?;
let mut permissions = fs::metadata(&script_path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions)?;
Ok(script_path)
}
#[cfg(unix)]
fn find_test_zsh_path() -> Result<Option<PathBuf>> {
use core_test_support::fetch_dotslash_file;
let repo_root = codex_utils_cargo_bin::repo_root()?;
let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh");
if !dotslash_zsh.is_file() {
eprintln!(
"skipping zsh-fork skill test: shared zsh DotSlash file not found at {}",
dotslash_zsh.display()
);
return Ok(None);
}
match fetch_dotslash_file(&dotslash_zsh, None) {
Ok(path) => Ok(Some(path)),
Err(error) => {
eprintln!("skipping zsh-fork skill test: failed to fetch zsh via dotslash: {error:#}");
Ok(None)
}
}
}
#[cfg(unix)]
fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool {
let status = std::process::Command::new(zsh_path)
.arg("-fc")
.arg("/usr/bin/true")
.env("EXEC_WRAPPER", "/usr/bin/false")
.status();
match status {
Ok(status) => !status.success(),
Err(_) => false,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn skill_approval_event_round_trip_for_shell_command_skill_script_exec() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -317,3 +393,120 @@ async fn skill_approval_cache_is_per_skill() -> Result<()> {
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_prompts_for_skill_script_execution() -> Result<()> {
use codex_config::Constrained;
use codex_protocol::protocol::ReviewDecision;
skip_if_no_network!(Ok(()));
let Some(zsh_path) = find_test_zsh_path()? else {
return Ok(());
};
if !supports_exec_wrapper_intercept(&zsh_path) {
eprintln!(
"skipping zsh-fork skill test: zsh does not support EXEC_WRAPPER intercepts ({})",
zsh_path.display()
);
return Ok(());
}
let Ok(main_execve_wrapper_exe) = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")
else {
eprintln!("skipping zsh-fork skill test: unable to resolve `codex-execve-wrapper` binary");
return Ok(());
};
let server = start_mock_server().await;
let tool_call_id = "zsh-fork-skill-call";
let mut builder = test_codex()
.with_pre_build_hook(|home| {
write_skill_with_shell_script(home, "mbolin-test-skill", "hello-mbolin.sh").unwrap();
})
.with_config(move |config| {
config.features.enable(Feature::ShellTool);
config.features.enable(Feature::ShellZshFork);
config.zsh_path = Some(zsh_path.clone());
config.main_execve_wrapper_exe = Some(main_execve_wrapper_exe);
config.permissions.allow_login_shell = false;
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.permissions.sandbox_policy =
Constrained::allow_any(SandboxPolicy::new_workspace_write_policy());
});
let test = builder.build(&server).await?;
let script_path = fs::canonicalize(
test.codex_home_path()
.join("skills/mbolin-test-skill/scripts/hello-mbolin.sh"),
)?;
let script_path_str = script_path.to_string_lossy().into_owned();
let command = shlex::try_join([script_path_str.as_str()])?;
let arguments = shell_command_arguments(&command)?;
let mocks =
mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command")
.await;
submit_turn_with_policies(
&test,
"use $mbolin-test-skill",
AskForApproval::OnRequest,
SandboxPolicy::new_workspace_write_policy(),
)
.await?;
let maybe_approval = wait_for_event_match(test.codex.as_ref(), |event| match event {
EventMsg::ExecApprovalRequest(request) => Some(Some(request.clone())),
EventMsg::TurnComplete(_) => Some(None),
_ => None,
})
.await;
let approval = match maybe_approval {
Some(approval) => approval,
None => {
let call_output = mocks
.completion
.single_request()
.function_call_output(tool_call_id);
panic!(
"expected exec approval request before completion; function_call_output={call_output:?}"
);
}
};
assert_eq!(approval.call_id, tool_call_id);
assert_eq!(approval.command.first(), Some(&script_path_str));
assert!(
approval.command.iter().all(|arg| arg == &script_path_str),
"expected approval command to contain only the script path: {:?}",
approval.command
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_event(test.codex.as_ref(), |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let call_output = mocks
.completion
.single_request()
.function_call_output(tool_call_id);
let output = call_output["output"].as_str().unwrap_or_default();
assert!(
output.contains("zsh-fork-stdout"),
"expected stdout marker in function_call_output: {output:?}"
);
assert!(
output.contains("zsh-fork-stderr"),
"expected stderr marker in function_call_output: {output:?}"
);
Ok(())
}
+32 -15
View File
@@ -36,28 +36,37 @@ use image::GenericImageView;
use image::ImageBuffer;
use image::Rgba;
use image::load_from_memory;
use pretty_assertions::assert_eq;
use serde_json::Value;
use tokio::time::Duration;
use wiremock::BodyPrintLimit;
use wiremock::MockServer;
fn find_image_message(body: &Value) -> Option<&Value> {
fn image_messages(body: &Value) -> Vec<&Value> {
body.get("input")
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find(|item| {
item.get("type").and_then(Value::as_str) == Some("message")
&& item
.get("content")
.and_then(Value::as_array)
.map(|content| {
content.iter().any(|span| {
span.get("type").and_then(Value::as_str) == Some("input_image")
.map(|items| {
items
.iter()
.filter(|item| {
item.get("type").and_then(Value::as_str) == Some("message")
&& item
.get("content")
.and_then(Value::as_array)
.map(|content| {
content.iter().any(|span| {
span.get("type").and_then(Value::as_str) == Some("input_image")
})
})
})
.unwrap_or(false)
})
.unwrap_or(false)
})
.collect()
})
.unwrap_or_default()
}
fn find_image_message(body: &Value) -> Option<&Value> {
image_messages(body).into_iter().next()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -366,8 +375,16 @@ console.log(out.output?.body?.text ?? "");
);
let body = req.body_json();
let image_message =
find_image_message(&body).expect("pending input image message not included in request");
let image_messages = image_messages(&body);
assert_eq!(
image_messages.len(),
1,
"js_repl view_image should inject exactly one pending input image message"
);
let image_message = image_messages
.into_iter()
.next()
.expect("pending input image message not included in request");
let image_url = image_message
.get("content")
.and_then(Value::as_array)
+2 -2
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use crate::mcp::RequestId;
use crate::models::AdditionalPermissions;
use crate::models::PermissionProfile;
use crate::parse_command::ParsedCommand;
use crate::protocol::FileChange;
use schemars::JsonSchema;
@@ -106,7 +106,7 @@ pub struct ExecApprovalRequestEvent {
/// Optional additional filesystem permissions requested for this command.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<AdditionalPermissions>,
pub additional_permissions: Option<PermissionProfile>,
pub parsed_cmd: Vec<ParsedCommand>,
}
+61 -9
View File
@@ -53,16 +53,68 @@ impl SandboxPermissions {
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
pub struct AdditionalPermissions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fs_read: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fs_write: Vec<PathBuf>,
pub struct FileSystemPermissions {
pub read: Option<Vec<PathBuf>>,
pub write: Option<Vec<PathBuf>>,
}
impl AdditionalPermissions {
impl FileSystemPermissions {
pub fn is_empty(&self) -> bool {
self.fs_read.is_empty() && self.fs_write.is_empty()
self.read.is_none() && self.write.is_none()
}
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
pub struct MacOsPermissions {
pub preferences: Option<MacOsPreferencesValue>,
pub automations: Option<MacOsAutomationValue>,
pub accessibility: Option<bool>,
pub calendar: Option<bool>,
}
impl MacOsPermissions {
pub fn is_empty(&self) -> bool {
self.preferences.is_none()
&& self.automations.is_none()
&& self.accessibility.is_none()
&& self.calendar.is_none()
}
}
#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(untagged)]
pub enum MacOsPreferencesValue {
Bool(bool),
Mode(String),
}
#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(untagged)]
pub enum MacOsAutomationValue {
Bool(bool),
BundleIds(Vec<String>),
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
pub struct PermissionProfile {
pub network: Option<bool>,
pub file_system: Option<FileSystemPermissions>,
pub macos: Option<MacOsPermissions>,
}
impl PermissionProfile {
pub fn is_empty(&self) -> bool {
self.network.is_none()
&& self
.file_system
.as_ref()
.map(FileSystemPermissions::is_empty)
.unwrap_or(true)
&& self
.macos
.as_ref()
.map(MacOsPermissions::is_empty)
.unwrap_or(true)
}
}
@@ -800,7 +852,7 @@ pub struct ShellToolCallParams {
pub prefix_rule: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<AdditionalPermissions>,
pub additional_permissions: Option<PermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
@@ -826,7 +878,7 @@ pub struct ShellCommandToolCallParams {
pub prefix_rule: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<AdditionalPermissions>,
pub additional_permissions: Option<PermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
@@ -8,8 +8,8 @@ When you need extra filesystem access for one command, use:
- `sandbox_permissions: "with_additional_permissions"`
- `additional_permissions` with one or both fields:
- `fs_read`: list of paths that need read access
- `fs_write`: list of paths that need write access
- `file_system.read`: list of paths that need read access
- `file_system.write`: list of paths that need write access
This keeps execution inside the current sandbox policy, while adding only the requested permissions for that command, unless an exec-policy allow rule applies and authorizes running the command outside the sandbox.
@@ -17,7 +17,10 @@ pub const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER";
/// The client sends this to the server to request an exec() call.
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct EscalateRequest {
/// The absolute path to the executable to run, i.e. the first arg to exec.
/// The executable path from the intercepted exec call.
///
/// This may be relative, in which case it should be resolved against
/// `workdir`.
pub file: PathBuf,
/// The argv, including the program name (argv[0]).
pub argv: Vec<String>,
@@ -152,8 +152,8 @@ async fn handle_escalate_session_with_policy(
workdir,
env,
} = socket.receive::<EscalateRequest>().await?;
let file = PathBuf::from(&file).absolutize()?.into_owned();
let workdir = PathBuf::from(&workdir).absolutize()?.into_owned();
let file = file.absolutize_from(&workdir)?.into_owned();
let action = policy
.determine_action(file.as_path(), &argv, &workdir)
.await
@@ -257,6 +257,25 @@ mod tests {
}
}
struct AssertingEscalationPolicy {
expected_file: PathBuf,
expected_workdir: PathBuf,
}
#[async_trait::async_trait]
impl EscalationPolicy for AssertingEscalationPolicy {
async fn determine_action(
&self,
file: &Path,
_argv: &[String],
workdir: &Path,
) -> anyhow::Result<EscalateAction> {
assert_eq!(file, self.expected_file.as_path());
assert_eq!(workdir, self.expected_workdir.as_path());
Ok(EscalateAction::Run)
}
}
#[tokio::test]
async fn handle_escalate_session_respects_run_in_sandbox_decision() -> anyhow::Result<()> {
let (server, client) = AsyncSocket::pair()?;
@@ -292,6 +311,41 @@ mod tests {
server_task.await?
}
#[tokio::test]
async fn handle_escalate_session_resolves_relative_file_against_request_workdir()
-> anyhow::Result<()> {
let (server, client) = AsyncSocket::pair()?;
let tmp = tempfile::TempDir::new()?;
let workdir = tmp.path().join("workspace");
std::fs::create_dir(&workdir)?;
let expected_file = workdir.join("bin/tool");
let server_task = tokio::spawn(handle_escalate_session_with_policy(
server,
Arc::new(AssertingEscalationPolicy {
expected_file,
expected_workdir: workdir.clone(),
}),
));
client
.send(EscalateRequest {
file: PathBuf::from("./bin/tool"),
argv: vec!["./bin/tool".to_string()],
workdir,
env: HashMap::new(),
})
.await?;
let response = client.receive::<EscalateResponse>().await?;
assert_eq!(
EscalateResponse {
action: EscalateAction::Run,
},
response
);
server_task.await?
}
#[tokio::test]
async fn handle_escalate_session_executes_escalated_command() -> anyhow::Result<()> {
let (server, client) = AsyncSocket::pair()?;
@@ -18,7 +18,7 @@ use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use codex_core::features::Features;
use codex_protocol::mcp::RequestId;
use codex_protocol::models::AdditionalPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ElicitationAction;
use codex_protocol::protocol::ExecPolicyAmendment;
use codex_protocol::protocol::FileChange;
@@ -46,7 +46,7 @@ pub(crate) enum ApprovalRequest {
reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
additional_permissions: Option<AdditionalPermissions>,
additional_permissions: Option<PermissionProfile>,
},
ApplyPatch {
id: String,
@@ -450,7 +450,7 @@ enum ApprovalVariant {
command: Vec<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
additional_permissions: Option<AdditionalPermissions>,
additional_permissions: Option<PermissionProfile>,
},
ApplyPatch {
id: String,
@@ -486,7 +486,7 @@ impl ApprovalOption {
fn exec_options(
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
network_approval_context: Option<&NetworkApprovalContext>,
additional_permissions: Option<&AdditionalPermissions>,
additional_permissions: Option<&PermissionProfile>,
) -> Vec<ApprovalOption> {
if network_approval_context.is_some() {
return vec![
@@ -562,26 +562,26 @@ fn exec_options(
}
fn format_additional_permissions_rule(
additional_permissions: &AdditionalPermissions,
additional_permissions: &PermissionProfile,
) -> Option<String> {
let mut parts = Vec::new();
if !additional_permissions.fs_read.is_empty() {
let reads = additional_permissions
.fs_read
.iter()
.map(|path| format!("`{}`", path.display()))
.collect::<Vec<_>>()
.join(", ");
parts.push(format!("read {reads}"));
}
if !additional_permissions.fs_write.is_empty() {
let writes = additional_permissions
.fs_write
.iter()
.map(|path| format!("`{}`", path.display()))
.collect::<Vec<_>>()
.join(", ");
parts.push(format!("write {writes}"));
if let Some(file_system) = additional_permissions.file_system.as_ref() {
if let Some(read) = file_system.read.as_ref() {
let reads = read
.iter()
.map(|path| format!("`{}`", path.display()))
.collect::<Vec<_>>()
.join(", ");
parts.push(format!("read {reads}"));
}
if let Some(write) = file_system.write.as_ref() {
let writes = write
.iter()
.map(|path| format!("`{}`", path.display()))
.collect::<Vec<_>>()
.join(", ");
parts.push(format!("write {writes}"));
}
}
if parts.is_empty() {
@@ -641,6 +641,7 @@ fn elicitation_options() -> Vec<ApprovalOption> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::protocol::NetworkApprovalProtocol;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
@@ -800,9 +801,12 @@ mod tests {
#[test]
fn additional_permissions_exec_options_hide_execpolicy_amendment() {
let additional_permissions = AdditionalPermissions {
fs_read: vec![PathBuf::from("/tmp/readme.txt")],
fs_write: vec![PathBuf::from("/tmp/out.txt")],
let additional_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![PathBuf::from("/tmp/readme.txt")]),
write: Some(vec![PathBuf::from("/tmp/out.txt")]),
}),
..Default::default()
};
let options = exec_options(None, None, Some(&additional_permissions));
@@ -826,9 +830,12 @@ mod tests {
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
additional_permissions: Some(AdditionalPermissions {
fs_read: vec![PathBuf::from("/tmp/readme.txt")],
fs_write: vec![PathBuf::from("/tmp/out.txt")],
additional_permissions: Some(PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![PathBuf::from("/tmp/readme.txt")]),
write: Some(vec![PathBuf::from("/tmp/out.txt")]),
}),
..Default::default()
}),
};
@@ -862,9 +869,12 @@ mod tests {
reason: Some("need filesystem access".into()),
network_approval_context: None,
proposed_execpolicy_amendment: None,
additional_permissions: Some(AdditionalPermissions {
fs_read: vec![PathBuf::from("/tmp/readme.txt")],
fs_write: vec![PathBuf::from("/tmp/out.txt")],
additional_permissions: Some(PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![PathBuf::from("/tmp/readme.txt")]),
write: Some(vec![PathBuf::from("/tmp/out.txt")]),
}),
..Default::default()
}),
};
+2 -2
View File
@@ -3,11 +3,11 @@ def _rbe_platform_repo_impl(rctx):
if arch in ["x86_64", "amd64"]:
cpu = "x86_64"
exec_arch = "amd64"
image_sha = "8c9ff94187ea7c08a31e9a81f5fe8046ea3972a6768983c955c4079fa30567fb"
image_sha = "0a8e56bfaa3b2e5279db0d3bb2d62b44ba5e5d63a37d97eb8790f49da570af70"
elif arch in ["aarch64", "arm64"]:
cpu = "aarch64"
exec_arch = "arm64"
image_sha = "ad9506086215fccfc66ed8d2be87847324be56790ae6a1964c241c28b77ef141"
image_sha = "136487cc4b7cf6f1021816ca18ed00896daed98404ea91dc4d6dd9e9d1cf9564"
else:
fail("Unsupported host arch for rbe platform: {}".format(arch))