Compare commits

...

19 Commits

Author SHA1 Message Date
starr-openai
689107592d codex: route view_image through sandboxed fs options 2026-04-06 17:01:56 -07:00
starr-openai
a19601e767 codex: use tempdir-backed apply_patch test paths 2026-04-06 16:40:53 -07:00
starr-openai
6b9c625223 codex: pin manual approval reviewer in approval tests 2026-04-06 16:40:53 -07:00
starr-openai
d725d0f825 codex: emit apply_patch file change output deltas 2026-04-06 16:40:53 -07:00
starr-openai
ffd36edcf8 codex: normalize apply_patch fs cwd 2026-04-06 16:40:53 -07:00
starr-openai
350b24d177 codex: normalize apply_patch fs paths against symlinked ancestors 2026-04-06 16:40:53 -07:00
starr-openai
8b1b2e9efd codex: restore generated app-server schema fixtures 2026-04-06 16:40:53 -07:00
starr-openai
fc86503a4e codex: pass cwd through sandboxed fs ops 2026-04-06 16:40:53 -07:00
starr-openai
3aa46997c0 codex: keep apply_patch summaries relative 2026-04-06 16:40:53 -07:00
starr-openai
e4939840ab codex: fix apply_patch clippy borrow 2026-04-06 16:40:53 -07:00
starr-openai
957f8e8433 codex: fix apply_patch test imports 2026-04-06 16:40:53 -07:00
starr-openai
23d45e5224 codex: simplify apply_patch sandbox FS path 2026-04-06 16:40:53 -07:00
starr-openai
1121fd8490 codex: keep local apply_patch verification on host fs 2026-04-06 16:40:53 -07:00
starr-openai
59da18ca84 codex: move apply_patch onto environment filesystem 2026-04-06 16:40:53 -07:00
starr-openai
0a346655bd codex: remove stale exec-server disabled-environment test
Co-authored-by: Codex <noreply@openai.com>
2026-04-06 15:59:41 -07:00
starr-openai
828f8c73b1 codex: fix sdk follow-up on PR #16937 2026-04-06 14:47:29 -07:00
starr-openai
b99066b0ae codex: fix follow-up CI failures on PR #16937 2026-04-06 14:43:35 -07:00
starr-openai
994f8311ca codex: fix CI failure on PR #16937 2026-04-06 14:36:46 -07:00
starr-openai
38ea15e634 Surface remote startup exec approvals
Add exec-server startup approval plumbing, wire it into unified exec, and cover the live remote path with focused smoke coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-04-06 13:02:54 -07:00
57 changed files with 3040 additions and 277 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2048,6 +2048,7 @@ dependencies = [
"base64 0.22.1",
"clap",
"codex-app-server-protocol",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-pty",

View File

@@ -663,6 +663,17 @@
"FsCopyParams": {
"description": "Copy a file or directory tree on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"destinationPath": {
"allOf": [
{
@@ -693,6 +704,17 @@
"FsCreateDirectoryParams": {
"description": "Create a directory on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -717,6 +739,17 @@
"FsGetMetadataParams": {
"description": "Request metadata for an absolute path.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -734,6 +767,17 @@
"FsReadDirectoryParams": {
"description": "List direct child names for a directory.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -751,6 +795,17 @@
"FsReadFileParams": {
"description": "Read a file from the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -768,6 +823,17 @@
"FsRemoveParams": {
"description": "Remove a file or directory tree from the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"force": {
"description": "Whether missing paths should be ignored. Defaults to `true`.",
"type": [
@@ -829,6 +895,17 @@
"FsWriteFileParams": {
"description": "Write a file on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"dataBase64": {
"description": "File contents encoded as base64.",
"type": "string"

View File

@@ -7502,6 +7502,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Copy a file or directory tree on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"destinationPath": {
"allOf": [
{
@@ -7540,6 +7551,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Create a directory on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -7572,6 +7594,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Request metadata for an absolute path.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -7646,6 +7679,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "List direct child names for a directory.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -7683,6 +7727,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Read a file from the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -7717,6 +7772,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Remove a file or directory tree from the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"force": {
"description": "Whether missing paths should be ignored. Defaults to `true`.",
"type": [
@@ -7820,6 +7886,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write a file on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"dataBase64": {
"description": "File contents encoded as base64.",
"type": "string"

View File

@@ -4170,6 +4170,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Copy a file or directory tree on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"destinationPath": {
"allOf": [
{
@@ -4208,6 +4219,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Create a directory on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -4240,6 +4262,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Request metadata for an absolute path.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -4314,6 +4347,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "List direct child names for a directory.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -4351,6 +4395,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Read a file from the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{
@@ -4385,6 +4440,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Remove a file or directory tree from the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"force": {
"description": "Whether missing paths should be ignored. Defaults to `true`.",
"type": [
@@ -4488,6 +4554,17 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write a file on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"dataBase64": {
"description": "File contents encoded as base64.",
"type": "string"

View File

@@ -8,6 +8,17 @@
},
"description": "Copy a file or directory tree on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"destinationPath": {
"allOf": [
{

View File

@@ -8,6 +8,17 @@
},
"description": "Create a directory on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{

View File

@@ -8,6 +8,17 @@
},
"description": "Request metadata for an absolute path.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{

View File

@@ -8,6 +8,17 @@
},
"description": "List direct child names for a directory.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{

View File

@@ -8,6 +8,17 @@
},
"description": "Read a file from the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"path": {
"allOf": [
{

View File

@@ -8,6 +8,17 @@
},
"description": "Remove a file or directory tree from the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"force": {
"description": "Whether missing paths should be ignored. Defaults to `true`.",
"type": [

View File

@@ -8,6 +8,17 @@
},
"description": "Write a file on the host filesystem.",
"properties": {
"cwd": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Optional cwd to resolve legacy sandbox workspace roots against."
},
"dataBase64": {
"description": "File contents encoded as base64.",
"type": "string"

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SandboxPolicy } from "./SandboxPolicy";
/**
* Copy a file or directory tree on the host filesystem.
@@ -18,4 +19,12 @@ destinationPath: AbsolutePathBuf,
/**
* Required for directory copies; ignored for file copies.
*/
recursive?: boolean, };
recursive?: boolean,
/**
* Optional sandbox policy for this filesystem copy.
*/
sandboxPolicy?: SandboxPolicy | null,
/**
* Optional cwd to resolve legacy sandbox workspace roots against.
*/
cwd?: AbsolutePathBuf | null, };

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SandboxPolicy } from "./SandboxPolicy";
/**
* Create a directory on the host filesystem.
@@ -14,4 +15,12 @@ path: AbsolutePathBuf,
/**
* Whether parent directories should also be created. Defaults to `true`.
*/
recursive?: boolean | null, };
recursive?: boolean | null,
/**
* Optional sandbox policy for this filesystem mutation.
*/
sandboxPolicy?: SandboxPolicy | null,
/**
* Optional cwd to resolve legacy sandbox workspace roots against.
*/
cwd?: AbsolutePathBuf | null, };

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SandboxPolicy } from "./SandboxPolicy";
/**
* Request metadata for an absolute path.
@@ -10,4 +11,12 @@ export type FsGetMetadataParams = {
/**
* Absolute path to inspect.
*/
path: AbsolutePathBuf, };
path: AbsolutePathBuf,
/**
* Optional sandbox policy for this filesystem metadata lookup.
*/
sandboxPolicy?: SandboxPolicy | null,
/**
* Optional cwd to resolve legacy sandbox workspace roots against.
*/
cwd?: AbsolutePathBuf | null, };

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SandboxPolicy } from "./SandboxPolicy";
/**
* List direct child names for a directory.
@@ -10,4 +11,12 @@ export type FsReadDirectoryParams = {
/**
* Absolute directory path to read.
*/
path: AbsolutePathBuf, };
path: AbsolutePathBuf,
/**
* Optional sandbox policy for this directory read.
*/
sandboxPolicy?: SandboxPolicy | null,
/**
* Optional cwd to resolve legacy sandbox workspace roots against.
*/
cwd?: AbsolutePathBuf | null, };

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SandboxPolicy } from "./SandboxPolicy";
/**
* Read a file from the host filesystem.
@@ -10,4 +11,12 @@ export type FsReadFileParams = {
/**
* Absolute path to read.
*/
path: AbsolutePathBuf, };
path: AbsolutePathBuf,
/**
* Optional sandbox policy for this filesystem read.
*/
sandboxPolicy?: SandboxPolicy | null,
/**
* Optional cwd to resolve legacy sandbox workspace roots against.
*/
cwd?: AbsolutePathBuf | null, };

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SandboxPolicy } from "./SandboxPolicy";
/**
* Remove a file or directory tree from the host filesystem.
@@ -18,4 +19,12 @@ recursive?: boolean | null,
/**
* Whether missing paths should be ignored. Defaults to `true`.
*/
force?: boolean | null, };
force?: boolean | null,
/**
* Optional sandbox policy for this filesystem mutation.
*/
sandboxPolicy?: SandboxPolicy | null,
/**
* Optional cwd to resolve legacy sandbox workspace roots against.
*/
cwd?: AbsolutePathBuf | null, };

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SandboxPolicy } from "./SandboxPolicy";
/**
* Write a file on the host filesystem.
@@ -14,4 +15,12 @@ path: AbsolutePathBuf,
/**
* File contents encoded as base64.
*/
dataBase64: string, };
dataBase64: string,
/**
* Optional sandbox policy for this filesystem write.
*/
sandboxPolicy?: SandboxPolicy | null,
/**
* Optional cwd to resolve legacy sandbox workspace roots against.
*/
cwd?: AbsolutePathBuf | null, };

View File

@@ -1610,14 +1610,18 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::FsGetMetadataParams {
path: absolute_path("tmp/example"),
sandbox_policy: None,
cwd: None,
},
};
assert_eq!(
json!({
"method": "fs/getMetadata",
"id": 9,
"params": {
"path": absolute_path_string("tmp/example")
"params": {
"path": absolute_path_string("tmp/example"),
"sandboxPolicy": null,
"cwd": null
}
}),
serde_json::to_value(&request)?,

View File

@@ -2170,6 +2170,12 @@ pub struct FeedbackUploadResponse {
pub struct FsReadFileParams {
/// Absolute path to read.
pub path: AbsolutePathBuf,
/// Optional sandbox policy for this filesystem read.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Optional cwd to resolve legacy sandbox workspace roots against.
#[ts(optional = nullable)]
pub cwd: Option<AbsolutePathBuf>,
}
/// Base64-encoded file contents returned by `fs/readFile`.
@@ -2190,6 +2196,12 @@ pub struct FsWriteFileParams {
pub path: AbsolutePathBuf,
/// File contents encoded as base64.
pub data_base64: String,
/// Optional sandbox policy for this filesystem write.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Optional cwd to resolve legacy sandbox workspace roots against.
#[ts(optional = nullable)]
pub cwd: Option<AbsolutePathBuf>,
}
/// Successful response for `fs/writeFile`.
@@ -2208,6 +2220,12 @@ pub struct FsCreateDirectoryParams {
/// Whether parent directories should also be created. Defaults to `true`.
#[ts(optional = nullable)]
pub recursive: Option<bool>,
/// Optional sandbox policy for this filesystem mutation.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Optional cwd to resolve legacy sandbox workspace roots against.
#[ts(optional = nullable)]
pub cwd: Option<AbsolutePathBuf>,
}
/// Successful response for `fs/createDirectory`.
@@ -2223,6 +2241,12 @@ pub struct FsCreateDirectoryResponse {}
pub struct FsGetMetadataParams {
/// Absolute path to inspect.
pub path: AbsolutePathBuf,
/// Optional sandbox policy for this filesystem metadata lookup.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Optional cwd to resolve legacy sandbox workspace roots against.
#[ts(optional = nullable)]
pub cwd: Option<AbsolutePathBuf>,
}
/// Metadata returned by `fs/getMetadata`.
@@ -2249,6 +2273,12 @@ pub struct FsGetMetadataResponse {
pub struct FsReadDirectoryParams {
/// Absolute directory path to read.
pub path: AbsolutePathBuf,
/// Optional sandbox policy for this directory read.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Optional cwd to resolve legacy sandbox workspace roots against.
#[ts(optional = nullable)]
pub cwd: Option<AbsolutePathBuf>,
}
/// A directory entry returned by `fs/readDirectory`.
@@ -2286,6 +2316,12 @@ pub struct FsRemoveParams {
/// Whether missing paths should be ignored. Defaults to `true`.
#[ts(optional = nullable)]
pub force: Option<bool>,
/// Optional sandbox policy for this filesystem mutation.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Optional cwd to resolve legacy sandbox workspace roots against.
#[ts(optional = nullable)]
pub cwd: Option<AbsolutePathBuf>,
}
/// Successful response for `fs/remove`.
@@ -2306,6 +2342,12 @@ pub struct FsCopyParams {
/// Required for directory copies; ignored for file copies.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub recursive: bool,
/// Optional sandbox policy for this filesystem copy.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Optional cwd to resolve legacy sandbox workspace roots against.
#[ts(optional = nullable)]
pub cwd: Option<AbsolutePathBuf>,
}
/// Successful response for `fs/copy`.
@@ -6528,6 +6570,8 @@ mod tests {
fn fs_read_file_params_round_trip() {
let params = FsReadFileParams {
path: absolute_path("tmp/example.txt"),
sandbox_policy: None,
cwd: None,
};
let value = serde_json::to_value(&params).expect("serialize fs/readFile params");
@@ -6535,6 +6579,8 @@ mod tests {
value,
json!({
"path": absolute_path_string("tmp/example.txt"),
"sandboxPolicy": null,
"cwd": null,
})
);
@@ -6548,6 +6594,8 @@ mod tests {
let params = FsCreateDirectoryParams {
path: absolute_path("tmp/example"),
recursive: None,
sandbox_policy: None,
cwd: None,
};
let value = serde_json::to_value(&params).expect("serialize fs/createDirectory params");
@@ -6556,6 +6604,8 @@ mod tests {
json!({
"path": absolute_path_string("tmp/example"),
"recursive": null,
"sandboxPolicy": null,
"cwd": null,
})
);
@@ -6569,6 +6619,8 @@ mod tests {
let params = FsWriteFileParams {
path: absolute_path("tmp/example.bin"),
data_base64: "AAE=".to_string(),
sandbox_policy: None,
cwd: None,
};
let value = serde_json::to_value(&params).expect("serialize fs/writeFile params");
@@ -6577,6 +6629,8 @@ mod tests {
json!({
"path": absolute_path_string("tmp/example.bin"),
"dataBase64": "AAE=",
"sandboxPolicy": null,
"cwd": null,
})
);
@@ -6591,6 +6645,8 @@ mod tests {
source_path: absolute_path("tmp/source"),
destination_path: absolute_path("tmp/destination"),
recursive: true,
sandbox_policy: None,
cwd: None,
};
let value = serde_json::to_value(&params).expect("serialize fs/copy params");
@@ -6600,6 +6656,8 @@ mod tests {
"sourcePath": absolute_path_string("tmp/source"),
"destinationPath": absolute_path_string("tmp/destination"),
"recursive": true,
"sandboxPolicy": null,
"cwd": null,
})
);

View File

@@ -22,6 +22,7 @@ use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileSystemOperationOptions;
use codex_exec_server::RemoveOptions;
use std::io;
use std::sync::Arc;
@@ -46,7 +47,10 @@ impl FsApi {
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system
.read_file(&params.path)
.read_file_with_options(
&params.path,
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
Ok(FsReadFileResponse {
@@ -64,7 +68,11 @@ impl FsApi {
))
})?;
self.file_system
.write_file(&params.path, bytes)
.write_file_with_options(
&params.path,
bytes,
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
Ok(FsWriteFileResponse {})
@@ -75,11 +83,12 @@ impl FsApi {
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
self.file_system
.create_directory(
.create_directory_with_options(
&params.path,
CreateDirectoryOptions {
recursive: params.recursive.unwrap_or(true),
},
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
@@ -92,7 +101,10 @@ impl FsApi {
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
let metadata = self
.file_system
.get_metadata(&params.path)
.get_metadata_with_options(
&params.path,
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
Ok(FsGetMetadataResponse {
@@ -109,7 +121,10 @@ impl FsApi {
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
let entries = self
.file_system
.read_directory(&params.path)
.read_directory_with_options(
&params.path,
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
Ok(FsReadDirectoryResponse {
@@ -129,12 +144,13 @@ impl FsApi {
params: FsRemoveParams,
) -> Result<FsRemoveResponse, JSONRPCErrorError> {
self.file_system
.remove(
.remove_with_options(
&params.path,
RemoveOptions {
recursive: params.recursive.unwrap_or(true),
force: params.force.unwrap_or(true),
},
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
@@ -146,12 +162,13 @@ impl FsApi {
params: FsCopyParams,
) -> Result<FsCopyResponse, JSONRPCErrorError> {
self.file_system
.copy(
.copy_with_options(
&params.source_path,
&params.destination_path,
CopyOptions {
recursive: params.recursive,
},
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
@@ -159,6 +176,16 @@ impl FsApi {
}
}
fn fs_operation_options(
sandbox_policy: Option<codex_app_server_protocol::SandboxPolicy>,
cwd: Option<codex_utils_absolute_path::AbsolutePathBuf>,
) -> FileSystemOperationOptions {
FileSystemOperationOptions {
sandbox_policy: sandbox_policy.map(|policy| policy.to_core()),
cwd,
}
}
pub(crate) fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,

View File

@@ -13,7 +13,9 @@ use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsWatchResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::ReadOnlyAccess;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -61,6 +63,16 @@ fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(path).expect("path should be absolute")
}
fn read_only_sandbox_policy(readable_root: PathBuf) -> SandboxPolicy {
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: vec![absolute_path(readable_root)],
},
network_access: false,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -71,6 +83,8 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
let request_id = mcp
.send_fs_get_metadata_request(codex_app_server_protocol::FsGetMetadataParams {
path: absolute_path(file_path.clone()),
sandbox_policy: None,
cwd: None,
})
.await?;
let response = timeout(
@@ -129,6 +143,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
.send_fs_create_directory_request(codex_app_server_protocol::FsCreateDirectoryParams {
path: absolute_path(nested_dir.clone()),
recursive: None,
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -141,6 +157,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(nested_file.clone()),
data_base64: STANDARD.encode("hello from app-server"),
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -153,6 +171,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(source_file.clone()),
data_base64: STANDARD.encode("hello from source root"),
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -164,6 +184,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
let read_request_id = mcp
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
path: absolute_path(nested_file.clone()),
sandbox_policy: None,
cwd: None,
})
.await?;
let read_response: FsReadFileResponse = to_response(
@@ -185,6 +207,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
source_path: absolute_path(nested_file.clone()),
destination_path: absolute_path(copy_file_path.clone()),
recursive: false,
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -202,6 +226,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
source_path: absolute_path(source_dir.clone()),
destination_path: absolute_path(copied_dir.clone()),
recursive: true,
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -217,6 +243,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
let read_directory_request_id = mcp
.send_fs_read_directory_request(codex_app_server_protocol::FsReadDirectoryParams {
path: absolute_path(source_dir.clone()),
sandbox_policy: None,
cwd: None,
})
.await?;
let readdir_response = timeout(
@@ -249,6 +277,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
path: absolute_path(copied_dir.clone()),
recursive: None,
force: None,
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -275,6 +305,8 @@ async fn fs_write_file_accepts_base64_bytes() -> Result<()> {
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(file_path.clone()),
data_base64: STANDARD.encode(bytes),
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -287,6 +319,8 @@ async fn fs_write_file_accepts_base64_bytes() -> Result<()> {
let read_request_id = mcp
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
path: absolute_path(file_path),
sandbox_policy: None,
cwd: None,
})
.await?;
let read_response: FsReadFileResponse = to_response(
@@ -316,6 +350,8 @@ async fn fs_write_file_rejects_invalid_base64() -> Result<()> {
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(file_path),
data_base64: "%%%".to_string(),
sandbox_policy: None,
cwd: None,
})
.await?;
let error = timeout(
@@ -335,6 +371,70 @@ async fn fs_write_file_rejects_invalid_base64() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_read_file_respects_sandbox_policy() -> Result<()> {
let codex_home = TempDir::new()?;
let allowed_dir = codex_home.path().join("allowed");
let file_path = allowed_dir.join("note.txt");
std::fs::create_dir_all(&allowed_dir)?;
std::fs::write(&file_path, "sandboxed hello")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
path: absolute_path(file_path),
sandbox_policy: Some(read_only_sandbox_policy(allowed_dir)),
cwd: None,
})
.await?;
let response: FsReadFileResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
assert_eq!(
response,
FsReadFileResponse {
data_base64: STANDARD.encode("sandboxed hello"),
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_write_file_rejects_path_outside_sandbox_policy() -> Result<()> {
let codex_home = TempDir::new()?;
let allowed_dir = codex_home.path().join("allowed");
let blocked_path = codex_home.path().join("blocked.txt");
std::fs::create_dir_all(&allowed_dir)?;
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(blocked_path.clone()),
data_base64: STANDARD.encode("nope"),
sandbox_policy: Some(read_only_sandbox_policy(allowed_dir)),
cwd: None,
})
.await?;
expect_error_message(
&mut mcp,
request_id,
format!(
"fs/write is not permitted by sandbox policy for path {}",
blocked_path.display()
)
.as_str(),
)
.await?;
assert!(!blocked_path.exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_methods_reject_relative_paths() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -471,6 +571,8 @@ async fn fs_copy_rejects_directory_without_recursive() -> Result<()> {
source_path: absolute_path(source_dir),
destination_path: absolute_path(codex_home.path().join("dest")),
recursive: false,
sandbox_policy: None,
cwd: None,
})
.await?;
let error = timeout(
@@ -498,6 +600,8 @@ async fn fs_copy_rejects_copying_directory_into_descendant() -> Result<()> {
source_path: absolute_path(source_dir.clone()),
destination_path: absolute_path(source_dir.join("nested").join("copy")),
recursive: true,
sandbox_policy: None,
cwd: None,
})
.await?;
let error = timeout(
@@ -529,6 +633,8 @@ async fn fs_copy_preserves_symlinks_in_recursive_copy() -> Result<()> {
source_path: absolute_path(source_dir),
destination_path: absolute_path(copied_dir.clone()),
recursive: true,
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -569,6 +675,8 @@ async fn fs_copy_ignores_unknown_special_files_in_recursive_copy() -> Result<()>
source_path: absolute_path(source_dir),
destination_path: absolute_path(copied_dir.clone()),
recursive: true,
sandbox_policy: None,
cwd: None,
})
.await?;
timeout(
@@ -606,6 +714,8 @@ async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> {
source_path: absolute_path(fifo_path),
destination_path: absolute_path(codex_home.path().join("copied")),
recursive: false,
sandbox_policy: None,
cwd: None,
})
.await?;
expect_error_message(

View File

@@ -11,6 +11,7 @@ use app_test_support::format_with_current_shell_display;
use app_test_support::to_response;
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::ByteRange;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::CollabAgentStatus;
@@ -1527,6 +1528,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
text_elements: Vec::new(),
}],
cwd: Some(workspace.clone()),
approvals_reviewer: Some(ApprovalsReviewer::User),
..Default::default()
})
.await?;

View File

@@ -1,7 +1,9 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use similar::TextDiff;
use tree_sitter::Parser;
use tree_sitter::Query;
use tree_sitter::QueryCursor;
@@ -12,9 +14,11 @@ use crate::ApplyPatchAction;
use crate::ApplyPatchArgs;
use crate::ApplyPatchError;
use crate::ApplyPatchFileChange;
use crate::ApplyPatchFileSystem;
use crate::ApplyPatchFileUpdate;
use crate::IoError;
use crate::MaybeApplyPatchVerified;
use crate::derive_new_contents_from_chunks_with_fs;
use crate::parser::Hunk;
use crate::parser::ParseError;
use crate::parser::parse_patch;
@@ -149,17 +153,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
hunks,
workdir,
}) => {
let effective_cwd = workdir
.as_ref()
.map(|dir| {
let path = Path::new(dir);
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
})
.unwrap_or_else(|| cwd.to_path_buf());
let effective_cwd = resolve_effective_cwd(cwd, workdir.as_deref());
let mut changes = HashMap::new();
for hunk in hunks {
let path = hunk.resolve_path(&effective_cwd);
@@ -216,6 +210,101 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
}
}
/// Async variant of [`maybe_parse_apply_patch_verified`] that reads file
/// contents through an abstract filesystem instead of directly from `std::fs`.
pub async fn maybe_parse_apply_patch_verified_with_fs(
argv: &[String],
cwd: &Path,
fs: &dyn ApplyPatchFileSystem,
) -> MaybeApplyPatchVerified {
if let [body] = argv
&& parse_patch(body).is_ok()
{
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
if let Some((_, script)) = parse_shell_script(argv)
&& parse_patch(script).is_ok()
{
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
match maybe_parse_apply_patch(argv) {
MaybeApplyPatch::Body(ApplyPatchArgs {
patch,
hunks,
workdir,
}) => {
let effective_cwd = resolve_effective_cwd(cwd, workdir.as_deref());
let mut changes = HashMap::new();
for hunk in hunks {
let path = hunk.resolve_path(&effective_cwd);
match hunk {
Hunk::AddFile { contents, .. } => {
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
}
Hunk::DeleteFile { .. } => {
let content = match fs.read_text(&path).await {
Ok(content) => content,
Err(err) => {
return MaybeApplyPatchVerified::CorrectnessError(err);
}
};
changes.insert(path, ApplyPatchFileChange::Delete { content });
}
Hunk::UpdateFile {
move_path, chunks, ..
} => {
let applied =
match derive_new_contents_from_chunks_with_fs(&path, &chunks, fs).await
{
Ok(applied) => applied,
Err(err) => {
return MaybeApplyPatchVerified::CorrectnessError(err);
}
};
let unified_diff =
TextDiff::from_lines(&applied.original_contents, &applied.new_contents)
.unified_diff()
.context_radius(1)
.to_string();
changes.insert(
path,
ApplyPatchFileChange::Update {
unified_diff,
move_path: move_path.map(|p| effective_cwd.join(p)),
new_content: applied.new_contents,
},
);
}
}
}
MaybeApplyPatchVerified::Body(ApplyPatchAction {
changes,
patch,
cwd: effective_cwd,
})
}
MaybeApplyPatch::ShellParseError(err) => MaybeApplyPatchVerified::ShellParseError(err),
MaybeApplyPatch::PatchParseError(err) => {
MaybeApplyPatchVerified::CorrectnessError(err.into())
}
MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
}
}
fn resolve_effective_cwd(cwd: &Path, workdir: Option<&str>) -> PathBuf {
workdir
.map(Path::new)
.map(|path| {
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
})
.unwrap_or_else(|| cwd.to_path_buf())
}
/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script
/// that invokes the apply_patch tool using a heredoc.
///

View File

@@ -4,8 +4,10 @@ mod seek_sequence;
mod standalone_executable;
use std::collections::HashMap;
use std::future::Future;
use std::path::Path;
use std::path::PathBuf;
use std::pin::Pin;
use anyhow::Context;
use anyhow::Result;
@@ -18,6 +20,7 @@ use similar::TextDiff;
use thiserror::Error;
pub use invocation::maybe_parse_apply_patch_verified;
pub use invocation::maybe_parse_apply_patch_verified_with_fs;
pub use standalone_executable::main;
use crate::invocation::ExtractHeredocError;
@@ -50,6 +53,15 @@ pub enum ApplyPatchError {
ImplicitInvocation,
}
impl ApplyPatchError {
pub fn io_error(context: impl Into<String>, source: std::io::Error) -> Self {
Self::IoError(IoError {
context: context.into(),
source,
})
}
}
impl From<std::io::Error> for ApplyPatchError {
fn from(err: std::io::Error) -> Self {
ApplyPatchError::IoError(IoError {
@@ -179,6 +191,33 @@ impl ApplyPatchAction {
}
}
/// Filesystem operations required to verify and apply a patch.
///
/// This keeps the patch parser/diff logic independent from the host filesystem
/// so callers can provide sandboxed or remote-backed implementations.
pub trait ApplyPatchFileSystem: Send + Sync {
fn read_text<'a>(
&'a self,
path: &'a Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<String, ApplyPatchError>> + Send + 'a>>;
fn write_text<'a>(
&'a self,
path: &'a Path,
contents: String,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>>;
fn create_dir_all<'a>(
&'a self,
path: &'a Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>>;
fn remove_file<'a>(
&'a self,
path: &'a Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>>;
}
/// Applies the patch and prints the result to stdout/stderr.
pub fn apply_patch(
patch: &str,
@@ -268,6 +307,7 @@ pub fn apply_hunks(
/// Applies each parsed patch hunk to the filesystem.
/// Returns an error if any of the changes could not be applied.
/// Tracks file paths affected by applying a patch.
#[derive(Debug, PartialEq, Eq)]
pub struct AffectedPaths {
pub added: Vec<PathBuf>,
pub modified: Vec<PathBuf>,
@@ -359,6 +399,14 @@ fn derive_new_contents_from_chunks(
}
};
derive_new_contents_from_text(&original_contents, path, chunks)
}
pub(crate) fn derive_new_contents_from_text(
original_contents: &str,
path: &Path,
chunks: &[UpdateFileChunk],
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect();
// Drop the trailing empty element that results from the final newline so
@@ -375,11 +423,88 @@ fn derive_new_contents_from_chunks(
}
let new_contents = new_lines.join("\n");
Ok(AppliedPatch {
original_contents,
original_contents: original_contents.to_string(),
new_contents,
})
}
pub(crate) async fn derive_new_contents_from_chunks_with_fs(
path: &Path,
chunks: &[UpdateFileChunk],
fs: &dyn ApplyPatchFileSystem,
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
let original_contents = match fs.read_text(path).await {
Ok(contents) => contents,
Err(ApplyPatchError::IoError(IoError { source, .. })) => {
return Err(ApplyPatchError::IoError(IoError {
context: format!("Failed to read file to update {}", path.display()),
source,
}));
}
Err(err) => return Err(err),
};
derive_new_contents_from_text(&original_contents, path, chunks)
}
/// Apply a verified patch action through an abstract filesystem.
pub async fn apply_action_with_fs(
action: &ApplyPatchAction,
fs: &dyn ApplyPatchFileSystem,
) -> std::result::Result<AffectedPaths, ApplyPatchError> {
if action.is_empty() {
return Err(ApplyPatchError::IoError(IoError {
context: "No files were modified.".to_string(),
source: std::io::Error::other("empty patch"),
}));
}
let mut added = Vec::new();
let mut modified = Vec::new();
let mut deleted = Vec::new();
for (path, change) in action.changes() {
match change {
ApplyPatchFileChange::Add { content } => {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs.create_dir_all(parent).await?;
}
fs.write_text(path, content.clone()).await?;
added.push(path.clone());
}
ApplyPatchFileChange::Delete { .. } => {
fs.remove_file(path).await?;
deleted.push(path.clone());
}
ApplyPatchFileChange::Update {
new_content,
move_path,
..
} => {
if let Some(dest) = move_path {
if let Some(parent) = dest.parent()
&& !parent.as_os_str().is_empty()
{
fs.create_dir_all(parent).await?;
}
fs.write_text(dest, new_content.clone()).await?;
fs.remove_file(path).await?;
modified.push(dest.clone());
} else {
fs.write_text(path, new_content.clone()).await?;
modified.push(path.clone());
}
}
}
}
Ok(AffectedPaths {
added,
modified,
deleted,
})
}
/// Compute a list of replacements needed to transform `original_lines` into the
/// new lines, given the patch `chunks`. Each replacement is returned as
/// `(start_index, old_len, new_lines)`.
@@ -556,9 +681,120 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use std::future::Future;
use std::pin::Pin;
use std::string::ToString;
use std::sync::Arc;
use std::task::Context;
use std::task::Poll;
use std::task::Wake;
use std::task::Waker;
use tempfile::tempdir;
#[derive(Default)]
struct NoopWake;
impl Wake for NoopWake {
fn wake(self: Arc<Self>) {}
}
fn block_on<F: Future>(future: F) -> F::Output {
let waker = Waker::from(Arc::new(NoopWake));
let mut context = Context::from_waker(&waker);
let mut future = Pin::from(Box::new(future));
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::yield_now(),
}
}
}
#[derive(Clone, Default)]
struct TestApplyPatchFileSystem {
files: std::sync::Arc<std::sync::Mutex<HashMap<PathBuf, String>>>,
removed: std::sync::Arc<std::sync::Mutex<Vec<PathBuf>>>,
directories: std::sync::Arc<std::sync::Mutex<Vec<PathBuf>>>,
}
impl TestApplyPatchFileSystem {
fn with_file(path: &Path, content: &str) -> Self {
let file_system = Self::default();
file_system
.files
.lock()
.expect("lock files")
.insert(path.to_path_buf(), content.to_string());
file_system
}
}
impl ApplyPatchFileSystem for TestApplyPatchFileSystem {
fn read_text<'a>(
&'a self,
path: &'a Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<String, ApplyPatchError>> + Send + 'a>>
{
Box::pin(async move {
self.files
.lock()
.expect("lock files")
.get(path)
.cloned()
.ok_or_else(|| {
ApplyPatchError::io_error(
format!("missing test file {}", path.display()),
std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
)
})
})
}
fn write_text<'a>(
&'a self,
path: &'a Path,
contents: String,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>>
{
Box::pin(async move {
self.files
.lock()
.expect("lock files")
.insert(path.to_path_buf(), contents);
Ok(())
})
}
fn create_dir_all<'a>(
&'a self,
path: &'a Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>>
{
Box::pin(async move {
self.directories
.lock()
.expect("lock dirs")
.push(path.to_path_buf());
Ok(())
})
}
fn remove_file<'a>(
&'a self,
path: &'a Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>>
{
Box::pin(async move {
self.files.lock().expect("lock files").remove(path);
self.removed
.lock()
.expect("lock removed")
.push(path.to_path_buf());
Ok(())
})
}
}
/// Helper to construct a patch with the given body.
fn wrap_patch(body: &str) -> String {
format!("*** Begin Patch\n{body}\n*** End Patch")
@@ -1071,4 +1307,83 @@ g
let result = apply_patch(&patch, &mut stdout, &mut stderr);
assert!(result.is_err());
}
#[test]
fn apply_action_with_fs_updates_file_without_touching_host_fs() {
let path = PathBuf::from("/virtual/update.txt");
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
-before
+after"#,
path.display()
));
let argv = vec!["apply_patch".to_string(), patch];
let fs = TestApplyPatchFileSystem::with_file(&path, "before\n");
let action = match block_on(maybe_parse_apply_patch_verified_with_fs(
&argv,
Path::new("/"),
&fs,
)) {
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected patch body, got {other:?}"),
};
let affected = block_on(apply_action_with_fs(&action, &fs)).expect("apply action");
assert_eq!(affected.modified, vec![path.clone()]);
assert_eq!(
fs.files.lock().expect("lock files").get(&path).cloned(),
Some("after\n".to_string())
);
}
#[test]
fn maybe_parse_apply_patch_verified_with_fs_uses_abstract_fs_for_update_and_delete() {
let update_path = PathBuf::from("/virtual/update.txt");
let delete_path = PathBuf::from("/virtual/delete.txt");
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
-before
+after
*** Delete File: {}"#,
update_path.display(),
delete_path.display()
));
let argv = vec!["apply_patch".to_string(), patch];
let fs = TestApplyPatchFileSystem::default();
fs.files
.lock()
.expect("lock files")
.insert(update_path.clone(), "before\n".to_string());
fs.files
.lock()
.expect("lock files")
.insert(delete_path.clone(), "gone\n".to_string());
let action = match block_on(maybe_parse_apply_patch_verified_with_fs(
&argv,
Path::new("/"),
&fs,
)) {
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected patch body, got {other:?}"),
};
assert_eq!(
action.changes().get(&delete_path),
Some(&ApplyPatchFileChange::Delete {
content: "gone\n".to_string()
})
);
assert_eq!(
action.changes().get(&update_path),
Some(&ApplyPatchFileChange::Update {
unified_diff: "@@ -1 +1 @@\n-before\n+after\n".to_string(),
move_path: None,
new_content: "after\n".to_string(),
})
);
}
}

View File

@@ -4,11 +4,23 @@ use crate::safety::SafetyCheck;
use crate::safety::assess_patch_safety;
use crate::tools::sandboxing::ExecApprovalRequirement;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchError;
use codex_apply_patch::ApplyPatchFileChange;
use codex_apply_patch::ApplyPatchFileSystem;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileSystemOperationOptions;
use codex_exec_server::RemoveOptions;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
pub(crate) enum InternalApplyPatchInvocation {
/// The `apply_patch` call was handled programmatically, without any sort
@@ -18,21 +30,166 @@ pub(crate) enum InternalApplyPatchInvocation {
/// The `apply_patch` call was approved, either automatically because it
/// appears that it should be allowed based on the user's sandbox policy
/// *or* because the user explicitly approved it. In either case, we use
/// exec with [`codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1`] to realize
/// the `apply_patch` call,
/// but [`ApplyPatchExec::auto_approved`] is used to determine the sandbox
/// used with the `exec()`.
DelegateToExec(ApplyPatchExec),
/// or because the user explicitly approved it. The tool runtime realizes
/// the verified patch through the environment filesystem.
DelegateToRuntime(ApprovedApplyPatch),
}
#[derive(Debug)]
pub(crate) struct ApplyPatchExec {
pub(crate) struct ApprovedApplyPatch {
pub(crate) action: ApplyPatchAction,
pub(crate) auto_approved: bool,
pub(crate) exec_approval_requirement: ExecApprovalRequirement,
}
pub(crate) struct EnvironmentApplyPatchFileSystem {
file_system: Arc<dyn ExecutorFileSystem>,
operation_options: FileSystemOperationOptions,
}
impl EnvironmentApplyPatchFileSystem {
pub(crate) fn for_verification(file_system: Arc<dyn ExecutorFileSystem>, cwd: PathBuf) -> Self {
Self {
file_system,
operation_options: FileSystemOperationOptions {
cwd: absolute_path(cwd.as_path()).ok(),
..FileSystemOperationOptions::default()
},
}
}
pub(crate) fn for_apply(
file_system: Arc<dyn ExecutorFileSystem>,
cwd: PathBuf,
sandbox_policy: SandboxPolicy,
) -> Self {
Self {
file_system,
operation_options: FileSystemOperationOptions {
sandbox_policy: Some(sandbox_policy),
cwd: absolute_path(cwd.as_path()).ok(),
},
}
}
}
impl ApplyPatchFileSystem for EnvironmentApplyPatchFileSystem {
fn read_text<'a>(
&'a self,
path: &'a std::path::Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<String, ApplyPatchError>> + Send + 'a>>
{
Box::pin(async move {
let path = absolute_path(path)?;
let bytes = self
.file_system
.read_file_with_options(&path, &self.operation_options)
.await
.map_err(|source| {
ApplyPatchError::io_error(format!("Failed to read {}", path.display()), source)
})?;
String::from_utf8(bytes).map_err(|source| {
ApplyPatchError::io_error(
format!("Failed to decode UTF-8 for {}", path.display()),
io::Error::new(io::ErrorKind::InvalidData, source.to_string()),
)
})
})
}
fn write_text<'a>(
&'a self,
path: &'a std::path::Path,
contents: String,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>> {
Box::pin(async move {
let path = absolute_path(path)?;
let contents = contents.into_bytes();
self.file_system
.write_file_with_options(&path, contents, &self.operation_options)
.await
.map_err(|source| {
ApplyPatchError::io_error(
format!("Failed to write file {}", path.display()),
source,
)
})
})
}
fn create_dir_all<'a>(
&'a self,
path: &'a std::path::Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>> {
Box::pin(async move {
let path = absolute_path(path)?;
self.file_system
.create_directory_with_options(
&path,
CreateDirectoryOptions { recursive: true },
&self.operation_options,
)
.await
.map_err(|source| {
ApplyPatchError::io_error(
format!("Failed to create parent directories for {}", path.display()),
source,
)
})
})
}
fn remove_file<'a>(
&'a self,
path: &'a std::path::Path,
) -> Pin<Box<dyn Future<Output = std::result::Result<(), ApplyPatchError>> + Send + 'a>> {
Box::pin(async move {
let path = absolute_path(path)?;
let remove_options = RemoveOptions {
recursive: false,
force: false,
};
self.file_system
.remove_with_options(&path, remove_options, &self.operation_options)
.await
.map_err(|source| {
ApplyPatchError::io_error(
format!("Failed to delete file {}", path.display()),
source,
)
})
})
}
}
fn absolute_path(path: &std::path::Path) -> std::result::Result<AbsolutePathBuf, ApplyPatchError> {
let path = AbsolutePathBuf::from_absolute_path(path).map_err(|error| {
ApplyPatchError::io_error(
format!("Expected absolute path for apply_patch: {}", path.display()),
io::Error::new(io::ErrorKind::InvalidInput, error.to_string()),
)
})?;
Ok(normalize_existing_ancestor_path(path))
}
fn normalize_existing_ancestor_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
let raw_path = path.to_path_buf();
for ancestor in raw_path.ancestors() {
let Ok(canonical_ancestor) = ancestor.canonicalize() else {
continue;
};
let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
continue;
};
if let Ok(normalized_path) =
AbsolutePathBuf::from_absolute_path(canonical_ancestor.join(suffix))
{
return normalized_path;
}
}
path
}
pub(crate) async fn apply_patch(
turn_context: &TurnContext,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
@@ -49,7 +206,7 @@ pub(crate) async fn apply_patch(
SafetyCheck::AutoApprove {
user_explicitly_approved,
..
} => InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
} => InternalApplyPatchInvocation::DelegateToRuntime(ApprovedApplyPatch {
action,
auto_approved: !user_explicitly_approved,
exec_approval_requirement: ExecApprovalRequirement::Skip {
@@ -61,7 +218,7 @@ pub(crate) async fn apply_patch(
// Delegate the approval prompt (including cached approvals) to the
// tool runtime, consistent with how shell/unified_exec approvals
// are orchestrator-driven.
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
InternalApplyPatchInvocation::DelegateToRuntime(ApprovedApplyPatch {
action,
auto_approved: false,
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {

View File

@@ -1,7 +1,17 @@
use super::*;
use async_trait::async_trait;
use codex_exec_server::CopyOptions;
use codex_exec_server::FileMetadata;
use codex_exec_server::FileSystemOperationOptions;
use codex_exec_server::ReadDirectoryEntry;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use tempfile::tempdir;
use tokio::io;
#[test]
fn convert_apply_patch_maps_add_variant() {
@@ -19,3 +29,226 @@ fn convert_apply_patch_maps_add_variant() {
})
);
}
#[cfg(unix)]
#[test]
fn absolute_path_normalizes_existing_symlink_ancestor() {
use std::os::unix::fs::symlink;
let tmp = tempdir().expect("tempdir");
let real_root = tmp.path().join("real");
let link_root = tmp.path().join("link");
std::fs::create_dir_all(&real_root).expect("create real root");
symlink(&real_root, &link_root).expect("create symlink");
let path = link_root.join("nested").join("file.txt");
let got = absolute_path(path.as_path()).expect("normalize absolute path");
let expected = AbsolutePathBuf::from_absolute_path(
real_root
.canonicalize()
.expect("canonicalize real root")
.join("nested/file.txt"),
)
.expect("expected normalized path");
assert_eq!(got, expected);
}
#[derive(Default)]
struct RecordingExecutorFileSystem {
raw_reads: Mutex<Vec<PathBuf>>,
option_reads: Mutex<Vec<FileSystemOperationOptions>>,
raw_writes: Mutex<Vec<PathBuf>>,
option_writes: Mutex<Vec<FileSystemOperationOptions>>,
raw_creates: Mutex<Vec<PathBuf>>,
option_creates: Mutex<Vec<FileSystemOperationOptions>>,
raw_removes: Mutex<Vec<PathBuf>>,
option_removes: Mutex<Vec<FileSystemOperationOptions>>,
}
#[async_trait]
impl ExecutorFileSystem for RecordingExecutorFileSystem {
async fn read_file(&self, path: &AbsolutePathBuf) -> io::Result<Vec<u8>> {
self.raw_reads
.lock()
.expect("raw_reads lock")
.push(path.as_path().to_path_buf());
Ok(b"before\n".to_vec())
}
async fn read_file_with_options(
&self,
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> io::Result<Vec<u8>> {
self.option_reads
.lock()
.expect("option_reads lock")
.push(options.clone());
self.read_file(path).await
}
async fn write_file(&self, path: &AbsolutePathBuf, _contents: Vec<u8>) -> io::Result<()> {
self.raw_writes
.lock()
.expect("raw_writes lock")
.push(path.as_path().to_path_buf());
Ok(())
}
async fn write_file_with_options(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
options: &FileSystemOperationOptions,
) -> io::Result<()> {
self.option_writes
.lock()
.expect("option_writes lock")
.push(options.clone());
self.write_file(path, contents).await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
_options: CreateDirectoryOptions,
) -> io::Result<()> {
self.raw_creates
.lock()
.expect("raw_creates lock")
.push(path.as_path().to_path_buf());
Ok(())
}
async fn create_directory_with_options(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
fs_options: &FileSystemOperationOptions,
) -> io::Result<()> {
self.option_creates
.lock()
.expect("option_creates lock")
.push(fs_options.clone());
self.create_directory(path, options).await
}
async fn get_metadata(&self, _path: &AbsolutePathBuf) -> io::Result<FileMetadata> {
Err(io::Error::other("unused"))
}
async fn read_directory(&self, _path: &AbsolutePathBuf) -> io::Result<Vec<ReadDirectoryEntry>> {
Err(io::Error::other("unused"))
}
async fn remove(&self, path: &AbsolutePathBuf, _options: RemoveOptions) -> io::Result<()> {
self.raw_removes
.lock()
.expect("raw_removes lock")
.push(path.as_path().to_path_buf());
Ok(())
}
async fn remove_with_options(
&self,
path: &AbsolutePathBuf,
options: RemoveOptions,
fs_options: &FileSystemOperationOptions,
) -> io::Result<()> {
self.option_removes
.lock()
.expect("option_removes lock")
.push(fs_options.clone());
self.remove(path, options).await
}
async fn copy(
&self,
_source_path: &AbsolutePathBuf,
_destination_path: &AbsolutePathBuf,
_options: CopyOptions,
) -> io::Result<()> {
Err(io::Error::other("unused"))
}
}
#[tokio::test]
async fn verification_filesystem_uses_default_operation_options() {
let file_system = Arc::new(RecordingExecutorFileSystem::default());
let tmp = tempdir().expect("tmp");
let cwd = tmp.path().join("apply-patch-verification");
let path = tmp.path().join("apply-patch-verification.txt");
let adapter =
EnvironmentApplyPatchFileSystem::for_verification(file_system.clone(), cwd.clone());
let content = adapter
.read_text(path.as_path())
.await
.expect("read through adapter");
assert_eq!(content, "before\n");
assert_eq!(
file_system
.option_reads
.lock()
.expect("option_reads lock")
.as_slice(),
[FileSystemOperationOptions {
sandbox_policy: None,
cwd: Some(absolute_path(cwd.as_path()).expect("normalized cwd")),
}]
);
assert_eq!(
file_system
.raw_reads
.lock()
.expect("raw_reads lock")
.as_slice(),
[absolute_path(path.as_path())
.expect("normalized path")
.into_path_buf()]
);
}
#[tokio::test]
async fn apply_filesystem_uses_sandbox_options() {
let file_system = Arc::new(RecordingExecutorFileSystem::default());
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
let tmp = tempdir().expect("tmp");
let cwd = tmp.path().join("apply-patch-sandboxed");
let path = cwd.join("new.txt");
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
let adapter = EnvironmentApplyPatchFileSystem::for_apply(
file_system.clone(),
cwd.clone(),
sandbox_policy.clone(),
);
codex_apply_patch::apply_action_with_fs(&action, &adapter)
.await
.expect("apply patch through adapter");
assert_eq!(
file_system
.option_creates
.lock()
.expect("option_creates lock")
.as_slice(),
[FileSystemOperationOptions {
sandbox_policy: Some(sandbox_policy.clone()),
cwd: Some(absolute_path(cwd.as_path()).expect("normalized cwd")),
}]
);
assert_eq!(
file_system
.option_writes
.lock()
.expect("option_writes lock")
.as_slice(),
[FileSystemOperationOptions {
sandbox_policy: Some(sandbox_policy),
cwd: Some(absolute_path(cwd.as_path()).expect("normalized cwd")),
}]
);
}

View File

@@ -1,6 +1,7 @@
use std::path::Path;
use crate::apply_patch;
use crate::apply_patch::EnvironmentApplyPatchFileSystem;
use crate::apply_patch::InternalApplyPatchInvocation;
use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::codex::Session;
@@ -25,6 +26,7 @@ use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::policy_transforms::EffectiveSandboxPermissions;
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
@@ -167,7 +169,7 @@ impl ToolHandler for ApplyPatchHandler {
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let command = vec!["apply_patch".to_string(), patch_input.clone()];
match codex_apply_patch::maybe_parse_apply_patch_verified(&command, &cwd) {
match parse_apply_patch_verified(turn.as_ref(), &command, &cwd).await {
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await;
@@ -178,7 +180,7 @@ impl ToolHandler for ApplyPatchHandler {
let content = item?;
Ok(ApplyPatchToolOutput::from_text(content))
}
InternalApplyPatchInvocation::DelegateToExec(apply) => {
InternalApplyPatchInvocation::DelegateToRuntime(apply) => {
let changes = convert_apply_patch_to_protocol(&apply.action);
let emitter =
ToolEmitter::apply_patch(changes.clone(), apply.auto_approved);
@@ -194,12 +196,16 @@ impl ToolHandler for ApplyPatchHandler {
action: apply.action,
file_paths,
changes,
sandbox_policy: EffectiveSandboxPermissions::new(
turn.sandbox_policy.get(),
effective_additional_permissions
.additional_permissions
.as_ref(),
)
.sandbox_policy,
exec_approval_requirement: apply.exec_approval_requirement,
additional_permissions: effective_additional_permissions
.additional_permissions,
permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
timeout_ms: None,
};
let mut orchestrator = ToolOrchestrator::new();
@@ -255,14 +261,14 @@ impl ToolHandler for ApplyPatchHandler {
pub(crate) async fn intercept_apply_patch(
command: &[String],
cwd: &Path,
timeout_ms: Option<u64>,
_timeout_ms: Option<u64>,
session: Arc<Session>,
turn: Arc<TurnContext>,
tracker: Option<&SharedTurnDiffTracker>,
call_id: &str,
tool_name: &str,
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd) {
match parse_apply_patch_verified(turn.as_ref(), command, cwd).await {
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
session
.record_model_warning(
@@ -281,7 +287,7 @@ pub(crate) async fn intercept_apply_patch(
let content = item?;
Ok(Some(FunctionToolOutput::from_text(content, Some(true))))
}
InternalApplyPatchInvocation::DelegateToExec(apply) => {
InternalApplyPatchInvocation::DelegateToRuntime(apply) => {
let changes = convert_apply_patch_to_protocol(&apply.action);
let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved);
let event_ctx = ToolEventCtx::new(
@@ -296,12 +302,16 @@ pub(crate) async fn intercept_apply_patch(
action: apply.action,
file_paths: approval_keys,
changes,
sandbox_policy: EffectiveSandboxPermissions::new(
turn.sandbox_policy.get(),
effective_additional_permissions
.additional_permissions
.as_ref(),
)
.sandbox_policy,
exec_approval_requirement: apply.exec_approval_requirement,
additional_permissions: effective_additional_permissions
.additional_permissions,
permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
timeout_ms,
};
let mut orchestrator = ToolOrchestrator::new();
@@ -346,6 +356,18 @@ pub(crate) async fn intercept_apply_patch(
}
}
async fn parse_apply_patch_verified(
turn: &TurnContext,
command: &[String],
cwd: &Path,
) -> codex_apply_patch::MaybeApplyPatchVerified {
let fs = EnvironmentApplyPatchFileSystem::for_verification(
turn.environment.get_filesystem(),
turn.cwd.to_path_buf(),
);
codex_apply_patch::maybe_parse_apply_patch_verified_with_fs(command, cwd, &fs).await
}
#[cfg(test)]
#[path = "apply_patch_tests.rs"]
mod tests;

View File

@@ -1,3 +1,4 @@
use codex_exec_server::FileSystemOperationOptions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
@@ -8,6 +9,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_image::PromptImageMode;
use codex_utils_image::load_for_prompt_bytes;
use serde::Deserialize;
use std::path::Path;
use crate::function_tool::FunctionCallError;
use crate::original_image_detail::can_request_original_image_detail;
@@ -91,11 +93,13 @@ impl ToolHandler for ViewImageHandler {
AbsolutePathBuf::try_from(turn.resolve_path(Some(args.path))).map_err(|error| {
FunctionCallError::RespondToModel(format!("unable to resolve image path: {error}"))
})?;
let file_system_options =
view_image_file_system_options(turn.cwd.as_path(), turn.sandbox_policy.get().clone());
let metadata = turn
.environment
.get_filesystem()
.get_metadata(&abs_path)
.get_metadata_with_options(&abs_path, &file_system_options)
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
@@ -113,7 +117,7 @@ impl ToolHandler for ViewImageHandler {
let file_bytes = turn
.environment
.get_filesystem()
.read_file(&abs_path)
.read_file_with_options(&abs_path, &file_system_options)
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
@@ -160,6 +164,16 @@ impl ToolHandler for ViewImageHandler {
}
}
fn view_image_file_system_options(
cwd: &Path,
sandbox_policy: codex_protocol::protocol::SandboxPolicy,
) -> FileSystemOperationOptions {
FileSystemOperationOptions {
sandbox_policy: Some(sandbox_policy),
cwd: AbsolutePathBuf::from_absolute_path(cwd).ok(),
}
}
pub struct ViewImageOutput {
image_url: String,
image_detail: Option<ImageDetail>,
@@ -204,6 +218,7 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn code_mode_result_returns_image_url_object() {
@@ -224,4 +239,18 @@ mod tests {
})
);
}
#[test]
fn view_image_file_system_options_include_sandbox_policy_and_cwd() {
let temp_dir = tempdir().expect("temp dir");
let sandbox_policy = codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy();
let options = view_image_file_system_options(temp_dir.path(), sandbox_policy.clone());
assert_eq!(options.sandbox_policy, Some(sandbox_policy));
assert_eq!(
options.cwd,
AbsolutePathBuf::from_absolute_path(temp_dir.path()).ok()
);
}
}

View File

@@ -1,15 +1,12 @@
//! Apply Patch runtime: executes verified patches under the orchestrator.
//!
//! Assumes `apply_patch` verification/approval happened upstream. Reuses that
//! decision to avoid re-prompting, builds the self-invocation command for
//! `codex --codex-run-as-apply-patch`, and runs under the current
//! `SandboxAttempt` with a minimal environment.
use crate::exec::ExecCapturePolicy;
//! decision to avoid re-prompting, then applies the verified action directly
//! through the turn environment's filesystem with the effective sandbox policy.
use crate::apply_patch::EnvironmentApplyPatchFileSystem;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::execute_env;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
@@ -20,28 +17,30 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::PermissionProfile;
use codex_protocol::exec_output::StreamOutput;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxCommand;
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxablePreference;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug)]
pub struct ApplyPatchRequest {
pub action: ApplyPatchAction,
pub file_paths: Vec<AbsolutePathBuf>,
pub changes: std::collections::HashMap<PathBuf, FileChange>,
pub sandbox_policy: SandboxPolicy,
pub exec_approval_requirement: ExecApprovalRequirement,
pub additional_permissions: Option<PermissionProfile>,
pub permissions_preapproved: bool,
pub timeout_ms: Option<u64>,
}
#[derive(Default)]
@@ -64,59 +63,75 @@ impl ApplyPatchRuntime {
}
}
#[cfg(target_os = "windows")]
fn build_sandbox_command(
async fn run_with_environment_fs(
req: &ApplyPatchRequest,
codex_home: &std::path::Path,
) -> Result<SandboxCommand, ToolError> {
Ok(Self::build_sandbox_command_with_program(
req,
codex_windows_sandbox::resolve_current_exe_for_launch(codex_home, "codex.exe"),
))
}
#[cfg(not(target_os = "windows"))]
fn build_sandbox_command(
req: &ApplyPatchRequest,
codex_self_exe: Option<&PathBuf>,
) -> Result<SandboxCommand, ToolError> {
let exe = Self::resolve_apply_patch_program(codex_self_exe)?;
Ok(Self::build_sandbox_command_with_program(req, exe))
}
#[cfg(not(target_os = "windows"))]
fn resolve_apply_patch_program(codex_self_exe: Option<&PathBuf>) -> Result<PathBuf, ToolError> {
if let Some(path) = codex_self_exe {
return Ok(path.clone());
fs: EnvironmentApplyPatchFileSystem,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let affected: codex_apply_patch::AffectedPaths =
codex_apply_patch::apply_action_with_fs(&req.action, &fs)
.await
.map_err(|err| ToolError::Rejected(err.to_string()))?;
let affected = relativize_affected_paths(&affected, &req.action.cwd);
let mut stdout = Vec::new();
codex_apply_patch::print_summary(&affected, &mut stdout)
.map_err(|err| ToolError::Rejected(err.to_string()))?;
let stdout = String::from_utf8(stdout).map_err(|err| {
ToolError::Rejected(format!("apply_patch wrote non-UTF-8 output: {err}"))
})?;
if !stdout.is_empty() {
ctx.session
.send_event(
ctx.turn.as_ref(),
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
call_id: ctx.call_id.clone(),
stream: ExecOutputStream::Stdout,
chunk: stdout.clone().into_bytes(),
}),
)
.await;
}
std::env::current_exe()
.map_err(|e| ToolError::Rejected(format!("failed to determine codex exe: {e}")))
}
fn build_sandbox_command_with_program(req: &ApplyPatchRequest, exe: PathBuf) -> SandboxCommand {
SandboxCommand {
program: exe.into_os_string(),
args: vec![
CODEX_CORE_APPLY_PATCH_ARG1.to_string(),
req.action.patch.clone(),
],
cwd: req.action.cwd.clone(),
// Run apply_patch with a minimal environment for determinism and to avoid leaks.
env: HashMap::new(),
additional_permissions: req.additional_permissions.clone(),
}
}
fn stdout_stream(ctx: &ToolCtx) -> Option<crate::exec::StdoutStream> {
Some(crate::exec::StdoutStream {
sub_id: ctx.turn.sub_id.clone(),
call_id: ctx.call_id.clone(),
tx_event: ctx.session.get_tx_event(),
Ok(ExecToolCallOutput {
exit_code: 0,
stdout: StreamOutput::new(stdout.clone()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new(stdout),
duration: Duration::ZERO,
timed_out: false,
})
}
}
fn relativize_affected_paths(
affected: &codex_apply_patch::AffectedPaths,
cwd: &Path,
) -> codex_apply_patch::AffectedPaths {
codex_apply_patch::AffectedPaths {
added: affected
.added
.iter()
.map(|path| summary_path(path, cwd))
.collect(),
modified: affected
.modified
.iter()
.map(|path| summary_path(path, cwd))
.collect(),
deleted: affected
.deleted
.iter()
.map(|path| summary_path(path, cwd))
.collect(),
}
}
fn summary_path(path: &Path, cwd: &Path) -> PathBuf {
match path.strip_prefix(cwd) {
Ok(relative) if !relative.as_os_str().is_empty() => relative.to_path_buf(),
_ => path.to_path_buf(),
}
}
impl Sandboxable for ApplyPatchRuntime {
fn sandbox_preference(&self) -> SandboxablePreference {
SandboxablePreference::Auto
@@ -208,24 +223,15 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
async fn run(
&mut self,
req: &ApplyPatchRequest,
attempt: &SandboxAttempt<'_>,
_attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
#[cfg(target_os = "windows")]
let command = Self::build_sandbox_command(req, &ctx.turn.config.codex_home)?;
#[cfg(not(target_os = "windows"))]
let command = Self::build_sandbox_command(req, ctx.turn.codex_self_exe.as_ref())?;
let options = ExecOptions {
expiration: req.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
};
let env = attempt
.env_for(command, options, /*network*/ None)
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(env, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?;
Ok(out)
let fs = EnvironmentApplyPatchFileSystem::for_apply(
ctx.turn.environment.get_filesystem(),
req.action.cwd.clone(),
req.sandbox_policy.clone(),
);
Self::run_with_environment_fs(req, fs, ctx).await
}
}

View File

@@ -1,8 +1,9 @@
use super::*;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
#[cfg(not(target_os = "windows"))]
use std::path::Path;
use std::path::PathBuf;
#[test]
@@ -46,13 +47,12 @@ fn guardian_review_request_includes_patch_context() {
content: "hello".to_string(),
},
)]),
sandbox_policy: SandboxPolicy::DangerFullAccess,
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
},
additional_permissions: None,
permissions_preapproved: false,
timeout_ms: None,
};
let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1");
@@ -68,70 +68,23 @@ fn guardian_review_request_includes_patch_context() {
);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn build_sandbox_command_prefers_configured_codex_self_exe_for_apply_patch() {
let path = std::env::temp_dir().join("apply-patch-current-exe-test.txt");
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
let request = ApplyPatchRequest {
action,
file_paths: vec![
AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"),
],
changes: HashMap::from([(
path,
FileChange::Add {
content: "hello".to_string(),
},
)]),
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
},
additional_permissions: None,
permissions_preapproved: false,
timeout_ms: None,
};
let codex_self_exe = PathBuf::from("/tmp/codex");
let command = ApplyPatchRuntime::build_sandbox_command(&request, Some(&codex_self_exe))
.expect("build sandbox command");
assert_eq!(command.program, codex_self_exe.into_os_string());
}
#[cfg(not(target_os = "windows"))]
#[test]
fn build_sandbox_command_falls_back_to_current_exe_for_apply_patch() {
let path = std::env::temp_dir().join("apply-patch-current-exe-test.txt");
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
let request = ApplyPatchRequest {
action,
file_paths: vec![
AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"),
],
changes: HashMap::from([(
path,
FileChange::Add {
content: "hello".to_string(),
},
)]),
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
},
additional_permissions: None,
permissions_preapproved: false,
timeout_ms: None,
fn summary_paths_are_relative_to_cwd_when_possible() {
let cwd = Path::new("/workspace");
let affected = codex_apply_patch::AffectedPaths {
added: vec![PathBuf::from("/workspace/nested/new.txt")],
modified: vec![PathBuf::from("/workspace/existing.txt")],
deleted: vec![PathBuf::from("/outside/delete.txt")],
};
let command = ApplyPatchRuntime::build_sandbox_command(&request, /*codex_self_exe*/ None)
.expect("build sandbox command");
let got = relativize_affected_paths(&affected, cwd);
assert_eq!(
command.program,
std::env::current_exe()
.expect("current exe")
.into_os_string()
got,
codex_apply_patch::AffectedPaths {
added: vec![PathBuf::from("nested/new.txt")],
modified: vec![PathBuf::from("existing.txt")],
deleted: vec![PathBuf::from("/outside/delete.txt")],
}
);
}

View File

@@ -33,6 +33,7 @@ use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_exec_server::ExecApprovalRequest as RemoteExecApprovalRequest;
use codex_network_proxy::NetworkProxy;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -62,6 +63,7 @@ pub struct UnifiedExecRequest {
pub additional_permissions_preapproved: bool,
pub justification: Option<String>,
pub exec_approval_requirement: ExecApprovalRequirement,
pub remote_startup_exec_approval: Option<RemoteExecApprovalRequest>,
}
/// Cache key for approval decisions that can be reused across equivalent
@@ -173,7 +175,17 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
&self,
req: &UnifiedExecRequest,
) -> Option<ExecApprovalRequirement> {
Some(req.exec_approval_requirement.clone())
if req.remote_startup_exec_approval.is_some() {
Some(ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: req
.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
})
} else {
Some(req.exec_approval_requirement.clone())
}
}
fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride {
@@ -250,6 +262,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
req.process_id,
&prepared.exec_request,
req.tty,
/*startup_exec_approval*/ None,
prepared.spawn_lifecycle,
ctx.turn.environment.as_ref(),
)
@@ -286,6 +299,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
req.process_id,
&exec_env,
req.tty,
req.remote_startup_exec_approval.clone(),
Box::new(NoopSpawnLifecycle),
ctx.turn.environment.as_ref(),
)

View File

@@ -97,6 +97,7 @@ async fn exec_command_with_tty(
process_id,
&request,
tty,
/*startup_exec_approval*/ None,
Box::new(NoopSpawnLifecycle),
turn.environment.as_ref(),
)
@@ -512,6 +513,7 @@ async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> {
/*process_id*/ 1234,
&request,
/*tty*/ false,
/*startup_exec_approval*/ None,
Box::new(NoopSpawnLifecycle),
&environment,
)
@@ -554,6 +556,7 @@ async fn unified_exec_uses_remote_exec_server_when_configured() -> anyhow::Resul
/*process_id*/ 1234,
&request,
/*tty*/ true,
/*startup_exec_approval*/ None,
Box::new(NoopSpawnLifecycle),
remote_test_env.environment(),
)
@@ -608,6 +611,7 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()
/*process_id*/ 1234,
&request,
/*tty*/ true,
/*startup_exec_approval*/ None,
Box::new(TestSpawnLifecycle {
inherited_fds: vec![42],
}),

View File

@@ -13,6 +13,7 @@ use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::exec::is_likely_sandbox_denied;
use codex_exec_server::ExecApprovalRequest;
use codex_exec_server::ExecProcess;
use codex_exec_server::ReadResponse as ExecReadResponse;
use codex_exec_server::StartedExecProcess;
@@ -224,6 +225,32 @@ impl UnifiedExecProcess {
self.state_rx.borrow().failure_message.clone()
}
pub(super) async fn take_pending_exec_approval(&self) -> Option<ExecApprovalRequest> {
let state = self.state_rx.borrow().clone();
let pending_exec_approval = state.pending_exec_approval.clone();
let _ = self
.state_tx
.send_replace(state.clear_pending_exec_approval());
pending_exec_approval
}
pub(super) async fn resolve_exec_approval(
&self,
approval_id: String,
decision: codex_app_server_protocol::CommandExecutionApprovalDecision,
) -> Result<(), UnifiedExecError> {
match &self.process_handle {
ProcessHandle::Local(_) => Err(UnifiedExecError::create_process(
"local unified exec process cannot resolve remote exec approvals".to_string(),
)),
ProcessHandle::Remote(process_handle) => process_handle
.resolve_exec_approval(approval_id, decision)
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))
.map(|_| ()),
}
}
pub(super) async fn check_for_sandbox_denial(&self) -> Result<(), UnifiedExecError> {
let _ =
tokio::time::timeout(Duration::from_millis(20), self.output_notify.notified()).await;
@@ -349,7 +376,10 @@ impl UnifiedExecProcess {
if tokio::time::timeout(EARLY_EXIT_GRACE_PERIOD, async {
loop {
let state = state_rx.borrow().clone();
if state.has_exited || state.failure_message.is_some() {
if state.has_exited
|| state.failure_message.is_some()
|| state.pending_exec_approval.is_some()
{
break;
}
if state_rx.changed().await.is_err() {
@@ -396,6 +426,7 @@ impl UnifiedExecProcess {
exit_code,
closed,
failure,
exec_approval,
} = response;
for chunk in chunks {
@@ -416,6 +447,13 @@ impl UnifiedExecProcess {
break;
}
if let Some(exec_approval) = exec_approval {
let state = state_tx.borrow().clone();
let _ = state_tx
.send_replace(state.with_pending_exec_approval(exec_approval));
output_notify.notify_waiters();
}
if exited {
let state = state_tx.borrow().clone();
let _ = state_tx.send_replace(state.exited(exit_code));

View File

@@ -2,6 +2,7 @@ use rand::Rng;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -12,6 +13,7 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::sandboxing::ExecRequest;
@@ -22,9 +24,11 @@ use crate::tools::events::ToolEventStage;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey;
use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest;
use crate::tools::runtimes::unified_exec::UnifiedExecRuntime;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::ExecCommandRequest;
use crate::unified_exec::MAX_UNIFIED_EXEC_PROCESSES;
use crate::unified_exec::MAX_YIELD_TIME_MS;
@@ -48,7 +52,9 @@ use crate::unified_exec::process::OutputBuffer;
use crate::unified_exec::process::OutputHandles;
use crate::unified_exec::process::SpawnLifecycleHandle;
use crate::unified_exec::process::UnifiedExecProcess;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ReviewDecision;
use codex_utils_output_truncation::approx_token_count;
const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [
@@ -107,6 +113,34 @@ fn exec_server_process_id(process_id: i32) -> String {
process_id.to_string()
}
fn startup_exec_approval_request(
context: &UnifiedExecContext,
request: &ExecCommandRequest,
cwd: &Path,
exec_approval_requirement: &crate::tools::sandboxing::ExecApprovalRequirement,
) -> Option<codex_exec_server::ExecApprovalRequest> {
match exec_approval_requirement {
crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval {
reason,
proposed_execpolicy_amendment,
} => Some(codex_exec_server::ExecApprovalRequest {
call_id: context.call_id.clone(),
approval_id: None,
turn_id: context.turn.sub_id.clone(),
command: request.command.clone(),
cwd: cwd.to_path_buf(),
reason: reason.clone().or_else(|| request.justification.clone()),
additional_permissions: request.additional_permissions.clone().map(Into::into),
proposed_execpolicy_amendment: proposed_execpolicy_amendment
.clone()
.map(codex_app_server_protocol::ExecPolicyAmendment::from),
available_decisions: None,
}),
crate::tools::sandboxing::ExecApprovalRequirement::Skip { .. }
| crate::tools::sandboxing::ExecApprovalRequirement::Forbidden { .. } => None,
}
}
impl UnifiedExecProcessManager {
pub(crate) async fn allocate_process_id(&self) -> i32 {
loop {
@@ -230,7 +264,7 @@ impl UnifiedExecProcessManager {
cancellation_token,
} = process.output_handles();
let deadline = start + Duration::from_millis(yield_time_ms);
let collected = Self::collect_output_until_deadline(
let mut collected = Self::collect_output_until_deadline(
&output_buffer,
&output_notify,
&output_closed,
@@ -244,6 +278,71 @@ impl UnifiedExecProcessManager {
deadline,
)
.await;
if let Some(exec_approval) = process.take_pending_exec_approval().await {
let call_id = exec_approval.call_id.clone();
let approval_id = exec_approval.approval_id.clone();
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
let approval_keys = vec![UnifiedExecApprovalKey {
command: canonicalize_command_for_approval(&request.command),
cwd: cwd.clone(),
tty: request.tty,
sandbox_permissions: request.sandbox_permissions,
additional_permissions: request.additional_permissions.clone(),
}];
let decision = with_cached_approval(
&context.session.services,
"unified_exec",
approval_keys,
|| async move {
context
.session
.request_command_approval(
&context.turn,
call_id,
approval_id,
exec_approval.command,
exec_approval.cwd,
exec_approval.reason,
/*network_approval_context*/ None,
exec_approval
.proposed_execpolicy_amendment
.map(codex_app_server_protocol::ExecPolicyAmendment::into_core),
exec_approval.additional_permissions.map(Into::into),
exec_approval.available_decisions.map(|decisions| {
decisions
.into_iter()
.map(command_execution_approval_decision_to_core)
.collect()
}),
)
.await
},
)
.await;
process
.resolve_exec_approval(
effective_approval_id,
command_execution_approval_decision_from_core(decision),
)
.await?;
let remaining_deadline = deadline.max(Instant::now() + Duration::from_millis(1));
let additional_output = Self::collect_output_until_deadline(
&output_buffer,
&output_notify,
&output_closed,
&output_closed_notify,
&cancellation_token,
Some(
context
.session
.subscribe_out_of_band_elicitation_pause_state(),
),
remaining_deadline,
)
.await;
collected.extend(additional_output);
}
let wall_time = Instant::now().saturating_duration_since(start);
let text = String::from_utf8_lossy(&collected).to_string();
@@ -584,6 +683,7 @@ impl UnifiedExecProcessManager {
process_id: i32,
env: &ExecRequest,
tty: bool,
startup_exec_approval: Option<codex_exec_server::ExecApprovalRequest>,
mut spawn_lifecycle: SpawnLifecycleHandle,
environment: &codex_exec_server::Environment,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
@@ -609,6 +709,7 @@ impl UnifiedExecProcessManager {
env: env.env.clone(),
tty,
arg0: env.arg0.clone(),
startup_exec_approval,
})
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
@@ -675,6 +776,11 @@ impl UnifiedExecProcessManager {
prefix_rule: request.prefix_rule.clone(),
})
.await;
let remote_startup_exec_approval = if context.turn.environment.exec_server_url().is_some() {
startup_exec_approval_request(context, request, &cwd, &exec_approval_requirement)
} else {
None
};
let req = UnifiedExecToolRequest {
command: request.command.clone(),
process_id: request.process_id,
@@ -689,6 +795,7 @@ impl UnifiedExecProcessManager {
additional_permissions_preapproved: request.additional_permissions_preapproved,
justification: request.justification.clone(),
exec_approval_requirement,
remote_startup_exec_approval,
};
let tool_ctx = ToolCtx {
session: context.session.clone(),
@@ -913,6 +1020,48 @@ enum ProcessStatus {
Unknown,
}
fn command_execution_approval_decision_to_core(
decision: CommandExecutionApprovalDecision,
) -> ReviewDecision {
match decision {
CommandExecutionApprovalDecision::Accept => ReviewDecision::Approved,
CommandExecutionApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment,
} => ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: execpolicy_amendment.into_core(),
},
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment,
} => ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.into_core(),
},
CommandExecutionApprovalDecision::Decline => ReviewDecision::Denied,
CommandExecutionApprovalDecision::Cancel => ReviewDecision::Abort,
}
}
fn command_execution_approval_decision_from_core(
decision: ReviewDecision,
) -> CommandExecutionApprovalDecision {
match decision {
ReviewDecision::Approved => CommandExecutionApprovalDecision::Accept,
ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
} => CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment: proposed_execpolicy_amendment.into(),
},
ReviewDecision::ApprovedForSession => CommandExecutionApprovalDecision::AcceptForSession,
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.into(),
},
ReviewDecision::Denied => CommandExecutionApprovalDecision::Decline,
ReviewDecision::Abort => CommandExecutionApprovalDecision::Cancel,
}
}
#[cfg(test)]
#[path = "process_manager_tests.rs"]
mod tests;

View File

@@ -1,8 +1,11 @@
#[derive(Clone, Debug, Default, Eq, PartialEq)]
use codex_exec_server::ExecApprovalRequest;
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ProcessState {
pub(crate) has_exited: bool,
pub(crate) exit_code: Option<i32>,
pub(crate) failure_message: Option<String>,
pub(crate) pending_exec_approval: Option<ExecApprovalRequest>,
}
impl ProcessState {
@@ -11,6 +14,7 @@ impl ProcessState {
has_exited: true,
exit_code,
failure_message: self.failure_message.clone(),
pending_exec_approval: self.pending_exec_approval.clone(),
}
}
@@ -19,6 +23,28 @@ impl ProcessState {
has_exited: true,
exit_code: self.exit_code,
failure_message: Some(message),
pending_exec_approval: self.pending_exec_approval.clone(),
}
}
pub(crate) fn with_pending_exec_approval(
&self,
pending_exec_approval: ExecApprovalRequest,
) -> Self {
Self {
has_exited: self.has_exited,
exit_code: self.exit_code,
failure_message: self.failure_message.clone(),
pending_exec_approval: Some(pending_exec_approval),
}
}
pub(crate) fn clear_pending_exec_approval(&self) -> Self {
Self {
has_exited: self.has_exited,
exit_code: self.exit_code,
failure_message: self.failure_message.clone(),
pending_exec_approval: None,
}
}
}

View File

@@ -1,10 +1,13 @@
use super::process::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecError;
use async_trait::async_trait;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_exec_server::ExecApprovalRequest;
use codex_exec_server::ExecProcess;
use codex_exec_server::ExecServerError;
use codex_exec_server::ProcessId;
use codex_exec_server::ReadResponse;
use codex_exec_server::ResolveExecApprovalResponse;
use codex_exec_server::StartedExecProcess;
use codex_exec_server::WriteResponse;
use codex_exec_server::WriteStatus;
@@ -51,6 +54,7 @@ impl ExecProcess for MockExecProcess {
exit_code: None,
closed: false,
failure: None,
exec_approval: None,
}))
}
@@ -61,6 +65,14 @@ impl ExecProcess for MockExecProcess {
async fn terminate(&self) -> Result<(), ExecServerError> {
Ok(())
}
async fn resolve_exec_approval(
&self,
_approval_id: String,
_decision: CommandExecutionApprovalDecision,
) -> Result<ResolveExecApprovalResponse, ExecServerError> {
Ok(ResolveExecApprovalResponse { accepted: true })
}
}
async fn remote_process(write_status: WriteStatus) -> UnifiedExecProcess {
@@ -123,6 +135,7 @@ async fn remote_process_waits_for_early_exit_event() {
exit_code: Some(17),
closed: true,
failure: None,
exec_approval: None,
}])),
wake_tx: wake_tx.clone(),
}),
@@ -140,3 +153,48 @@ async fn remote_process_waits_for_early_exit_event() {
assert!(process.has_exited());
assert_eq!(process.exit_code(), Some(17));
}
#[tokio::test]
async fn remote_process_surfaces_pending_exec_approval() {
let approval = ExecApprovalRequest {
call_id: "call-1".to_string(),
approval_id: Some("approval-1".to_string()),
turn_id: "turn-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: "/tmp".into(),
reason: Some("approval required".to_string()),
additional_permissions: None,
proposed_execpolicy_amendment: None,
available_decisions: Some(vec![CommandExecutionApprovalDecision::Accept]),
};
let (wake_tx, _wake_rx) = watch::channel(0);
let started = StartedExecProcess {
process: Arc::new(MockExecProcess {
process_id: "test-process".to_string().into(),
write_response: WriteResponse {
status: WriteStatus::Accepted,
},
read_responses: Mutex::new(VecDeque::from([ReadResponse {
chunks: Vec::new(),
next_seq: 2,
exited: false,
exit_code: None,
closed: false,
failure: None,
exec_approval: Some(approval.clone()),
}])),
wake_tx: wake_tx.clone(),
}),
};
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
let _ = wake_tx.send(1);
});
let process = UnifiedExecProcess::from_remote_started(started, SandboxType::None)
.await
.expect("remote process should start");
assert_eq!(process.take_pending_exec_approval().await, Some(approval));
}

View File

@@ -14,6 +14,7 @@ use codex_features::Feature;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
@@ -589,7 +590,7 @@ async fn submit_turn(
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy,
approvals_reviewer: None,
approvals_reviewer: Some(ApprovalsReviewer::User),
sandbox_policy,
model: session_model,
effort: None,

View File

@@ -9,9 +9,11 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::assert_regex_match;
use core_test_support::get_remote_test_env;
use core_test_support::process::process_is_alive;
use core_test_support::process::wait_for_pid_file;
use core_test_support::process::wait_for_process_exit;
@@ -352,6 +354,120 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_unified_exec_smoke_requests_approval_before_running_command() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_windows!(Ok(()));
if get_remote_test_env().is_none() {
return Ok(());
}
let builder = test_codex().with_model("gpt-5").with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
});
let server = start_mock_server().await;
let mut builder = builder;
let test = builder.build_remote_aware(&server).await?;
let call_id = "uexec-remote-approval-smoke";
let smoke_target = "/tmp/nonexistent";
let smoke_command = format!("rm -rf {smoke_target}");
let args = json!({
"cmd": smoke_command,
"yield_time_ms": 250,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "approved"),
ev_completed("resp-2"),
]),
];
mount_sse_sequence(&server, responses).await;
let codex = test.codex.clone();
let cwd = test.cwd_path().to_path_buf();
let session_model = test.session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "run remote unified exec approval smoke".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd,
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
let approval_or_completion = wait_for_event(&codex, |msg| {
matches!(
msg,
EventMsg::ExecApprovalRequest(approval) if approval.call_id == call_id
) || matches!(msg, EventMsg::TurnComplete(_))
})
.await;
let EventMsg::ExecApprovalRequest(approval) = approval_or_completion else {
let bodies: Vec<Value> = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter_map(|req| serde_json::from_slice::<Value>(&req.body).ok())
.collect();
panic!("remote smoke completed without approval event; bodies={bodies:?}");
};
codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let bodies: Vec<Value> = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter_map(|req| serde_json::from_slice::<Value>(&req.body).ok())
.collect();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs
.get(call_id)
.expect("missing function call output for remote smoke");
assert!(
output.process_id.is_some() || output.exit_code.is_some(),
"expected approved remote unified exec metadata, got: {output:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_resolves_relative_workdir() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -20,6 +20,7 @@ async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
futures = { workspace = true }
@@ -42,6 +43,7 @@ tracing = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -35,6 +35,7 @@ use crate::protocol::EXEC_EXITED_METHOD;
use crate::protocol::EXEC_METHOD;
use crate::protocol::EXEC_OUTPUT_DELTA_METHOD;
use crate::protocol::EXEC_READ_METHOD;
use crate::protocol::EXEC_RESOLVE_APPROVAL_METHOD;
use crate::protocol::EXEC_TERMINATE_METHOD;
use crate::protocol::EXEC_WRITE_METHOD;
use crate::protocol::ExecClosedNotification;
@@ -55,6 +56,8 @@ use crate::protocol::InitializeParams;
use crate::protocol::InitializeResponse;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
use crate::protocol::ResolveExecApprovalParams;
use crate::protocol::ResolveExecApprovalResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::protocol::WriteParams;
@@ -257,6 +260,26 @@ impl ExecServerClient {
.map_err(Into::into)
}
pub async fn resolve_exec_approval(
&self,
process_id: &ProcessId,
approval_id: String,
decision: codex_app_server_protocol::CommandExecutionApprovalDecision,
) -> Result<ResolveExecApprovalResponse, ExecServerError> {
self.inner
.client
.call(
EXEC_RESOLVE_APPROVAL_METHOD,
&ResolveExecApprovalParams {
process_id: process_id.clone(),
approval_id,
decision,
},
)
.await
.map_err(Into::into)
}
pub async fn fs_read_file(
&self,
params: FsReadFileParams,
@@ -464,6 +487,7 @@ impl SessionState {
exit_code: None,
closed: true,
failure: Some(message),
exec_approval: None,
}
}
}
@@ -516,6 +540,16 @@ impl Session {
Ok(())
}
pub(crate) async fn resolve_exec_approval(
&self,
approval_id: String,
decision: codex_app_server_protocol::CommandExecutionApprovalDecision,
) -> Result<ResolveExecApprovalResponse, ExecServerError> {
self.client
.resolve_exec_approval(&self.process_id, approval_id, decision)
.await
}
pub(crate) async fn unregister(&self) {
self.client.unregister_session(&self.process_id).await;
}

View File

@@ -202,6 +202,7 @@ mod tests {
env: Default::default(),
tty: false,
arg0: None,
startup_exec_approval: None,
})
.await
.expect("start process");

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use tokio::io;
@@ -18,6 +19,12 @@ pub struct CopyOptions {
pub recursive: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct FileSystemOperationOptions {
pub sandbox_policy: Option<SandboxPolicy>,
pub cwd: Option<AbsolutePathBuf>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FileMetadata {
pub is_directory: bool,
@@ -39,27 +46,88 @@ pub type FileSystemResult<T> = io::Result<T>;
pub trait ExecutorFileSystem: Send + Sync {
async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult<Vec<u8>>;
async fn read_file_with_options(
&self,
path: &AbsolutePathBuf,
_options: &FileSystemOperationOptions,
) -> FileSystemResult<Vec<u8>> {
self.read_file(path).await
}
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()>;
async fn write_file_with_options(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
_options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
self.write_file(path, contents).await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
) -> FileSystemResult<()>;
async fn create_directory_with_options(
&self,
path: &AbsolutePathBuf,
create_directory_options: CreateDirectoryOptions,
_options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
self.create_directory(path, create_directory_options).await
}
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata>;
async fn get_metadata_with_options(
&self,
path: &AbsolutePathBuf,
_options: &FileSystemOperationOptions,
) -> FileSystemResult<FileMetadata> {
self.get_metadata(path).await
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
) -> FileSystemResult<Vec<ReadDirectoryEntry>>;
async fn read_directory_with_options(
&self,
path: &AbsolutePathBuf,
_options: &FileSystemOperationOptions,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
self.read_directory(path).await
}
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()>;
async fn remove_with_options(
&self,
path: &AbsolutePathBuf,
remove_options: RemoveOptions,
_options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
self.remove(path, remove_options).await
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
) -> FileSystemResult<()>;
async fn copy_with_options(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
copy_options: CopyOptions,
_options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
self.copy(source_path, destination_path, copy_options).await
}
}

View File

@@ -39,6 +39,7 @@ pub use file_system::CopyOptions;
pub use file_system::CreateDirectoryOptions;
pub use file_system::ExecutorFileSystem;
pub use file_system::FileMetadata;
pub use file_system::FileSystemOperationOptions;
pub use file_system::FileSystemResult;
pub use file_system::ReadDirectoryEntry;
pub use file_system::RemoveOptions;
@@ -46,6 +47,7 @@ pub use process::ExecBackend;
pub use process::ExecProcess;
pub use process::StartedExecProcess;
pub use process_id::ProcessId;
pub use protocol::ExecApprovalRequest;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
@@ -56,6 +58,8 @@ pub use protocol::InitializeParams;
pub use protocol::InitializeResponse;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::ResolveExecApprovalParams;
pub use protocol::ResolveExecApprovalResponse;
pub use protocol::TerminateParams;
pub use protocol::TerminateResponse;
pub use protocol::WriteParams;

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Component;
use std::path::Path;
@@ -11,6 +12,7 @@ use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::ExecutorFileSystem;
use crate::FileMetadata;
use crate::FileSystemOperationOptions;
use crate::FileSystemResult;
use crate::ReadDirectoryEntry;
use crate::RemoveOptions;
@@ -33,10 +35,29 @@ impl ExecutorFileSystem for LocalFileSystem {
tokio::fs::read(path.as_path()).await
}
async fn read_file_with_options(
&self,
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> FileSystemResult<Vec<u8>> {
enforce_read_access(path, options)?;
self.read_file(path).await
}
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()> {
tokio::fs::write(path.as_path(), contents).await
}
async fn write_file_with_options(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
enforce_write_access(path, options)?;
self.write_file(path, contents).await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
@@ -50,6 +71,16 @@ impl ExecutorFileSystem for LocalFileSystem {
Ok(())
}
async fn create_directory_with_options(
&self,
path: &AbsolutePathBuf,
create_directory_options: CreateDirectoryOptions,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
enforce_write_access(path, options)?;
self.create_directory(path, create_directory_options).await
}
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata> {
let metadata = tokio::fs::metadata(path.as_path()).await?;
Ok(FileMetadata {
@@ -60,6 +91,15 @@ impl ExecutorFileSystem for LocalFileSystem {
})
}
async fn get_metadata_with_options(
&self,
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> FileSystemResult<FileMetadata> {
enforce_read_access(path, options)?;
self.get_metadata(path).await
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
@@ -77,6 +117,15 @@ impl ExecutorFileSystem for LocalFileSystem {
Ok(entries)
}
async fn read_directory_with_options(
&self,
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
enforce_read_access(path, options)?;
self.read_directory(path).await
}
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> {
match tokio::fs::symlink_metadata(path.as_path()).await {
Ok(metadata) => {
@@ -97,6 +146,16 @@ impl ExecutorFileSystem for LocalFileSystem {
}
}
async fn remove_with_options(
&self,
path: &AbsolutePathBuf,
remove_options: RemoveOptions,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
enforce_write_access(path, options)?;
self.remove(path, remove_options).await
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
@@ -147,6 +206,70 @@ impl ExecutorFileSystem for LocalFileSystem {
.await
.map_err(|err| io::Error::other(format!("filesystem task failed: {err}")))?
}
async fn copy_with_options(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
copy_options: CopyOptions,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
enforce_read_access(source_path, options)?;
enforce_write_access(destination_path, options)?;
self.copy(source_path, destination_path, copy_options).await
}
}
fn enforce_read_access(
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
enforce_access(
path,
options,
FileSystemSandboxPolicy::can_read_path_with_cwd,
"read",
)
}
fn enforce_write_access(
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
enforce_access(
path,
options,
FileSystemSandboxPolicy::can_write_path_with_cwd,
"write",
)
}
fn enforce_access(
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
access_kind: &str,
) -> FileSystemResult<()> {
let Some(sandbox_policy) = &options.sandbox_policy else {
return Ok(());
};
let cwd = match &options.cwd {
Some(cwd) => cwd.clone().into_path_buf(),
None => std::env::current_dir()
.map_err(|err| io::Error::other(format!("failed to read current dir: {err}")))?,
};
let file_system_policy = FileSystemSandboxPolicy::from(sandbox_policy);
if is_allowed(&file_system_policy, path.as_path(), cwd.as_path()) {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"fs/{access_kind} is not permitted by sandbox policy for path {}",
path.as_path().display()
),
))
}
}
fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {

View File

@@ -8,6 +8,7 @@ use std::time::Duration;
use async_trait::async_trait;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::SpawnedPty;
use codex_utils_pty::TerminalSize;
use tokio::sync::Mutex;
use tokio::sync::Notify;
@@ -20,6 +21,7 @@ use crate::ExecServerError;
use crate::ProcessId;
use crate::StartedExecProcess;
use crate::protocol::EXEC_CLOSED_METHOD;
use crate::protocol::ExecApprovalRequest;
use crate::protocol::ExecClosedNotification;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
@@ -30,6 +32,8 @@ use crate::protocol::InitializeResponse;
use crate::protocol::ProcessOutputChunk;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
use crate::protocol::ResolveExecApprovalParams;
use crate::protocol::ResolveExecApprovalResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::protocol::WriteParams;
@@ -56,7 +60,7 @@ struct RetainedOutputChunk {
}
struct RunningProcess {
session: ExecCommandSession,
session: Option<ExecCommandSession>,
tty: bool,
output: VecDeque<RetainedOutputChunk>,
retained_bytes: usize,
@@ -66,6 +70,9 @@ struct RunningProcess {
output_notify: Arc<Notify>,
open_streams: usize,
closed: bool,
failure: Option<String>,
pending_exec_approval: Option<ExecApprovalRequest>,
pending_start: Option<ExecParams>,
}
enum ProcessEntry {
@@ -124,7 +131,9 @@ impl LocalProcess {
.collect::<Vec<_>>()
};
for process in remaining {
process.session.terminate();
if let Some(session) = process.session.as_ref() {
session.terminate();
}
}
}
@@ -162,16 +171,41 @@ impl LocalProcess {
Ok(())
}
async fn spawn_process_session(params: &ExecParams) -> Result<SpawnedPty, JSONRPCErrorError> {
let (program, args) = params
.argv
.split_first()
.ok_or_else(|| invalid_params("argv must not be empty".to_string()))?;
if params.tty {
codex_utils_pty::spawn_pty_process(
program,
args,
params.cwd.as_path(),
&params.env,
&params.arg0,
TerminalSize::default(),
)
.await
.map_err(|err| internal_error(err.to_string()))
} else {
codex_utils_pty::spawn_pipe_process_no_stdin(
program,
args,
params.cwd.as_path(),
&params.env,
&params.arg0,
)
.await
.map_err(|err| internal_error(err.to_string()))
}
}
async fn start_process(
&self,
params: ExecParams,
) -> Result<(ExecResponse, watch::Sender<u64>), JSONRPCErrorError> {
self.require_initialized_for("exec")?;
let process_id = params.process_id.clone();
let (program, args) = params
.argv
.split_first()
.ok_or_else(|| invalid_params("argv must not be empty".to_string()))?;
{
let mut process_map = self.inner.processes.lock().await;
@@ -183,45 +217,14 @@ impl LocalProcess {
process_map.insert(process_id.clone(), ProcessEntry::Starting);
}
let spawned_result = if params.tty {
codex_utils_pty::spawn_pty_process(
program,
args,
params.cwd.as_path(),
&params.env,
&params.arg0,
TerminalSize::default(),
)
.await
} else {
codex_utils_pty::spawn_pipe_process_no_stdin(
program,
args,
params.cwd.as_path(),
&params.env,
&params.arg0,
)
.await
};
let spawned = match spawned_result {
Ok(spawned) => spawned,
Err(err) => {
let mut process_map = self.inner.processes.lock().await;
if matches!(process_map.get(&process_id), Some(ProcessEntry::Starting)) {
process_map.remove(&process_id);
}
return Err(internal_error(err.to_string()));
}
};
let output_notify = Arc::new(Notify::new());
let (wake_tx, _wake_rx) = watch::channel(0);
{
if let Some(startup_exec_approval) = params.startup_exec_approval.clone() {
let mut process_map = self.inner.processes.lock().await;
process_map.insert(
process_id.clone(),
ProcessEntry::Running(Box::new(RunningProcess {
session: spawned.session,
session: None,
tty: params.tty,
output: VecDeque::new(),
retained_bytes: 0,
@@ -229,40 +232,76 @@ impl LocalProcess {
exit_code: None,
wake_tx: wake_tx.clone(),
output_notify: Arc::clone(&output_notify),
open_streams: 2,
open_streams: 0,
closed: false,
failure: None,
pending_exec_approval: Some(startup_exec_approval),
pending_start: Some(params),
})),
);
}
} else {
let spawned = match Self::spawn_process_session(&params).await {
Ok(spawned) => spawned,
Err(err) => {
let mut process_map = self.inner.processes.lock().await;
if matches!(process_map.get(&process_id), Some(ProcessEntry::Starting)) {
process_map.remove(&process_id);
}
return Err(err);
}
};
tokio::spawn(stream_output(
process_id.clone(),
if params.tty {
ExecOutputStream::Pty
} else {
ExecOutputStream::Stdout
},
spawned.stdout_rx,
Arc::clone(&self.inner),
Arc::clone(&output_notify),
));
tokio::spawn(stream_output(
process_id.clone(),
if params.tty {
ExecOutputStream::Pty
} else {
ExecOutputStream::Stderr
},
spawned.stderr_rx,
Arc::clone(&self.inner),
Arc::clone(&output_notify),
));
tokio::spawn(watch_exit(
process_id.clone(),
spawned.exit_rx,
Arc::clone(&self.inner),
output_notify,
));
{
let mut process_map = self.inner.processes.lock().await;
process_map.insert(
process_id.clone(),
ProcessEntry::Running(Box::new(RunningProcess {
session: Some(spawned.session),
tty: params.tty,
output: VecDeque::new(),
retained_bytes: 0,
next_seq: 1,
exit_code: None,
wake_tx: wake_tx.clone(),
output_notify: Arc::clone(&output_notify),
open_streams: 2,
closed: false,
failure: None,
pending_exec_approval: None,
pending_start: None,
})),
);
}
tokio::spawn(stream_output(
process_id.clone(),
if params.tty {
ExecOutputStream::Pty
} else {
ExecOutputStream::Stdout
},
spawned.stdout_rx,
Arc::clone(&self.inner),
Arc::clone(&output_notify),
));
tokio::spawn(stream_output(
process_id.clone(),
if params.tty {
ExecOutputStream::Pty
} else {
ExecOutputStream::Stderr
},
spawned.stderr_rx,
Arc::clone(&self.inner),
Arc::clone(&output_notify),
));
tokio::spawn(watch_exit(
process_id.clone(),
spawned.exit_rx,
Arc::clone(&self.inner),
output_notify,
));
}
Ok((ExecResponse { process_id }, wake_tx))
}
@@ -324,14 +363,18 @@ impl LocalProcess {
exited: process.exit_code.is_some(),
exit_code: process.exit_code,
closed: process.closed,
failure: None,
failure: process.failure.clone(),
exec_approval: process.pending_exec_approval.clone(),
},
Arc::clone(&process.output_notify),
)
};
if !response.chunks.is_empty()
|| response.exec_approval.is_some()
|| response.failure.is_some()
|| response.exited
|| response.closed
|| tokio::time::Instant::now() >= deadline
{
let _total_bytes: usize = response
@@ -374,7 +417,12 @@ impl LocalProcess {
status: WriteStatus::StdinClosed,
});
}
process.session.writer_sender()
let Some(session) = process.session.as_ref() else {
return Ok(WriteResponse {
status: WriteStatus::Starting,
});
};
session.writer_sender()
};
writer_tx
@@ -387,6 +435,151 @@ impl LocalProcess {
})
}
pub(crate) async fn resolve_exec_approval(
&self,
params: ResolveExecApprovalParams,
) -> Result<ResolveExecApprovalResponse, JSONRPCErrorError> {
self.require_initialized_for("exec")?;
let allows_spawn = matches!(
params.decision,
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept
| codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession
| codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { .. }
| codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { .. }
);
let pending_start = {
let mut process_map = self.inner.processes.lock().await;
let Some(process) = process_map.get_mut(&params.process_id) else {
return Err(invalid_request(format!(
"unknown process id {}",
params.process_id
)));
};
let ProcessEntry::Running(process) = process else {
return Err(invalid_request(format!(
"process id {} is starting",
params.process_id
)));
};
let Some(pending) = process.pending_exec_approval.as_ref() else {
return Err(invalid_request(format!(
"process id {} has no pending exec approval",
params.process_id
)));
};
let effective_approval_id = pending
.approval_id
.as_deref()
.unwrap_or(pending.call_id.as_str());
if effective_approval_id != params.approval_id {
return Err(invalid_request(format!(
"process id {} has no pending approval {}",
params.process_id, params.approval_id
)));
}
process.pending_exec_approval = None;
if allows_spawn {
process.pending_start.take()
} else {
process.failure = Some("rejected by user".to_string());
process.exit_code = Some(1);
process.closed = true;
process.pending_start = None;
process.output_notify.notify_waiters();
let _ = process.wake_tx.send(process.next_seq);
None
}
};
if let Some(pending_start) = pending_start {
let output_notify = {
let process_map = self.inner.processes.lock().await;
let Some(ProcessEntry::Running(process)) = process_map.get(&params.process_id)
else {
return Err(invalid_request(format!(
"process id {} disappeared while resolving approval",
params.process_id
)));
};
Arc::clone(&process.output_notify)
};
match Self::spawn_process_session(&pending_start).await {
Ok(spawned) => {
{
let mut process_map = self.inner.processes.lock().await;
let Some(ProcessEntry::Running(process)) =
process_map.get_mut(&params.process_id)
else {
return Err(invalid_request(format!(
"process id {} disappeared while spawning",
params.process_id
)));
};
process.session = Some(spawned.session);
process.open_streams = 2;
}
let process_id = params.process_id.clone();
let inner = Arc::clone(&self.inner);
tokio::spawn(stream_output(
process_id.clone(),
if pending_start.tty {
ExecOutputStream::Pty
} else {
ExecOutputStream::Stdout
},
spawned.stdout_rx,
Arc::clone(&inner),
Arc::clone(&output_notify),
));
tokio::spawn(stream_output(
process_id.clone(),
if pending_start.tty {
ExecOutputStream::Pty
} else {
ExecOutputStream::Stderr
},
spawned.stderr_rx,
Arc::clone(&inner),
Arc::clone(&output_notify),
));
tokio::spawn(watch_exit(
process_id,
spawned.exit_rx,
inner,
Arc::clone(&output_notify),
));
let process_map = self.inner.processes.lock().await;
let Some(ProcessEntry::Running(process)) = process_map.get(&params.process_id)
else {
return Err(invalid_request(format!(
"process id {} disappeared after spawning",
params.process_id
)));
};
process.output_notify.notify_waiters();
let _ = process.wake_tx.send(process.next_seq);
}
Err(err) => {
let mut process_map = self.inner.processes.lock().await;
let Some(ProcessEntry::Running(process)) =
process_map.get_mut(&params.process_id)
else {
return Err(invalid_request(format!(
"process id {} disappeared after failed spawn",
params.process_id
)));
};
process.failure = Some(err.message.clone());
process.closed = true;
process.output_notify.notify_waiters();
let _ = process.wake_tx.send(process.next_seq);
}
}
}
Ok(ResolveExecApprovalResponse { accepted: true })
}
pub(crate) async fn terminate_process(
&self,
params: TerminateParams,
@@ -400,7 +593,9 @@ impl LocalProcess {
if process.exit_code.is_some() {
return Ok(TerminateResponse { running: false });
}
process.session.terminate();
if let Some(session) = process.session.as_ref() {
session.terminate();
}
true
}
Some(ProcessEntry::Starting) | None => false,
@@ -456,6 +651,21 @@ impl ExecProcess for LocalExecProcess {
async fn terminate(&self) -> Result<(), ExecServerError> {
self.backend.terminate(&self.process_id).await
}
async fn resolve_exec_approval(
&self,
approval_id: String,
decision: codex_app_server_protocol::CommandExecutionApprovalDecision,
) -> Result<ResolveExecApprovalResponse, ExecServerError> {
self.backend
.resolve_exec_approval(ResolveExecApprovalParams {
process_id: self.process_id.clone(),
approval_id,
decision,
})
.await
.map_err(map_handler_error)
}
}
impl LocalProcess {

View File

@@ -7,6 +7,7 @@ use crate::ExecServerError;
use crate::ProcessId;
use crate::protocol::ExecParams;
use crate::protocol::ReadResponse;
use crate::protocol::ResolveExecApprovalResponse;
use crate::protocol::WriteResponse;
pub struct StartedExecProcess {
@@ -29,6 +30,12 @@ pub trait ExecProcess: Send + Sync {
async fn write(&self, chunk: Vec<u8>) -> Result<WriteResponse, ExecServerError>;
async fn terminate(&self) -> Result<(), ExecServerError>;
async fn resolve_exec_approval(
&self,
approval_id: String,
decision: codex_app_server_protocol::CommandExecutionApprovalDecision,
) -> Result<ResolveExecApprovalResponse, ExecServerError>;
}
#[async_trait]

View File

@@ -10,6 +10,7 @@ use crate::ProcessId;
pub const INITIALIZE_METHOD: &str = "initialize";
pub const INITIALIZED_METHOD: &str = "initialized";
pub const EXEC_METHOD: &str = "process/start";
pub const EXEC_RESOLVE_APPROVAL_METHOD: &str = "process/resolveApproval";
pub const EXEC_READ_METHOD: &str = "process/read";
pub const EXEC_WRITE_METHOD: &str = "process/write";
pub const EXEC_TERMINATE_METHOD: &str = "process/terminate";
@@ -50,7 +51,7 @@ pub struct InitializeParams {
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecParams {
/// Client-chosen logical process handle scoped to this connection/session.
@@ -61,6 +62,7 @@ pub struct ExecParams {
pub env: HashMap<String, String>,
pub tty: bool,
pub arg0: Option<String>,
pub startup_exec_approval: Option<ExecApprovalRequest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -69,6 +71,21 @@ pub struct ExecResponse {
pub process_id: ProcessId,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecApprovalRequest {
pub call_id: String,
pub approval_id: Option<String>,
pub turn_id: String,
pub command: Vec<String>,
pub cwd: PathBuf,
pub reason: Option<String>,
pub additional_permissions: Option<codex_app_server_protocol::AdditionalPermissionProfile>,
pub proposed_execpolicy_amendment: Option<codex_app_server_protocol::ExecPolicyAmendment>,
pub available_decisions:
Option<Vec<codex_app_server_protocol::CommandExecutionApprovalDecision>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadParams {
@@ -86,7 +103,7 @@ pub struct ProcessOutputChunk {
pub chunk: ByteChunk,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadResponse {
pub chunks: Vec<ProcessOutputChunk>,
@@ -95,6 +112,7 @@ pub struct ReadResponse {
pub exit_code: Option<i32>,
pub closed: bool,
pub failure: Option<String>,
pub exec_approval: Option<ExecApprovalRequest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -131,6 +149,20 @@ pub struct TerminateResponse {
pub running: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveExecApprovalParams {
pub process_id: ProcessId,
pub approval_id: String,
pub decision: codex_app_server_protocol::CommandExecutionApprovalDecision,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveExecApprovalResponse {
pub accepted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExecOutputStream {

View File

@@ -18,6 +18,7 @@ use crate::ExecServerClient;
use crate::ExecServerError;
use crate::ExecutorFileSystem;
use crate::FileMetadata;
use crate::FileSystemOperationOptions;
use crate::FileSystemResult;
use crate::ReadDirectoryEntry;
use crate::RemoveOptions;
@@ -42,7 +43,37 @@ impl ExecutorFileSystem for RemoteFileSystem {
trace!("remote fs read_file");
let response = self
.client
.fs_read_file(FsReadFileParams { path: path.clone() })
.fs_read_file(FsReadFileParams {
path: path.clone(),
sandbox_policy: None,
cwd: None,
})
.await
.map_err(map_remote_error)?;
STANDARD.decode(response.data_base64).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("remote fs/readFile returned invalid base64 dataBase64: {err}"),
)
})
}
async fn read_file_with_options(
&self,
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> FileSystemResult<Vec<u8>> {
trace!("remote fs read_file_with_options");
let response = self
.client
.fs_read_file(FsReadFileParams {
path: path.clone(),
sandbox_policy: options
.sandbox_policy
.clone()
.map(codex_app_server_protocol::SandboxPolicy::from),
cwd: options.cwd.clone(),
})
.await
.map_err(map_remote_error)?;
STANDARD.decode(response.data_base64).map_err(|err| {
@@ -59,6 +90,30 @@ impl ExecutorFileSystem for RemoteFileSystem {
.fs_write_file(FsWriteFileParams {
path: path.clone(),
data_base64: STANDARD.encode(contents),
sandbox_policy: None,
cwd: None,
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn write_file_with_options(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
trace!("remote fs write_file_with_options");
self.client
.fs_write_file(FsWriteFileParams {
path: path.clone(),
data_base64: STANDARD.encode(contents),
sandbox_policy: options
.sandbox_policy
.clone()
.map(codex_app_server_protocol::SandboxPolicy::from),
cwd: options.cwd.clone(),
})
.await
.map_err(map_remote_error)?;
@@ -75,6 +130,30 @@ impl ExecutorFileSystem for RemoteFileSystem {
.fs_create_directory(FsCreateDirectoryParams {
path: path.clone(),
recursive: Some(options.recursive),
sandbox_policy: None,
cwd: None,
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn create_directory_with_options(
&self,
path: &AbsolutePathBuf,
create_directory_options: CreateDirectoryOptions,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
trace!("remote fs create_directory_with_options");
self.client
.fs_create_directory(FsCreateDirectoryParams {
path: path.clone(),
recursive: Some(create_directory_options.recursive),
sandbox_policy: options
.sandbox_policy
.clone()
.map(codex_app_server_protocol::SandboxPolicy::from),
cwd: options.cwd.clone(),
})
.await
.map_err(map_remote_error)?;
@@ -85,7 +164,37 @@ impl ExecutorFileSystem for RemoteFileSystem {
trace!("remote fs get_metadata");
let response = self
.client
.fs_get_metadata(FsGetMetadataParams { path: path.clone() })
.fs_get_metadata(FsGetMetadataParams {
path: path.clone(),
sandbox_policy: None,
cwd: None,
})
.await
.map_err(map_remote_error)?;
Ok(FileMetadata {
is_directory: response.is_directory,
is_file: response.is_file,
created_at_ms: response.created_at_ms,
modified_at_ms: response.modified_at_ms,
})
}
async fn get_metadata_with_options(
&self,
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> FileSystemResult<FileMetadata> {
trace!("remote fs get_metadata_with_options");
let response = self
.client
.fs_get_metadata(FsGetMetadataParams {
path: path.clone(),
sandbox_policy: options
.sandbox_policy
.clone()
.map(codex_app_server_protocol::SandboxPolicy::from),
cwd: options.cwd.clone(),
})
.await
.map_err(map_remote_error)?;
Ok(FileMetadata {
@@ -103,7 +212,40 @@ impl ExecutorFileSystem for RemoteFileSystem {
trace!("remote fs read_directory");
let response = self
.client
.fs_read_directory(FsReadDirectoryParams { path: path.clone() })
.fs_read_directory(FsReadDirectoryParams {
path: path.clone(),
sandbox_policy: None,
cwd: None,
})
.await
.map_err(map_remote_error)?;
Ok(response
.entries
.into_iter()
.map(|entry| ReadDirectoryEntry {
file_name: entry.file_name,
is_directory: entry.is_directory,
is_file: entry.is_file,
})
.collect())
}
async fn read_directory_with_options(
&self,
path: &AbsolutePathBuf,
options: &FileSystemOperationOptions,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
trace!("remote fs read_directory_with_options");
let response = self
.client
.fs_read_directory(FsReadDirectoryParams {
path: path.clone(),
sandbox_policy: options
.sandbox_policy
.clone()
.map(codex_app_server_protocol::SandboxPolicy::from),
cwd: options.cwd.clone(),
})
.await
.map_err(map_remote_error)?;
Ok(response
@@ -124,6 +266,31 @@ impl ExecutorFileSystem for RemoteFileSystem {
path: path.clone(),
recursive: Some(options.recursive),
force: Some(options.force),
sandbox_policy: None,
cwd: None,
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn remove_with_options(
&self,
path: &AbsolutePathBuf,
remove_options: RemoveOptions,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
trace!("remote fs remove_with_options");
self.client
.fs_remove(FsRemoveParams {
path: path.clone(),
recursive: Some(remove_options.recursive),
force: Some(remove_options.force),
sandbox_policy: options
.sandbox_policy
.clone()
.map(codex_app_server_protocol::SandboxPolicy::from),
cwd: options.cwd.clone(),
})
.await
.map_err(map_remote_error)?;
@@ -142,6 +309,32 @@ impl ExecutorFileSystem for RemoteFileSystem {
source_path: source_path.clone(),
destination_path: destination_path.clone(),
recursive: options.recursive,
sandbox_policy: None,
cwd: None,
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn copy_with_options(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
copy_options: CopyOptions,
options: &FileSystemOperationOptions,
) -> FileSystemResult<()> {
trace!("remote fs copy_with_options");
self.client
.fs_copy(FsCopyParams {
source_path: source_path.clone(),
destination_path: destination_path.clone(),
recursive: copy_options.recursive,
sandbox_policy: options
.sandbox_policy
.clone()
.map(codex_app_server_protocol::SandboxPolicy::from),
cwd: options.cwd.clone(),
})
.await
.map_err(map_remote_error)?;

View File

@@ -12,6 +12,7 @@ use crate::client::ExecServerClient;
use crate::client::Session;
use crate::protocol::ExecParams;
use crate::protocol::ReadResponse;
use crate::protocol::ResolveExecApprovalResponse;
use crate::protocol::WriteResponse;
#[derive(Clone)]
@@ -74,6 +75,17 @@ impl ExecProcess for RemoteExecProcess {
trace!("exec process terminate");
self.session.terminate().await
}
async fn resolve_exec_approval(
&self,
approval_id: String,
decision: codex_app_server_protocol::CommandExecutionApprovalDecision,
) -> Result<ResolveExecApprovalResponse, ExecServerError> {
trace!("exec process resolve approval");
self.session
.resolve_exec_approval(approval_id, decision)
.await
}
}
impl Drop for RemoteExecProcess {

View File

@@ -22,6 +22,7 @@ use codex_app_server_protocol::JSONRPCErrorError;
use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::ExecutorFileSystem;
use crate::FileSystemOperationOptions;
use crate::RemoveOptions;
use crate::local_file_system::LocalFileSystem;
use crate::rpc::internal_error;
@@ -39,7 +40,10 @@ impl FileSystemHandler {
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system
.read_file(&params.path)
.read_file_with_options(
&params.path,
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
Ok(FsReadFileResponse {
@@ -57,7 +61,11 @@ impl FileSystemHandler {
))
})?;
self.file_system
.write_file(&params.path, bytes)
.write_file_with_options(
&params.path,
bytes,
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
Ok(FsWriteFileResponse {})
@@ -68,11 +76,12 @@ impl FileSystemHandler {
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
self.file_system
.create_directory(
.create_directory_with_options(
&params.path,
CreateDirectoryOptions {
recursive: params.recursive.unwrap_or(true),
},
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
@@ -85,7 +94,10 @@ impl FileSystemHandler {
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
let metadata = self
.file_system
.get_metadata(&params.path)
.get_metadata_with_options(
&params.path,
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
Ok(FsGetMetadataResponse {
@@ -102,7 +114,10 @@ impl FileSystemHandler {
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
let entries = self
.file_system
.read_directory(&params.path)
.read_directory_with_options(
&params.path,
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
Ok(FsReadDirectoryResponse {
@@ -122,12 +137,13 @@ impl FileSystemHandler {
params: FsRemoveParams,
) -> Result<FsRemoveResponse, JSONRPCErrorError> {
self.file_system
.remove(
.remove_with_options(
&params.path,
RemoveOptions {
recursive: params.recursive.unwrap_or(true),
force: params.force.unwrap_or(true),
},
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
@@ -139,12 +155,13 @@ impl FileSystemHandler {
params: FsCopyParams,
) -> Result<FsCopyResponse, JSONRPCErrorError> {
self.file_system
.copy(
.copy_with_options(
&params.source_path,
&params.destination_path,
CopyOptions {
recursive: params.recursive,
},
&fs_operation_options(params.sandbox_policy, params.cwd),
)
.await
.map_err(map_fs_error)?;
@@ -152,6 +169,16 @@ impl FileSystemHandler {
}
}
fn fs_operation_options(
sandbox_policy: Option<codex_app_server_protocol::SandboxPolicy>,
cwd: Option<codex_utils_absolute_path::AbsolutePathBuf>,
) -> FileSystemOperationOptions {
FileSystemOperationOptions {
sandbox_policy: sandbox_policy.map(|policy| policy.to_core()),
cwd,
}
}
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())

View File

@@ -19,6 +19,8 @@ use crate::protocol::ExecResponse;
use crate::protocol::InitializeResponse;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
use crate::protocol::ResolveExecApprovalParams;
use crate::protocol::ResolveExecApprovalResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::protocol::WriteParams;
@@ -64,6 +66,13 @@ impl ExecServerHandler {
self.process.exec_read(params).await
}
pub(crate) async fn resolve_exec_approval(
&self,
params: ResolveExecApprovalParams,
) -> Result<ResolveExecApprovalResponse, JSONRPCErrorError> {
self.process.resolve_exec_approval(params).await
}
pub(crate) async fn exec_write(
&self,
params: WriteParams,

View File

@@ -9,9 +9,12 @@ use super::ExecServerHandler;
use crate::ProcessId;
use crate::protocol::ExecParams;
use crate::protocol::InitializeResponse;
use crate::protocol::ReadParams;
use crate::protocol::ResolveExecApprovalParams;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::rpc::RpcNotificationSender;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
fn exec_params(process_id: &str) -> ExecParams {
let mut env = HashMap::new();
@@ -29,6 +32,7 @@ fn exec_params(process_id: &str) -> ExecParams {
env,
tty: false,
arg0: None,
startup_exec_approval: None,
}
}
@@ -101,3 +105,130 @@ async fn terminate_reports_false_after_process_exit() {
handler.shutdown().await;
}
#[tokio::test]
async fn startup_exec_approval_spawns_only_after_resolution() {
let handler = initialized_handler().await;
let mut params = exec_params("proc-approval");
params.argv = vec![
"bash".to_string(),
"-lc".to_string(),
"printf ready".to_string(),
];
params.startup_exec_approval = Some(crate::protocol::ExecApprovalRequest {
call_id: "call-1".to_string(),
approval_id: None,
turn_id: "turn-1".to_string(),
command: vec![
"bash".to_string(),
"-lc".to_string(),
"printf ready".to_string(),
],
cwd: std::env::current_dir().expect("cwd"),
reason: Some("approval required".to_string()),
additional_permissions: None,
proposed_execpolicy_amendment: None,
available_decisions: Some(vec![CommandExecutionApprovalDecision::Accept]),
});
handler.exec(params).await.expect("start process");
let pending = handler
.exec_read(ReadParams {
process_id: ProcessId::from("proc-approval"),
after_seq: None,
max_bytes: None,
wait_ms: Some(0),
})
.await
.expect("read pending approval");
assert_eq!(pending.chunks, Vec::new());
assert!(pending.exec_approval.is_some());
handler
.resolve_exec_approval(ResolveExecApprovalParams {
process_id: ProcessId::from("proc-approval"),
approval_id: "call-1".to_string(),
decision: CommandExecutionApprovalDecision::Accept,
})
.await
.expect("resolve approval");
let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
loop {
let response = handler
.exec_read(ReadParams {
process_id: ProcessId::from("proc-approval"),
after_seq: None,
max_bytes: None,
wait_ms: Some(0),
})
.await
.expect("read process output");
let output = response
.chunks
.iter()
.flat_map(|chunk| chunk.chunk.0.iter().copied())
.collect::<Vec<_>>();
if String::from_utf8_lossy(&output).contains("ready") {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"approved process did not produce output within timeout"
);
tokio::time::sleep(Duration::from_millis(25)).await;
}
handler.shutdown().await;
}
#[tokio::test]
async fn startup_exec_approval_decline_returns_failure_without_spawning() {
let handler = initialized_handler().await;
let mut params = exec_params("proc-decline");
params.argv = vec![
"bash".to_string(),
"-lc".to_string(),
"printf should-not-run".to_string(),
];
params.startup_exec_approval = Some(crate::protocol::ExecApprovalRequest {
call_id: "call-2".to_string(),
approval_id: None,
turn_id: "turn-2".to_string(),
command: vec![
"bash".to_string(),
"-lc".to_string(),
"printf should-not-run".to_string(),
],
cwd: std::env::current_dir().expect("cwd"),
reason: Some("approval required".to_string()),
additional_permissions: None,
proposed_execpolicy_amendment: None,
available_decisions: Some(vec![CommandExecutionApprovalDecision::Decline]),
});
handler.exec(params).await.expect("start process");
handler
.resolve_exec_approval(ResolveExecApprovalParams {
process_id: ProcessId::from("proc-decline"),
approval_id: "call-2".to_string(),
decision: CommandExecutionApprovalDecision::Decline,
})
.await
.expect("resolve approval");
let response = handler
.exec_read(ReadParams {
process_id: ProcessId::from("proc-decline"),
after_seq: None,
max_bytes: None,
wait_ms: Some(0),
})
.await
.expect("read declined process");
assert_eq!(response.chunks, Vec::new());
assert_eq!(response.failure.as_deref(), Some("rejected by user"));
assert!(response.closed);
handler.shutdown().await;
}

View File

@@ -6,6 +6,8 @@ use crate::protocol::ExecResponse;
use crate::protocol::InitializeResponse;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
use crate::protocol::ResolveExecApprovalParams;
use crate::protocol::ResolveExecApprovalResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::protocol::WriteParams;
@@ -54,6 +56,13 @@ impl ProcessHandler {
self.process.exec_read(params).await
}
pub(crate) async fn resolve_exec_approval(
&self,
params: ResolveExecApprovalParams,
) -> Result<ResolveExecApprovalResponse, JSONRPCErrorError> {
self.process.resolve_exec_approval(params).await
}
pub(crate) async fn exec_write(
&self,
params: WriteParams,

View File

@@ -2,6 +2,7 @@ use std::sync::Arc;
use crate::protocol::EXEC_METHOD;
use crate::protocol::EXEC_READ_METHOD;
use crate::protocol::EXEC_RESOLVE_APPROVAL_METHOD;
use crate::protocol::EXEC_TERMINATE_METHOD;
use crate::protocol::EXEC_WRITE_METHOD;
use crate::protocol::ExecParams;
@@ -16,6 +17,7 @@ use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::ReadParams;
use crate::protocol::ResolveExecApprovalParams;
use crate::protocol::TerminateParams;
use crate::protocol::WriteParams;
use crate::rpc::RpcRouter;
@@ -52,6 +54,12 @@ pub(crate) fn build_router() -> RpcRouter<ExecServerHandler> {
handler.exec_read(params).await
},
);
router.request(
EXEC_RESOLVE_APPROVAL_METHOD,
|handler: Arc<ExecServerHandler>, params: ResolveExecApprovalParams| async move {
handler.resolve_exec_approval(params).await
},
);
router.request(
EXEC_WRITE_METHOD,
|handler: Arc<ExecServerHandler>, params: WriteParams| async move {

View File

@@ -54,6 +54,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
env: Default::default(),
tty: false,
arg0: None,
startup_exec_approval: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), "proc-1");
@@ -130,6 +131,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
env: Default::default(),
tty: false,
arg0: None,
startup_exec_approval: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), process_id);
@@ -159,6 +161,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
env: Default::default(),
tty: true,
arg0: None,
startup_exec_approval: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), process_id);
@@ -195,6 +198,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
env: Default::default(),
tty: false,
arg0: None,
startup_exec_approval: None,
})
.await?;
@@ -225,6 +229,7 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
env: Default::default(),
tty: false,
arg0: None,
startup_exec_approval: None,
})
.await?;

View File

@@ -12,8 +12,11 @@ use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileSystemOperationOptions;
use codex_exec_server::ReadDirectoryEntry;
use codex_exec_server::RemoveOptions;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -56,6 +59,48 @@ fn absolute_path(path: std::path::PathBuf) -> AbsolutePathBuf {
}
}
fn read_only_options(readable_root: std::path::PathBuf) -> FileSystemOperationOptions {
FileSystemOperationOptions {
sandbox_policy: Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: vec![absolute_path(readable_root)],
},
network_access: false,
}),
cwd: None,
}
}
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn file_system_write_with_sandbox_policy_uses_supplied_cwd(use_remote: bool) -> Result<()> {
let context = create_file_system_context(use_remote).await?;
let file_system = context.file_system;
let tmp = TempDir::new()?;
let workspace_root = absolute_path(tmp.path().to_path_buf());
let file_path = tmp.path().join("workspace-write.txt");
let options = FileSystemOperationOptions {
sandbox_policy: Some(SandboxPolicy::new_workspace_write_policy()),
cwd: Some(workspace_root),
};
file_system
.write_file_with_options(
&absolute_path(file_path.clone()),
b"allowed".to_vec(),
&options,
)
.await
.with_context(|| format!("mode={use_remote}"))?;
assert_eq!(std::fs::read_to_string(file_path)?, "allowed");
Ok(())
}
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -212,6 +257,66 @@ async fn file_system_copy_rejects_directory_without_recursive(use_remote: bool)
Ok(())
}
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn file_system_read_with_sandbox_policy_allows_readable_root(use_remote: bool) -> Result<()> {
let context = create_file_system_context(use_remote).await?;
let file_system = context.file_system;
let tmp = TempDir::new()?;
let allowed_dir = tmp.path().join("allowed");
let file_path = allowed_dir.join("note.txt");
std::fs::create_dir_all(&allowed_dir)?;
std::fs::write(&file_path, "sandboxed hello")?;
let contents = file_system
.read_file_with_options(&absolute_path(file_path), &read_only_options(allowed_dir))
.await
.with_context(|| format!("mode={use_remote}"))?;
assert_eq!(contents, b"sandboxed hello");
Ok(())
}
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn file_system_write_with_sandbox_policy_rejects_unwritable_path(
use_remote: bool,
) -> Result<()> {
let context = create_file_system_context(use_remote).await?;
let file_system = context.file_system;
let tmp = TempDir::new()?;
let allowed_dir = tmp.path().join("allowed");
let blocked_path = tmp.path().join("blocked.txt");
std::fs::create_dir_all(&allowed_dir)?;
let error = match file_system
.write_file_with_options(
&absolute_path(blocked_path.clone()),
b"nope".to_vec(),
&read_only_options(allowed_dir),
)
.await
{
Ok(()) => anyhow::bail!("write should be blocked"),
Err(error) => error,
};
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
error.to_string(),
format!(
"fs/write is not permitted by sandbox policy for path {}",
blocked_path.display()
)
);
assert!(!blocked_path.exists());
Ok(())
}
#[test_case(false ; "local")]
#[test_case(true ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]