mirror of
https://github.com/openai/codex.git
synced 2026-04-07 14:24:47 +00:00
Compare commits
19 Commits
dev/window
...
codex/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
689107592d | ||
|
|
a19601e767 | ||
|
|
6b9c625223 | ||
|
|
d725d0f825 | ||
|
|
ffd36edcf8 | ||
|
|
350b24d177 | ||
|
|
8b1b2e9efd | ||
|
|
fc86503a4e | ||
|
|
3aa46997c0 | ||
|
|
e4939840ab | ||
|
|
957f8e8433 | ||
|
|
23d45e5224 | ||
|
|
1121fd8490 | ||
|
|
59da18ca84 | ||
|
|
0a346655bd | ||
|
|
828f8c73b1 | ||
|
|
b99066b0ae | ||
|
|
994f8311ca | ||
|
|
38ea15e634 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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(¶ms).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(¶ms).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(¶ms).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(¶ms).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,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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(¶ms.path)
|
||||
.read_file_with_options(
|
||||
¶ms.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(¶ms.path, bytes)
|
||||
.write_file_with_options(
|
||||
¶ms.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(
|
||||
¶ms.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(¶ms.path)
|
||||
.get_metadata_with_options(
|
||||
¶ms.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(¶ms.path)
|
||||
.read_directory_with_options(
|
||||
¶ms.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(
|
||||
¶ms.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(
|
||||
¶ms.source_path,
|
||||
¶ms.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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@ mod tests {
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
arg0: None,
|
||||
startup_exec_approval: None,
|
||||
})
|
||||
.await
|
||||
.expect("start process");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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(),
|
||||
¶ms.env,
|
||||
¶ms.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(),
|
||||
¶ms.env,
|
||||
¶ms.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(),
|
||||
¶ms.env,
|
||||
¶ms.arg0,
|
||||
TerminalSize::default(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
codex_utils_pty::spawn_pipe_process_no_stdin(
|
||||
program,
|
||||
args,
|
||||
params.cwd.as_path(),
|
||||
¶ms.env,
|
||||
¶ms.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(¶ms).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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¶ms.path)
|
||||
.read_file_with_options(
|
||||
¶ms.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(¶ms.path, bytes)
|
||||
.write_file_with_options(
|
||||
¶ms.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(
|
||||
¶ms.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(¶ms.path)
|
||||
.get_metadata_with_options(
|
||||
¶ms.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(¶ms.path)
|
||||
.read_directory_with_options(
|
||||
¶ms.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(
|
||||
¶ms.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(
|
||||
¶ms.source_path,
|
||||
¶ms.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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user