diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e15d70e..9df91f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,13 +33,21 @@ jobs: run: | cargo fmt --check cargo fmt --check --manifest-path crates/patch/Cargo.toml + cargo fmt --check --manifest-path crates/codex-runtime/Cargo.toml - name: Clippy run: | cargo clippy --all-targets -- -D warnings cargo clippy --manifest-path crates/patch/Cargo.toml --all-targets -- -D warnings + cargo clippy --manifest-path crates/codex-runtime/Cargo.toml --all-targets -- -D warnings - name: Test run: | cargo test --manifest-path crates/patch/Cargo.toml cargo build --manifest-path crates/patch/Cargo.toml --bin codespace-patch + cargo test --manifest-path crates/codex-runtime/Cargo.toml + cargo build --manifest-path crates/codex-runtime/Cargo.toml --bin codespace-codex-runtime + # Worker apply_patch still shells out to the patch helper. Export + # CODESPACE_PATCH_BIN before workspace and UDS tests. RuntimeProcess + # tests spawn the worker when CODESPACE_RUNTIME_BIN is set. export CODESPACE_PATCH_BIN="${PWD}/crates/patch/target/debug/codespace-patch" + export CODESPACE_RUNTIME_BIN="${PWD}/crates/codex-runtime/target/debug/codespace-codex-runtime" cargo test --workspace diff --git a/Cargo.toml b/Cargo.toml index 19eb2e6..48c87af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" members = ["crates/domain", "crates/policy", "crates/runner", "crates/store", "crates/server"] -exclude = ["crates/patch", "third_party"] +exclude = ["crates/patch", "crates/codex-runtime", "third_party"] [workspace.package] version = "0.6.0" diff --git a/NOTICE b/NOTICE index 3ea35fc..a677e09 100644 --- a/NOTICE +++ b/NOTICE @@ -23,6 +23,9 @@ under the Apache License, Version 2.0. depends on other Codex workspace crates (including `codex-exec-server`, path utilities, and tree-sitter bindings). The adapter lives in `crates/patch` and calls the library in-process. + Isolated `crates/codex-runtime` (`codespace-codex-runtime`) reuses + `codex-process-hardening` and `codex-uds` for the opt-in Unix-socket + worker. `codespace-patch` also calls `pre_main_hardening()`. Codex copyright: OpenAI. Codex license: Apache License 2.0. diff --git a/README.ko.md b/README.ko.md index df23d61..28e0225 100644 --- a/README.ko.md +++ b/README.ko.md @@ -14,7 +14,9 @@ stdin/stdout으로 Rust `codespace-patch` 헬퍼와 대화합니다. 그 헬퍼 프로세스는 Codex를 **프로세스 내부에서** 호출합니다. `exec_command`는 현재 워크스페이스를 cwd로 하는 **호스트** 프로세스 (`tokio::process::Command`)를 띄웁니다. 격리된 Linux 디스패치가 목표 -러너 경계이며, 현재 exec 경로는 아닙니다. +러너 경계이며, 현재 exec 경로는 아닙니다. 선택적 `CODESPACE_RUNNER=uds`는 +CodeSpace JSON으로 `codespace-codex-runtime`과 대화합니다. 그것은 Linux +격리가 아닙니다. 다음이 **아닙니다**. @@ -56,7 +58,8 @@ cargo run -p codespace-server --bin codespace-mcp -- --http ``` 시험: `cargo test --workspace`와 -`cargo test --manifest-path crates/patch/Cargo.toml`. ChatGPT Custom +`cargo test --manifest-path crates/patch/Cargo.toml`와 +`cargo test --manifest-path crates/codex-runtime/Cargo.toml`. ChatGPT Custom Connector 절차와 **검증하지 않은** 항목: [docs/chatgpt-connector.md](docs/chatgpt-connector.md). 운영자 설치: [docs/operations.md](docs/operations.md). diff --git a/README.md b/README.md index 262f14b..fc4d4a1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ helper over JSON stdin/stdout. That helper process calls Codex **in-process**. `exec_command` currently spawns a **host** process (`tokio::process::Command`) with the workspace as cwd. Isolated Linux dispatch is the target runner boundary, not the current exec path. +Opt-in `CODESPACE_RUNNER=uds` talks CodeSpace JSON to +`codespace-codex-runtime`; that is not Linux isolation. This is **not**: @@ -55,7 +57,8 @@ cargo run -p codespace-server --bin codespace-mcp -- --http ``` Tests: `cargo test --workspace` and -`cargo test --manifest-path crates/patch/Cargo.toml`. ChatGPT Custom +`cargo test --manifest-path crates/patch/Cargo.toml` and +`cargo test --manifest-path crates/codex-runtime/Cargo.toml`. ChatGPT Custom Connector steps and what is **not** verified: [docs/chatgpt-connector.md](docs/chatgpt-connector.md). Operator install: [docs/operations.md](docs/operations.md). diff --git a/crates/codex-runtime/Cargo.lock b/crates/codex-runtime/Cargo.lock new file mode 100644 index 0000000..e4fd5b7 --- /dev/null +++ b/crates/codex-runtime/Cargo.lock @@ -0,0 +1,685 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "codespace-codex-runtime" +version = "0.6.0" +dependencies = [ + "codespace-runner", + "codex-process-hardening", + "codex-uds", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "codespace-domain" +version = "0.6.0" +dependencies = [ + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "codespace-policy" +version = "0.6.0" +dependencies = [ + "codespace-domain", + "serde", + "serde_json", +] + +[[package]] +name = "codespace-runner" +version = "0.6.0" +dependencies = [ + "codespace-domain", + "codespace-policy", + "hex", + "serde", + "serde_json", + "sha2", + "tokio", +] + +[[package]] +name = "codex-process-hardening" +version = "0.154.0" +dependencies = [ + "libc", +] + +[[package]] +name = "codex-uds" +version = "0.154.0" +dependencies = [ + "async-io", + "tokio", + "tokio-util", + "uds_windows", + "windows-sys 0.52.0", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/codex-runtime/Cargo.toml b/crates/codex-runtime/Cargo.toml new file mode 100644 index 0000000..e0a9a7a --- /dev/null +++ b/crates/codex-runtime/Cargo.toml @@ -0,0 +1,31 @@ +# Isolated workspace (excluded from the repo root workspace) so the pinned +# Codex crates keep their own workspace.dependencies. + +[package] +name = "codespace-codex-runtime" +version = "0.6.0" +edition = "2021" +license = "Apache-2.0" +rust-version = "1.88" +publish = false +description = "Isolated Codex execution adapter: process-hardening and Unix socket worker" + +[workspace] +members = ["."] +resolver = "2" + +[dependencies] +codespace-runner = { path = "../runner" } +codex-process-hardening = { path = "../../third_party/codex/codex-rs/process-hardening" } +codex-uds = { path = "../../third_party/codex/codex-rs/uds" } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "fs", "sync", "process", "time"] } + +[dev-dependencies] +codespace-runner = { path = "../runner" } +serde_json = "1" +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "time", "process"] } + +[[bin]] +name = "codespace-codex-runtime" +path = "src/bin/codespace-codex-runtime.rs" diff --git a/crates/codex-runtime/src/bin/codespace-codex-runtime.rs b/crates/codex-runtime/src/bin/codespace-codex-runtime.rs new file mode 100644 index 0000000..62b8b66 --- /dev/null +++ b/crates/codex-runtime/src/bin/codespace-codex-runtime.rs @@ -0,0 +1,39 @@ +//! Isolated runner worker. Binds the given `$dir/runner.sock` (or a +//! newly allocated unique dir when run standalone). Parent chmod is the +//! gateway's job: this process never calls +//! `prepare_private_socket_directory`. One `InProcessRunner` per +//! process. P0 is 1:1 — serve one connection, then exit. + +use std::path::PathBuf; + +use codespace_runner::{ + allocate_private_runner_dir, host_worker, reclaim_leftover_socket, runner_socket_path, + serve_runner_connection, +}; + +#[tokio::main] +async fn main() { + codex_process_hardening::pre_main_hardening(); + let socket = match std::env::args().nth(1) { + Some(path) => PathBuf::from(path), + None => match std::env::var_os("CODESPACE_RUNNER_SOCKET") { + Some(path) => PathBuf::from(path), + None => { + let dir = allocate_private_runner_dir(None).expect("private runner dir"); + let socket = runner_socket_path(&dir); + eprintln!("codespace-codex-runtime listening on {}", socket.display()); + socket + } + }, + }; + reclaim_leftover_socket(&socket).expect("runner socket"); + let mut listener = codex_uds::UnixListener::bind(&socket) + .await + .expect("bind runner socket"); + let (runner, events) = host_worker(); + let stream = listener.accept().await.expect("accept runner socket"); + // Keep the listener bound so a live connect-probe sees AddrInUse + // instead of unlinking this rendezvous. + let _listener = listener; + let _ = serve_runner_connection(stream, runner, events).await; +} diff --git a/crates/codex-runtime/src/lib.rs b/crates/codex-runtime/src/lib.rs new file mode 100644 index 0000000..d73a64f --- /dev/null +++ b/crates/codex-runtime/src/lib.rs @@ -0,0 +1,9 @@ +//! Isolated Codex runtime adapter workspace. Product MCP stays in `crates/server`. + +#[cfg(test)] +mod tests { + #[test] + fn crate_is_isolated_adapter() { + assert_eq!(env!("CARGO_PKG_NAME"), "codespace-codex-runtime"); + } +} diff --git a/crates/codex-runtime/tests/runtime_binary.rs b/crates/codex-runtime/tests/runtime_binary.rs new file mode 100644 index 0000000..0ffd495 --- /dev/null +++ b/crates/codex-runtime/tests/runtime_binary.rs @@ -0,0 +1,138 @@ +//! Spawn the worker binary against a real socket file. + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use codespace_runner::{ + read_frame, write_frame, RunnerOp, RunnerOpResult, WireEnvelope, WIRE_PROTOCOL, +}; +use tokio::net::UnixStream; + +const BIN: &str = env!("CARGO_BIN_EXE_codespace-codex-runtime"); + +fn runner_socket(dir: &Path) -> PathBuf { + dir.join("runner.sock") +} + +async fn wait_connect(socket: &Path) -> UnixStream { + for _ in 0..100 { + if let Ok(stream) = UnixStream::connect(socket).await { + return stream; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("worker socket not ready at {}", socket.display()); +} + +async fn spawn_worker(socket: &Path) -> tokio::process::Child { + if let Some(parent) = socket.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + tokio::process::Command::new(BIN) + .arg(socket) + .kill_on_drop(true) + .spawn() + .expect("spawn codespace-codex-runtime") +} + +#[tokio::test] +async fn dedicated_leaf_stays_0700_and_parent_mode_is_unchanged() { + let parent = tempfile::tempdir().unwrap(); + std::fs::set_permissions(parent.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let leaf = parent.path().join("leaf"); + std::fs::create_dir(&leaf).unwrap(); + std::fs::set_permissions(&leaf, std::fs::Permissions::from_mode(0o700)).unwrap(); + let socket = runner_socket(&leaf); + let _worker = spawn_worker(&socket).await; + let stream = wait_connect(&socket).await; + let parent_mode = std::fs::metadata(parent.path()) + .unwrap() + .permissions() + .mode() + & 0o777; + let leaf_mode = std::fs::metadata(&leaf).unwrap().permissions().mode() & 0o777; + assert_eq!(parent_mode, 0o755); + assert_eq!(leaf_mode, 0o700); + drop(stream); +} + +#[tokio::test] +async fn parent_0755_is_not_chmodded_when_socket_lives_there() { + let parent = tempfile::tempdir().unwrap(); + std::fs::set_permissions(parent.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let socket = runner_socket(parent.path()); + let _worker = spawn_worker(&socket).await; + let stream = wait_connect(&socket).await; + let parent_mode = std::fs::metadata(parent.path()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(parent_mode, 0o755); + drop(stream); +} + +#[tokio::test] +async fn second_instance_does_not_unlink_live_socket() { + let dir = tempfile::tempdir().unwrap(); + let socket = runner_socket(dir.path()); + let _worker = spawn_worker(&socket).await; + let stream = wait_connect(&socket).await; + assert!(socket.exists()); + let second = Command::new(BIN) + .arg(&socket) + .output() + .expect("second worker"); + assert!( + !second.status.success(), + "second worker stdout={} stderr={}", + String::from_utf8_lossy(&second.stdout), + String::from_utf8_lossy(&second.stderr) + ); + assert!(socket.exists(), "live socket must not be unlinked"); + drop(stream); +} + +#[tokio::test] +async fn protocol_mismatch_responds_then_exits() { + let dir = tempfile::tempdir().unwrap(); + let socket = runner_socket(dir.path()); + let mut worker = spawn_worker(&socket).await; + let mut stream = wait_connect(&socket).await; + let mut envelope = WireEnvelope::request("rrpc-bad".into(), RunnerOp::Hello); + envelope.protocol = 99; + write_frame(&mut stream, &envelope).await.unwrap(); + let reply = read_frame(&mut stream).await.unwrap().unwrap(); + let parsed: WireEnvelope = serde_json::from_slice(&reply).unwrap(); + assert_eq!(parsed.ok, Some(false)); + assert!(parsed.error.is_some()); + let eof = read_frame(&mut stream).await.unwrap(); + assert!(eof.is_none()); + let status = tokio::time::timeout(Duration::from_secs(2), worker.wait()) + .await + .expect("worker should exit after mismatch") + .expect("wait worker"); + let _ = status; +} + +#[tokio::test] +async fn hello_on_live_socket() { + let dir = tempfile::tempdir().unwrap(); + let socket = runner_socket(dir.path()); + let _worker = spawn_worker(&socket).await; + let mut stream = wait_connect(&socket).await; + write_frame( + &mut stream, + &WireEnvelope::request("rrpc-hello".into(), RunnerOp::Hello), + ) + .await + .unwrap(); + let reply = read_frame(&mut stream).await.unwrap().unwrap(); + let parsed: WireEnvelope = serde_json::from_slice(&reply).unwrap(); + match parsed.result { + Some(RunnerOpResult::Hello { protocol }) => assert_eq!(protocol, WIRE_PROTOCOL), + other => panic!("unexpected hello {other:?}"), + } +} diff --git a/crates/domain/src/process.rs b/crates/domain/src/process.rs index 0991499..cd528d7 100644 --- a/crates/domain/src/process.rs +++ b/crates/domain/src/process.rs @@ -46,3 +46,21 @@ pub struct ReadProcessResult { pub struct TerminateProcessParams { pub process_id: ProcessId, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exec_command_params_have_no_environment_id() { + let json = serde_json::to_value(ExecCommandParams { + workspace_id: WorkspaceId("demo".into()), + command: vec!["/bin/echo".into()], + work_id: None, + }) + .unwrap(); + assert!(json.get("environment_id").is_none()); + assert!(json.get("cwd").is_none()); + assert!(json.get("tty").is_none()); + } +} diff --git a/crates/patch/Cargo.lock b/crates/patch/Cargo.lock index a3181e8..e9c96b5 100644 --- a/crates/patch/Cargo.lock +++ b/crates/patch/Cargo.lock @@ -728,6 +728,7 @@ dependencies = [ "codespace-policy", "codex-apply-patch", "codex-exec-server", + "codex-process-hardening", "codex-utils-path-uri", "hex", "serde", @@ -1131,6 +1132,13 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "codex-process-hardening" +version = "0.154.0" +dependencies = [ + "libc", +] + [[package]] name = "codex-protocol" version = "0.154.0" diff --git a/crates/patch/Cargo.toml b/crates/patch/Cargo.toml index 842ea7d..56b46d4 100644 --- a/crates/patch/Cargo.toml +++ b/crates/patch/Cargo.toml @@ -20,6 +20,7 @@ codespace-policy = { path = "../policy" } codex-apply-patch = { path = "../../third_party/codex/codex-rs/apply-patch" } codex-exec-server = { path = "../../third_party/codex/codex-rs/exec-server" } codex-utils-path-uri = { path = "../../third_party/codex/codex-rs/utils/path-uri" } +codex-process-hardening = { path = "../../third_party/codex/codex-rs/process-hardening" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/patch/src/bin/codespace-patch.rs b/crates/patch/src/bin/codespace-patch.rs index e37b399..f88c8f8 100644 --- a/crates/patch/src/bin/codespace-patch.rs +++ b/crates/patch/src/bin/codespace-patch.rs @@ -43,6 +43,7 @@ fn fail(err: ErrorBody) -> Response { #[tokio::main] async fn main() { + codex_process_hardening::pre_main_hardening(); let mut buf = String::new(); io::stdin().read_to_string(&mut buf).expect("read stdin"); let req: Request = match serde_json::from_str(&buf) { @@ -55,11 +56,11 @@ async fn main() { return; } }; - let ws = Workspace { - id: WorkspaceId("helper".into()), - root: req.root, - profile: Profile::WorkspaceWrite, - }; + let ws = Workspace::new( + WorkspaceId("helper".into()), + req.root, + Profile::WorkspaceWrite, + ); let resp = match req.op.as_str() { "preflight" => match apply_in_workspace(&ws, &req.patch, true).await { Ok(ApplyOutcome { files, changes }) => Response { diff --git a/crates/patch/src/lib.rs b/crates/patch/src/lib.rs index 423ff29..1b9fa45 100644 --- a/crates/patch/src/lib.rs +++ b/crates/patch/src/lib.rs @@ -256,11 +256,11 @@ mod tests { use tempfile::tempdir; fn ws(dir: &Path) -> Workspace { - Workspace { - id: WorkspaceId("demo".into()), - root: dir.to_path_buf(), - profile: Profile::WorkspaceWrite, - } + Workspace::new( + WorkspaceId("demo".into()), + dir.to_path_buf(), + Profile::WorkspaceWrite, + ) } #[test] diff --git a/crates/policy/Cargo.toml b/crates/policy/Cargo.toml index f81b61d..d3edb8a 100644 --- a/crates/policy/Cargo.toml +++ b/crates/policy/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true rust-version.workspace = true publish = false -description = "Workspace registry and path policy" +description = "Workspace registry, PermissionProfile, and Environment" [dependencies] codespace-domain = { path = "../domain" } diff --git a/crates/policy/src/environment.rs b/crates/policy/src/environment.rs new file mode 100644 index 0000000..083b731 --- /dev/null +++ b/crates/policy/src/environment.rs @@ -0,0 +1,85 @@ +//! Operator-registered execution location. Not an MCP tool field. +//! +//! `EnvironmentKind` is not a transport. `Host` may use in-process or an +//! opt-in UDS worker on the same host. `LinuxContainer` stays fail-closed. + +use codespace_domain::{ErrorBody, ErrorCode}; +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_ENVIRONMENT_ID: &str = "local"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EnvironmentKind { + #[default] + Host, + LinuxContainer, +} + +impl std::fmt::Display for EnvironmentKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Host => write!(f, "host"), + Self::LinuxContainer => write!(f, "linux-container"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Environment { + pub id: String, + pub kind: EnvironmentKind, +} + +impl Environment { + pub fn local_host() -> Self { + Self { + id: DEFAULT_ENVIRONMENT_ID.to_string(), + kind: EnvironmentKind::Host, + } + } +} + +/// Internal dispatch refusal. MCP never sees this type; map with +/// [`EnvironmentDispatchError::into_error_body`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EnvironmentDispatchError { + UnsupportedKind { kind: EnvironmentKind }, +} + +impl EnvironmentDispatchError { + pub fn into_error_body(self) -> ErrorBody { + match self { + Self::UnsupportedKind { kind } => ErrorBody::new( + ErrorCode::Unauthorized, + format!("{kind} environment is registered but not an exec path"), + ), + } + } +} + +pub fn require_host_execution(kind: EnvironmentKind) -> Result<(), EnvironmentDispatchError> { + match kind { + EnvironmentKind::Host => Ok(()), + EnvironmentKind::LinuxContainer => Err(EnvironmentDispatchError::UnsupportedKind { kind }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_is_allowed() { + assert!(require_host_execution(EnvironmentKind::Host).is_ok()); + } + + #[test] + fn linux_container_is_closed_failure() { + let err = require_host_execution(EnvironmentKind::LinuxContainer).unwrap_err(); + let body = err.into_error_body(); + assert_eq!(body.code, ErrorCode::Unauthorized); + assert!(body.message.contains("linux-container")); + assert!(body.operation_id.is_none()); + } +} diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 54644cd..fa8a3e3 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -1,11 +1,20 @@ //! Workspace registry and path policy. No `rmcp` types. +mod environment; +mod permission; + use std::collections::BTreeMap; use std::fs; use std::path::{Component, Path, PathBuf}; use codespace_domain::{ErrorBody, ErrorCode, Profile, WorkspaceId}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; + +pub use environment::{ + require_host_execution, Environment, EnvironmentDispatchError, EnvironmentKind, + DEFAULT_ENVIRONMENT_ID, +}; +pub use permission::{NetworkAxis, PathAccess, PathRule, PermissionProfile}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { @@ -23,40 +32,86 @@ pub struct ClientClaims { pub user_id: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Workspace { pub id: WorkspaceId, pub root: PathBuf, pub profile: Profile, + #[serde(default = "default_environment_id")] + pub environment_id: String, + #[serde(default)] + pub environment_kind: EnvironmentKind, +} + +fn default_environment_id() -> String { + DEFAULT_ENVIRONMENT_ID.to_string() } -#[derive(Debug, Clone, Default)] +impl Workspace { + pub fn new(id: WorkspaceId, root: PathBuf, profile: Profile) -> Self { + Self { + id, + root, + profile, + environment_id: DEFAULT_ENVIRONMENT_ID.to_string(), + environment_kind: EnvironmentKind::Host, + } + } + + pub fn require_host_execution(&self) -> Result<(), ErrorBody> { + require_host_execution(self.environment_kind) + .map_err(EnvironmentDispatchError::into_error_body) + } +} + +#[derive(Debug, Clone)] pub struct Registry { + environments: BTreeMap, workspaces: BTreeMap, } #[derive(Debug, Deserialize)] struct FileConfig { + #[serde(default)] + environments: BTreeMap, #[serde(default)] workspaces: BTreeMap, } +#[derive(Debug, Deserialize)] +struct FileEnvironment { + kind: EnvironmentKind, +} + #[derive(Debug, Deserialize)] struct FileWorkspace { root: String, #[serde(default)] profile: Profile, + #[serde(default)] + environment: Option, } impl Registry { pub fn new() -> Self { - Self::default() + let mut environments = BTreeMap::new(); + let local = Environment::local_host(); + environments.insert(local.id.clone(), local); + Self { + environments, + workspaces: BTreeMap::new(), + } } pub fn insert(&mut self, workspace: Workspace) { self.workspaces.insert(workspace.id.0.clone(), workspace); } + pub fn insert_environment(&mut self, environment: Environment) { + self.environments + .insert(environment.id.clone(), environment); + } + pub fn get(&self, id: &str) -> Result<&Workspace, ErrorBody> { self.workspaces.get(id).ok_or_else(|| { ErrorBody::new( @@ -66,6 +121,15 @@ impl Registry { }) } + pub fn environment(&self, id: &str) -> Result<&Environment, ErrorBody> { + self.environments.get(id).ok_or_else(|| { + ErrorBody::new( + ErrorCode::Unauthorized, + format!("unknown environment `{id}`"), + ) + }) + } + pub fn load_path(path: &Path) -> Result { let text = fs::read_to_string(path).map_err(|e| e.to_string())?; Self::load_json(&text) @@ -74,15 +138,29 @@ impl Registry { pub fn load_json(text: &str) -> Result { let parsed: FileConfig = serde_json::from_str(text).map_err(|e| e.to_string())?; let mut registry = Registry::new(); + for (id, entry) in parsed.environments { + registry.insert_environment(Environment { + id, + kind: entry.kind, + }); + } for (id, entry) in parsed.workspaces { let root = PathBuf::from(&entry.root); if !root.is_absolute() { return Err(format!("workspace `{id}` root must be absolute")); } + let environment_id = entry + .environment + .unwrap_or_else(|| DEFAULT_ENVIRONMENT_ID.to_string()); + let environment = registry.environments.get(&environment_id).ok_or_else(|| { + format!("workspace `{id}` references unknown environment `{environment_id}`") + })?; registry.insert(Workspace { id: WorkspaceId(id), root, profile: entry.profile, + environment_id: environment.id.clone(), + environment_kind: environment.kind, }); } Ok(registry) @@ -93,6 +171,12 @@ impl Registry { } } +impl Default for Registry { + fn default() -> Self { + Self::new() + } +} + /// `approved` / `user_id` never grant rights. Profile does. pub fn allow( workspace: &Workspace, @@ -103,7 +187,7 @@ pub fn allow( match action { Action::Read => Ok(()), Action::Write | Action::Exec => { - if workspace.profile.allows_mutation() { + if PermissionProfile::from_workspace_profile(workspace.profile).allows(action) { Ok(()) } else { Err(ErrorBody::new( @@ -164,11 +248,7 @@ mod tests { use tempfile::tempdir; fn demo(dir: &Path, profile: Profile) -> Workspace { - Workspace { - id: WorkspaceId("demo".into()), - root: dir.to_path_buf(), - profile, - } + Workspace::new(WorkspaceId("demo".into()), dir.to_path_buf(), profile) } #[test] @@ -182,7 +262,10 @@ mod tests { fn default_profile_is_read_only() { let json = r#"{"workspaces":{"demo":{"root":"/tmp/demo"}}}"#; let registry = Registry::load_json(json).unwrap(); - assert_eq!(registry.get("demo").unwrap().profile, Profile::ReadOnly); + let ws = registry.get("demo").unwrap(); + assert_eq!(ws.profile, Profile::ReadOnly); + assert_eq!(ws.environment_id, DEFAULT_ENVIRONMENT_ID); + assert_eq!(ws.environment_kind, EnvironmentKind::Host); } #[test] @@ -247,11 +330,45 @@ mod tests { fn model_cannot_register_a_workspace() { let mut registry = Registry::new(); assert!(registry.get("sneaky").is_err()); - registry.insert(Workspace { - id: WorkspaceId("sneaky".into()), - root: PathBuf::from("/tmp/sneaky"), - profile: Profile::WorkspaceWrite, - }); + registry.insert(Workspace::new( + WorkspaceId("sneaky".into()), + PathBuf::from("/tmp/sneaky"), + Profile::WorkspaceWrite, + )); assert!(registry.get("sneaky").is_ok()); } + + #[test] + fn unknown_environment_fails_config_load() { + let json = r#"{"workspaces":{"demo":{"root":"/tmp/demo","environment":"missing"}}}"#; + let err = Registry::load_json(json).unwrap_err(); + assert!(err.contains("unknown environment")); + } + + #[test] + fn linux_container_environment_loads_but_is_not_an_exec_path() { + let json = r#"{ + "environments": {"box": {"kind": "linux-container"}}, + "workspaces": {"demo": {"root": "/tmp/demo", "environment": "box"}} + }"#; + let registry = Registry::load_json(json).unwrap(); + let ws = registry.get("demo").unwrap(); + assert_eq!(ws.environment_kind, EnvironmentKind::LinuxContainer); + let err = require_host_execution(ws.environment_kind).unwrap_err(); + assert!(matches!( + err, + EnvironmentDispatchError::UnsupportedKind { + kind: EnvironmentKind::LinuxContainer + } + )); + assert_eq!( + ws.require_host_execution().unwrap_err().code, + ErrorCode::Unauthorized + ); + assert!(ws + .require_host_execution() + .unwrap_err() + .operation_id + .is_none()); + } } diff --git a/crates/policy/src/permission.rs b/crates/policy/src/permission.rs new file mode 100644 index 0000000..334d8c2 --- /dev/null +++ b/crates/policy/src/permission.rs @@ -0,0 +1,125 @@ +//! CodeSpace permission domain. Not a Codex type and not an MCP schema. +//! +//! Path glob rules are **domain only**. Live enforcement stays the coarse +//! workspace profile (`allow(Write|Exec)`) plus PathSandbox. The Codex +//! parser is not the gateway allow engine. + +use codespace_domain::Profile; +use serde::{Deserialize, Serialize}; + +use crate::Action; + +/// Read / Write / Deny on a path, glob, or special root. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PathAccess { + Read, + Write, + Deny, +} + +/// Expressed path rule. Not consulted by live `apply_patch` authorization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathRule { + pub pattern: String, + pub access: PathAccess, +} + +/// Network axis is recorded only. It is not an allow engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NetworkAxis { + Restricted, + Enabled, +} + +/// Gateway-owned permission shape. Do not import `codex_protocol::PermissionProfile`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionProfile { + pub paths: Vec, + /// `Action::Exec` is this axis only. Write globs do not grant exec. + pub process_exec: bool, + pub network: NetworkAxis, +} + +impl PermissionProfile { + pub fn from_workspace_profile(profile: Profile) -> Self { + match profile { + Profile::ReadOnly => Self { + paths: vec![PathRule { + pattern: "**".into(), + access: PathAccess::Read, + }], + process_exec: false, + network: NetworkAxis::Restricted, + }, + Profile::WorkspaceWrite => Self { + paths: vec![PathRule { + pattern: "**".into(), + access: PathAccess::Write, + }], + process_exec: true, + network: NetworkAxis::Restricted, + }, + } + } + + pub fn allows(&self, action: Action) -> bool { + let _ = self.network; + match action { + Action::Read => self + .paths + .iter() + .any(|rule| !matches!(rule.access, PathAccess::Deny)), + Action::Write => self + .paths + .iter() + .any(|rule| matches!(rule.access, PathAccess::Write)), + Action::Exec => self.process_exec, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_only_maps_to_read_and_restricted_network() { + let profile = PermissionProfile::from_workspace_profile(Profile::ReadOnly); + assert!(profile.allows(Action::Read)); + assert!(!profile.allows(Action::Write)); + assert!(!profile.allows(Action::Exec)); + assert!(!profile.process_exec); + assert_eq!(profile.network, NetworkAxis::Restricted); + } + + #[test] + fn workspace_write_allows_mutation() { + let profile = PermissionProfile::from_workspace_profile(Profile::WorkspaceWrite); + assert!(profile.allows(Action::Write)); + assert!(profile.allows(Action::Exec)); + assert!(profile.process_exec); + assert_eq!(profile.network, NetworkAxis::Restricted); + } + + #[test] + fn process_exec_is_independent_of_write_glob() { + let mut profile = PermissionProfile::from_workspace_profile(Profile::WorkspaceWrite); + profile.process_exec = false; + assert!(profile.allows(Action::Write)); + assert!(!profile.allows(Action::Exec)); + } + + #[test] + fn network_axis_does_not_grant() { + let mut profile = PermissionProfile::from_workspace_profile(Profile::ReadOnly); + profile.network = NetworkAxis::Enabled; + assert!(!profile.allows(Action::Write)); + assert!(!profile.allows(Action::Exec)); + assert!(profile.allows(Action::Read)); + profile.process_exec = true; + assert!(profile.allows(Action::Exec)); + assert!(!profile.allows(Action::Write)); + } +} diff --git a/crates/runner/Cargo.toml b/crates/runner/Cargo.toml index e775193..7bfb102 100644 --- a/crates/runner/Cargo.toml +++ b/crates/runner/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true rust-version.workspace = true publish = false -description = "In-process Runner: filesystem, patch transaction, host process supervisor, isolation fixture checks" +description = "Runner trait, execution DTOs, InProcessRunner, and opt-in UdsRunner" [dependencies] codespace-domain = { path = "../domain" } @@ -14,7 +14,7 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10" hex = "0.4" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "time", "io-util"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "time", "io-util", "net", "sync"] } [dev-dependencies] tempfile = "3" diff --git a/crates/runner/src/api.rs b/crates/runner/src/api.rs index 53f8c9c..4eee403 100644 --- a/crates/runner/src/api.rs +++ b/crates/runner/src/api.rs @@ -2,33 +2,112 @@ //! operation persistence. JsonSchema/rmcp stay in `crates/domain`. use std::collections::BTreeMap; +use std::path::Path; -use codespace_domain::{FileChange, PatchStatus, ProcessId}; +use codespace_domain::{ErrorBody, ErrorCode, FileChange, PatchStatus, ProcessId, Profile}; +use codespace_policy::NetworkAxis; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq)] +pub const MAX_OUTPUT_BYTES: usize = 256 * 1024; +pub const DEFAULT_TIMEOUT_MS: u64 = 30_000; + +/// Gateway-filled exec policy summary. Not an allow engine. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunnerExecPolicy { + pub workspace_profile: Profile, + pub network: NetworkAxis, +} + +/// Working directory for exec. The runner resolves this against its local +/// workspace root. Host absolute paths are not part of the wire contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunnerCwd { + WorkspaceRoot, +} + +/// Exec environment. `PATH` / `HOME` / `LANG` come from the **runner +/// process** when `use_runner_defaults` is set. The gateway does not +/// serialize its own `PATH` or host absolute cwd. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunnerExecEnv { + pub use_runner_defaults: bool, + #[serde(default)] + pub overrides: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunnerExecRequest { pub argv: Vec, pub process_id: ProcessId, + pub cwd: RunnerCwd, + pub env: RunnerExecEnv, + pub timeout_ms: u64, + pub output_bytes_cap: u64, + pub tty: bool, + pub policy: RunnerExecPolicy, } -#[derive(Debug, Clone, PartialEq, Eq)] +impl RunnerExecRequest { + pub fn for_host(argv: Vec, process_id: ProcessId, profile: Profile) -> Self { + Self { + argv, + process_id, + cwd: RunnerCwd::WorkspaceRoot, + env: RunnerExecEnv { + use_runner_defaults: true, + overrides: BTreeMap::new(), + }, + timeout_ms: default_exec_timeout_ms(), + output_bytes_cap: MAX_OUTPUT_BYTES as u64, + tty: false, + policy: RunnerExecPolicy { + workspace_profile: profile, + network: NetworkAxis::Restricted, + }, + } + } +} + +/// Runner-local defaults applied after `env_clear`. Not a DTO field. +pub fn runner_local_exec_env(home: &Path) -> BTreeMap { + let mut env = BTreeMap::new(); + env.insert( + "PATH".into(), + std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin:/usr/sbin:/sbin".into()), + ); + env.insert("HOME".into(), home.display().to_string()); + env.insert("LANG".into(), "C".into()); + env +} + +pub fn default_exec_timeout_ms() -> u64 { + std::env::var("CODESPACE_PROCESS_TIMEOUT_SECS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .filter(|secs| *secs > 0) + .map(|secs| secs.saturating_mul(1000)) + .unwrap_or(DEFAULT_TIMEOUT_MS) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunnerExecResult { pub process_id: ProcessId, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunnerWriteStdin { pub process_id: ProcessId, pub data: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunnerReadProcess { pub process_id: ProcessId, pub cursor: u64, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunnerReadResult { pub process_id: ProcessId, pub cursor: u64, @@ -36,33 +115,143 @@ pub struct RunnerReadResult { pub eof: bool, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunnerApplyPatchRequest { pub patch: String, pub expected_versions: BTreeMap, pub check_only: bool, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunnerApplyPatchResult { pub status: PatchStatus, pub files: Vec, pub changes: Vec, } +/// Runner-trait errors. Not MCP `ErrorBody` until the gateway maps them. +/// No new public `ErrorCode` is added. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunnerError { + /// Disk/exec engine refused the request. + Execution(ErrorBody), + /// The request never left this process. Disk is unchanged. + TransportBeforeDispatch { message: String }, + /// The request may have run. Result cannot be confirmed. + TransportAmbiguous { message: String }, +} + +impl RunnerError { + pub fn execution(body: ErrorBody) -> Self { + Self::Execution(body) + } + + pub fn before_dispatch(message: impl Into) -> Self { + Self::TransportBeforeDispatch { + message: message.into(), + } + } + + pub fn ambiguous(message: impl Into) -> Self { + Self::TransportAmbiguous { + message: message.into(), + } + } + + pub fn as_execution(&self) -> Option<&ErrorBody> { + match self { + Self::Execution(body) => Some(body), + _ => None, + } + } + + /// Gateway mapping onto the frozen MCP error catalog. + pub fn into_error_body(self) -> ErrorBody { + match self { + Self::Execution(body) => body, + Self::TransportBeforeDispatch { message } => ErrorBody::new( + ErrorCode::Timeout, + format!("runner transport failed before dispatch: {message}"), + ), + Self::TransportAmbiguous { message } => ErrorBody::new( + ErrorCode::Timeout, + format!("runner transport result is ambiguous: {message}"), + ), + } + } +} + +impl From for RunnerError { + fn from(body: ErrorBody) -> Self { + Self::Execution(body) + } +} + +impl std::fmt::Display for RunnerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Execution(body) => write!(f, "{}", body.message), + Self::TransportBeforeDispatch { message } => { + write!(f, "runner transport failed before dispatch: {message}") + } + Self::TransportAmbiguous { message } => { + write!(f, "runner transport result is ambiguous: {message}") + } + } + } +} + +impl std::error::Error for RunnerError {} + #[cfg(test)] mod tests { use super::*; #[test] fn exec_request_has_no_work_id_field() { - let req = RunnerExecRequest { - argv: vec!["/bin/echo".into()], - process_id: ProcessId("proc-1".into()), - }; - let RunnerExecRequest { argv, process_id } = req; + let req = RunnerExecRequest::for_host( + vec!["/bin/echo".into()], + ProcessId("proc-1".into()), + Profile::WorkspaceWrite, + ); + let RunnerExecRequest { + argv, + process_id, + cwd, + env, + timeout_ms, + output_bytes_cap, + tty, + policy, + } = req; assert_eq!(argv, ["/bin/echo"]); assert_eq!(process_id.0, "proc-1"); + assert_eq!(cwd, RunnerCwd::WorkspaceRoot); + assert!(env.use_runner_defaults); + assert!(env.overrides.is_empty()); + assert!(timeout_ms > 0); + assert_eq!(output_bytes_cap, MAX_OUTPUT_BYTES as u64); + assert!(!tty); + assert_eq!(policy.workspace_profile, Profile::WorkspaceWrite); + assert_eq!(policy.network, NetworkAxis::Restricted); + let json = serde_json::to_value(RunnerExecRequest::for_host( + vec!["/bin/echo".into()], + ProcessId("proc-1".into()), + Profile::WorkspaceWrite, + )) + .unwrap(); + assert!(json.get("work_id").is_none()); + assert!(json.get("operation_id").is_none()); + assert_eq!(json["cwd"], "workspace_root"); + assert!(json["cwd"].as_str().is_some()); + assert!(json["env"].get("PATH").is_none()); + assert_eq!(json["env"]["use_runner_defaults"], true); + assert_eq!(json["env"]["overrides"], serde_json::json!({})); + let dumped = json.to_string(); + assert!( + !dumped.contains("/tmp/") && !dumped.contains("/Users/"), + "host absolute cwd must not be serialized: {dumped}" + ); } #[test] diff --git a/crates/runner/src/apply.rs b/crates/runner/src/apply.rs index 2c11fe9..bcddc0e 100644 --- a/crates/runner/src/apply.rs +++ b/crates/runner/src/apply.rs @@ -9,14 +9,17 @@ use crate::PathSandbox; impl InProcessRunner { pub fn read_file(&self, ws: &Workspace, path: &str) -> Result { + ws.require_host_execution()?; PathSandbox::new(ws.clone()).read_file(path) } pub fn find_files(&self, ws: &Workspace, glob: Option<&str>) -> Result { + ws.require_host_execution()?; PathSandbox::new(ws.clone()).find(glob) } pub fn file_version(&self, ws: &Workspace, path: &str) -> Result { + ws.require_host_execution()?; PathSandbox::new(ws.clone()).version(path) } @@ -25,6 +28,7 @@ impl InProcessRunner { ws: &Workspace, req: RunnerApplyPatchRequest, ) -> Result { + ws.require_host_execution()?; let sandbox = PathSandbox::new(ws.clone()); for (path, expected) in &req.expected_versions { let actual = sandbox.version(path)?; diff --git a/crates/runner/src/files.rs b/crates/runner/src/files.rs index 120de18..da13345 100644 --- a/crates/runner/src/files.rs +++ b/crates/runner/src/files.rs @@ -155,11 +155,11 @@ mod tests { use tempfile::tempdir; fn sandbox(dir: &Path) -> PathSandbox { - PathSandbox::new(Workspace { - id: WorkspaceId("demo".into()), - root: dir.to_path_buf(), - profile: Profile::ReadOnly, - }) + PathSandbox::new(Workspace::new( + WorkspaceId("demo".into()), + dir.to_path_buf(), + Profile::ReadOnly, + )) } #[test] diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index 5f5dc10..f9f2d44 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -1,8 +1,10 @@ -//! In-process `Runner`: path sandbox, one patch transaction, host process +//! `Runner` trait, host `InProcessRunner`, and opt-in Unix-socket +//! `UdsRunner`. Path sandbox, one patch transaction, host process //! supervisor, and isolation-fixture checks. Linux containers are the //! **target** execution OS. macOS hosts may run the path sandbox for unit //! tests; that does **not** verify Linux isolation. `exec_command` is not -//! dispatched into compose. +//! dispatched into compose. Default backend remains in-process. Host + +//! `UdsRunner` is the same host over UDS, not a Linux isolation claim. use std::fs; use std::os::unix::fs::FileTypeExt; @@ -71,83 +73,224 @@ mod patch_helper; mod patch_verify; mod process; mod rollback; +mod socket; +mod uds; +mod wire; pub use api::{ - RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerExecRequest, RunnerExecResult, - RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, + default_exec_timeout_ms, runner_local_exec_env, RunnerApplyPatchRequest, + RunnerApplyPatchResult, RunnerCwd, RunnerError, RunnerExecEnv, RunnerExecPolicy, + RunnerExecRequest, RunnerExecResult, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, + DEFAULT_TIMEOUT_MS, MAX_OUTPUT_BYTES, }; pub use files::{DEFAULT_FIND_LIMIT, DEFAULT_READ_LIMIT, VERSION_ABSENT}; pub use patch_helper::ensure_helper_for_tests; pub use process::{ InProcessRunner, RetentionPolicy, ShellRelease, DEFAULT_COMPLETED_TTL, DEFAULT_MAX_COMPLETED, - DEFAULT_MAX_PROCESSES, DEFAULT_TIMEOUT, MAX_OUTPUT_BYTES, + DEFAULT_MAX_PROCESSES, DEFAULT_TIMEOUT, +}; +pub use socket::{ + allocate_private_runner_dir, is_forbidden_runner_dir, reclaim_leftover_socket, + runner_socket_path, RUNNER_SOCKET_NAME, +}; +pub use uds::{DisconnectHook, UdsRunner, RUNNER_CALL_DEADLINE}; +pub use wire::{ + host_worker, read_frame, serve_runner_connection, write_frame, RunnerEvent, RunnerOp, + RunnerOpResult, WireEnvelope, WireKind, WIRE_PROTOCOL, }; /// Execution-plane API. Control-plane fields (`work_id`, coordination, /// `operation_id` / `operation_key`) stay in the gateway. pub trait Runner: Send + Sync { - fn read(&self, ws: &Workspace, path: &str) -> Result; - fn find(&self, ws: &Workspace, glob: Option<&str>) -> Result; - fn version(&self, ws: &Workspace, path: &str) -> Result; + fn read( + &self, + ws: &Workspace, + path: &str, + ) -> impl std::future::Future> + Send; + fn find( + &self, + ws: &Workspace, + glob: Option<&str>, + ) -> impl std::future::Future> + Send; + fn version( + &self, + ws: &Workspace, + path: &str, + ) -> impl std::future::Future> + Send; fn apply_patch( &self, ws: &Workspace, req: RunnerApplyPatchRequest, - ) -> impl std::future::Future> + Send; - fn exec(&self, ws: &Workspace, req: RunnerExecRequest) -> Result; + ) -> impl std::future::Future> + Send; + fn exec( + &self, + ws: &Workspace, + req: RunnerExecRequest, + ) -> impl std::future::Future> + Send; fn write_stdin( &self, req: RunnerWriteStdin, - ) -> impl std::future::Future> + Send; - fn read_process(&self, req: RunnerReadProcess) -> Result; - fn terminate(&self, process_id: &ProcessId) -> Result<(), ErrorBody>; - fn workspace_of(&self, process_id: &str) -> Option; - fn terminate_workspace(&self, workspace_id: &str) -> Result; + ) -> impl std::future::Future> + Send; + fn read_process( + &self, + req: RunnerReadProcess, + ) -> impl std::future::Future> + Send; + fn terminate( + &self, + process_id: &ProcessId, + ) -> impl std::future::Future> + Send; + fn workspace_of( + &self, + process_id: &str, + ) -> impl std::future::Future> + Send; + fn terminate_workspace( + &self, + workspace_id: &str, + ) -> impl std::future::Future> + Send; } impl Runner for InProcessRunner { - fn read(&self, ws: &Workspace, path: &str) -> Result { - self.read_file(ws, path) + async fn read(&self, ws: &Workspace, path: &str) -> Result { + self.read_file(ws, path).map_err(RunnerError::from) } - fn find(&self, ws: &Workspace, glob: Option<&str>) -> Result { - self.find_files(ws, glob) + async fn find(&self, ws: &Workspace, glob: Option<&str>) -> Result { + self.find_files(ws, glob).map_err(RunnerError::from) } - fn version(&self, ws: &Workspace, path: &str) -> Result { - self.file_version(ws, path) + async fn version(&self, ws: &Workspace, path: &str) -> Result { + self.file_version(ws, path).map_err(RunnerError::from) } async fn apply_patch( &self, ws: &Workspace, req: RunnerApplyPatchRequest, - ) -> Result { - self.apply_patch_txn(ws, req).await + ) -> Result { + self.apply_patch_txn(ws, req) + .await + .map_err(RunnerError::from) } - fn exec(&self, ws: &Workspace, req: RunnerExecRequest) -> Result { - self.spawn_host(ws, req) + async fn exec( + &self, + ws: &Workspace, + req: RunnerExecRequest, + ) -> Result { + self.spawn_host(ws, req).map_err(RunnerError::from) } - async fn write_stdin(&self, req: RunnerWriteStdin) -> Result<(), ErrorBody> { - self.write_host_stdin(req).await + async fn write_stdin(&self, req: RunnerWriteStdin) -> Result<(), RunnerError> { + self.write_host_stdin(req).await.map_err(RunnerError::from) } - fn read_process(&self, req: RunnerReadProcess) -> Result { - self.read_host_process(req) + async fn read_process(&self, req: RunnerReadProcess) -> Result { + self.read_host_process(req).map_err(RunnerError::from) } - fn terminate(&self, process_id: &ProcessId) -> Result<(), ErrorBody> { - self.kill_host(process_id) + async fn terminate(&self, process_id: &ProcessId) -> Result<(), RunnerError> { + self.kill_host(process_id).map_err(RunnerError::from) } - fn workspace_of(&self, process_id: &str) -> Option { + async fn workspace_of(&self, process_id: &str) -> Option { self.host_workspace_of(process_id) } - fn terminate_workspace(&self, workspace_id: &str) -> Result { + async fn terminate_workspace(&self, workspace_id: &str) -> Result { self.kill_host_workspace(workspace_id) + .map_err(RunnerError::from) + } +} + +#[derive(Clone)] +pub enum RuntimeBackend { + InProcess(InProcessRunner), + Uds(UdsRunner), +} + +impl RuntimeBackend { + pub fn in_process(on_release: ShellRelease) -> Self { + Self::InProcess(InProcessRunner::new(on_release)) + } +} + +impl Runner for RuntimeBackend { + async fn read(&self, ws: &Workspace, path: &str) -> Result { + match self { + Self::InProcess(runner) => runner.read(ws, path).await, + Self::Uds(runner) => runner.read(ws, path).await, + } + } + + async fn find(&self, ws: &Workspace, glob: Option<&str>) -> Result { + match self { + Self::InProcess(runner) => runner.find(ws, glob).await, + Self::Uds(runner) => runner.find(ws, glob).await, + } + } + + async fn version(&self, ws: &Workspace, path: &str) -> Result { + match self { + Self::InProcess(runner) => runner.version(ws, path).await, + Self::Uds(runner) => runner.version(ws, path).await, + } + } + + async fn apply_patch( + &self, + ws: &Workspace, + req: RunnerApplyPatchRequest, + ) -> Result { + match self { + Self::InProcess(runner) => runner.apply_patch(ws, req).await, + Self::Uds(runner) => runner.apply_patch(ws, req).await, + } + } + + async fn exec( + &self, + ws: &Workspace, + req: RunnerExecRequest, + ) -> Result { + match self { + Self::InProcess(runner) => runner.exec(ws, req).await, + Self::Uds(runner) => runner.exec(ws, req).await, + } + } + + async fn write_stdin(&self, req: RunnerWriteStdin) -> Result<(), RunnerError> { + match self { + Self::InProcess(runner) => runner.write_stdin(req).await, + Self::Uds(runner) => runner.write_stdin(req).await, + } + } + + async fn read_process(&self, req: RunnerReadProcess) -> Result { + match self { + Self::InProcess(runner) => runner.read_process(req).await, + Self::Uds(runner) => runner.read_process(req).await, + } + } + + async fn terminate(&self, process_id: &ProcessId) -> Result<(), RunnerError> { + match self { + Self::InProcess(runner) => runner.terminate(process_id).await, + Self::Uds(runner) => runner.terminate(process_id).await, + } + } + + async fn workspace_of(&self, process_id: &str) -> Option { + match self { + Self::InProcess(runner) => runner.workspace_of(process_id).await, + Self::Uds(runner) => runner.workspace_of(process_id).await, + } + } + + async fn terminate_workspace(&self, workspace_id: &str) -> Result { + match self { + Self::InProcess(runner) => runner.terminate_workspace(workspace_id).await, + Self::Uds(runner) => runner.terminate_workspace(workspace_id).await, + } } } @@ -182,11 +325,11 @@ mod tests { let root = dir.path(); std::fs::create_dir(root.join("ws")).unwrap(); std::os::unix::fs::symlink("/etc/passwd", root.join("ws").join("link")).unwrap(); - let sandbox = PathSandbox::new(Workspace { - id: WorkspaceId("demo".into()), - root: root.join("ws"), - profile: Profile::ReadOnly, - }); + let sandbox = PathSandbox::new(Workspace::new( + WorkspaceId("demo".into()), + root.join("ws"), + Profile::ReadOnly, + )); let err = sandbox.resolve("link").unwrap_err(); assert_eq!(err.code, ErrorCode::SymlinkRejected); } @@ -197,11 +340,11 @@ mod tests { let ws = dir.path().join("ws"); std::fs::create_dir(&ws).unwrap(); std::fs::write(ws.join("readme.txt"), "ok").unwrap(); - let sandbox = PathSandbox::new(Workspace { - id: WorkspaceId("demo".into()), - root: ws, - profile: Profile::ReadOnly, - }); + let sandbox = PathSandbox::new(Workspace::new( + WorkspaceId("demo".into()), + ws, + Profile::ReadOnly, + )); let path = sandbox.resolve("readme.txt").unwrap(); assert!(path.ends_with("readme.txt")); } diff --git a/crates/runner/src/patch_verify.rs b/crates/runner/src/patch_verify.rs index 8ff06e2..345ead8 100644 --- a/crates/runner/src/patch_verify.rs +++ b/crates/runner/src/patch_verify.rs @@ -95,11 +95,11 @@ mod tests { use tempfile::tempdir; fn sandbox(dir: &std::path::Path) -> PathSandbox { - PathSandbox::new(Workspace { - id: WorkspaceId("demo".into()), - root: dir.to_path_buf(), - profile: Profile::WorkspaceWrite, - }) + PathSandbox::new(Workspace::new( + WorkspaceId("demo".into()), + dir.to_path_buf(), + Profile::WorkspaceWrite, + )) } #[test] diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index debdb1b..5b1fcbc 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -1,5 +1,6 @@ //! Managed workspace processes. Request lifetime is not process lifetime. -//! Spawns host `tokio::process::Command`. Container dispatch is not wired. +//! Spawns host `tokio::process::Command`. UDS dispatch lives in +//! `UdsRunner`; this supervisor stays the host exec engine. use std::collections::HashMap; use std::process::Stdio; @@ -13,15 +14,16 @@ use tokio::process::{Child, ChildStdin, Command}; use tokio::task::JoinHandle; use crate::{ - RunnerExecRequest, RunnerExecResult, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, + runner_local_exec_env, RunnerCwd, RunnerExecRequest, RunnerExecResult, RunnerReadProcess, + RunnerReadResult, RunnerWriteStdin, }; -pub const MAX_OUTPUT_BYTES: usize = 256 * 1024; pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); pub const DEFAULT_MAX_PROCESSES: usize = 8; pub const DEFAULT_COMPLETED_TTL: Duration = Duration::from_secs(15 * 60); pub const DEFAULT_MAX_COMPLETED: usize = 64; +/// Called with a server-minted `process_id` when that process exits. pub type ShellRelease = Arc; #[derive(Debug, Clone)] @@ -39,15 +41,6 @@ impl Default for RetentionPolicy { } } -fn process_timeout() -> Duration { - std::env::var("CODESPACE_PROCESS_TIMEOUT_SECS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .filter(|secs| *secs > 0) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_TIMEOUT) -} - fn max_processes() -> usize { std::env::var("CODESPACE_MAX_PROCESSES") .ok() @@ -56,10 +49,6 @@ fn max_processes() -> usize { .unwrap_or(DEFAULT_MAX_PROCESSES) } -fn child_path() -> String { - std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin:/usr/sbin:/sbin".into()) -} - #[derive(Clone)] pub struct InProcessRunner { inner: Arc>>, @@ -128,26 +117,43 @@ impl InProcessRunner { ws: &Workspace, req: RunnerExecRequest, ) -> Result { + ws.require_host_execution()?; + if req.tty { + return Err(ErrorBody::new( + ErrorCode::Unauthorized, + "PTY is not implemented", + )); + } if req.argv.is_empty() || req.argv[0].is_empty() { return Err(ErrorBody::new( ErrorCode::InvalidPatch, "command must be a non-empty argv (no shell)", )); } + let cap = req.output_bytes_cap.max(1) as usize; + let timeout = Duration::from_millis(req.timeout_ms.max(1)); + let cwd = match req.cwd { + RunnerCwd::WorkspaceRoot => ws.root.clone(), + }; let mut child = Command::new(&req.argv[0]); if req.argv.len() > 1 { child.args(&req.argv[1..]); } child - .current_dir(&ws.root) + .current_dir(&cwd) .env_clear() - .env("PATH", child_path()) - .env("HOME", &ws.root) - .env("LANG", "C") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); + if req.env.use_runner_defaults { + for (key, value) in runner_local_exec_env(&cwd) { + child.env(key, value); + } + } + for (key, value) in &req.env.overrides { + child.env(key, value); + } let mut spawned = child .spawn() .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; @@ -179,17 +185,17 @@ impl InProcessRunner { let out_handle = stdout.map(|out| { let buf = output.clone(); - tokio::spawn(async move { pump_reader(out, buf).await }) + tokio::spawn(async move { pump_reader(out, buf, cap).await }) }); let err_handle = stderr.map(|err| { let buf = output.clone(); - tokio::spawn(async move { pump_reader(err, buf).await }) + tokio::spawn(async move { pump_reader(err, buf, cap).await }) }); let wait_child = child.clone(); let wait_out = output.clone(); let wait_release = self.on_release.clone(); - let wait_ws = ws.id.0.clone(); + let wait_process = req.process_id.0.clone(); tokio::spawn(async move { reap_child(wait_child).await; join_pump(out_handle).await; @@ -200,13 +206,13 @@ impl InProcessRunner { if let Ok(mut done) = completed_at.lock() { *done = Some(Instant::now()); } - wait_release(&wait_ws); + wait_release(&wait_process); }); let timeout_child = child; let timeout_out = output; tokio::spawn(async move { - tokio::time::sleep(process_timeout()).await; + tokio::time::sleep(timeout).await; let mut ch = timeout_child.lock().expect("child"); if ch.try_wait().ok().flatten().is_none() { let _ = ch.start_kill(); @@ -342,6 +348,7 @@ async fn join_pump(handle: Option>) { async fn pump_reader( mut reader: R, output: Arc>, + cap: usize, ) { let mut buf = [0u8; 4096]; loop { @@ -351,8 +358,8 @@ async fn pump_reader( let mut out = output.lock().expect("output"); out.total += n as u64; out.bytes.extend_from_slice(&buf[..n]); - if out.bytes.len() > MAX_OUTPUT_BYTES { - let extra = out.bytes.len() - MAX_OUTPUT_BYTES; + if out.bytes.len() > cap { + let extra = out.bytes.len() - cap; out.bytes.drain(..extra); out.dropped += extra as u64; } @@ -385,11 +392,11 @@ mod tests { use tempfile::tempdir; fn workspace(root: &std::path::Path) -> Workspace { - Workspace { - id: WorkspaceId("demo".into()), - root: root.to_path_buf(), - profile: Profile::WorkspaceWrite, - } + Workspace::new( + WorkspaceId("demo".into()), + root.to_path_buf(), + Profile::WorkspaceWrite, + ) } #[tokio::test] @@ -411,11 +418,13 @@ mod tests { runner .exec( &ws, - RunnerExecRequest { - argv: vec!["/bin/echo".into(), "hi".into()], - process_id: process_id.clone(), - }, + RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "hi".into()], + process_id.clone(), + codespace_domain::Profile::WorkspaceWrite, + ), ) + .await .unwrap(); for _ in 0..50 { if released.load(Ordering::SeqCst) >= 1 { @@ -429,6 +438,7 @@ mod tests { process_id: process_id.clone(), cursor: 0, }) + .await .unwrap(); tokio::time::sleep(Duration::from_millis(200)).await; let err = runner @@ -436,8 +446,12 @@ mod tests { process_id, cursor: 0, }) + .await .unwrap_err(); - assert_eq!(err.code, ErrorCode::ProcessNotFound); + assert_eq!( + err.as_execution().map(|body| body.code), + Some(ErrorCode::ProcessNotFound) + ); } #[tokio::test] @@ -456,11 +470,13 @@ mod tests { runner .exec( &ws, - RunnerExecRequest { - argv: vec!["/bin/echo".into(), "one".into()], - process_id: first.clone(), - }, + RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "one".into()], + first.clone(), + codespace_domain::Profile::WorkspaceWrite, + ), ) + .await .unwrap(); for _ in 0..50 { if runner @@ -468,6 +484,7 @@ mod tests { process_id: first.clone(), cursor: 0, }) + .await .unwrap() .eof { @@ -478,11 +495,13 @@ mod tests { runner .exec( &ws, - RunnerExecRequest { - argv: vec!["/bin/echo".into(), "two".into()], - process_id: second.clone(), - }, + RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "two".into()], + second.clone(), + codespace_domain::Profile::WorkspaceWrite, + ), ) + .await .unwrap(); for _ in 0..50 { if runner @@ -490,6 +509,7 @@ mod tests { process_id: second.clone(), cursor: 0, }) + .await .unwrap() .eof { @@ -498,16 +518,63 @@ mod tests { tokio::time::sleep(Duration::from_millis(20)).await; } // Trigger eviction of the oldest completed slot. - let _ = runner.read_process(RunnerReadProcess { - process_id: second, - cursor: 0, - }); + let _ = runner + .read_process(RunnerReadProcess { + process_id: second, + cursor: 0, + }) + .await; let err = runner .read_process(RunnerReadProcess { process_id: first, cursor: 0, }) + .await .unwrap_err(); - assert_eq!(err.code, ErrorCode::ProcessNotFound); + assert_eq!( + err.as_execution().map(|body| body.code), + Some(ErrorCode::ProcessNotFound) + ); + } + + #[tokio::test] + async fn linux_container_environment_is_closed_failure() { + let dir = tempdir().unwrap(); + let mut ws = workspace(dir.path()); + ws.environment_kind = codespace_policy::EnvironmentKind::LinuxContainer; + let runner = InProcessRunner::new(Arc::new(|_| {})); + let err = runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/echo".into()], + ProcessId("proc-box".into()), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap_err(); + assert_eq!( + err.as_execution().map(|body| body.code), + Some(ErrorCode::Unauthorized) + ); + } + + #[tokio::test] + async fn tty_exec_is_rejected() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let mut req = RunnerExecRequest::for_host( + vec!["/bin/echo".into()], + ProcessId("proc-tty".into()), + Profile::WorkspaceWrite, + ); + req.tty = true; + let err = runner.exec(&ws, req).await.unwrap_err(); + assert_eq!( + err.as_execution().map(|body| body.code), + Some(ErrorCode::Unauthorized) + ); } } diff --git a/crates/runner/src/rollback.rs b/crates/runner/src/rollback.rs index a52686c..14f644a 100644 --- a/crates/runner/src/rollback.rs +++ b/crates/runner/src/rollback.rs @@ -112,11 +112,11 @@ mod tests { use tempfile::tempdir; fn sandbox(dir: &std::path::Path) -> PathSandbox { - PathSandbox::new(Workspace { - id: WorkspaceId("demo".into()), - root: dir.to_path_buf(), - profile: Profile::WorkspaceWrite, - }) + PathSandbox::new(Workspace::new( + WorkspaceId("demo".into()), + dir.to_path_buf(), + Profile::WorkspaceWrite, + )) } #[test] diff --git a/crates/runner/src/socket.rs b/crates/runner/src/socket.rs new file mode 100644 index 0000000..b2af405 --- /dev/null +++ b/crates/runner/src/socket.rs @@ -0,0 +1,208 @@ +//! Private Unix-socket rendezvous. CodeSpace never chmods `/tmp` or +//! other preexisting directories: only a newly created 0700 leaf. + +use std::fs::DirBuilder; +use std::io::{Error, ErrorKind, Result}; +use std::os::unix::fs::DirBuilderExt; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const RUNNER_SOCKET_NAME: &str = "runner.sock"; + +static DIR_NONCE: AtomicU64 = AtomicU64::new(1); + +/// True when `path` is `/`, `/tmp`, `/var/tmp`, or `$HOME` itself. +/// A child directory under those bases is allowed. +pub fn is_forbidden_runner_dir(path: &Path) -> bool { + let requested = normalize_path(path); + forbidden_runner_bases() + .into_iter() + .any(|base| requested == base) +} + +pub fn runner_socket_path(dir: impl AsRef) -> PathBuf { + dir.as_ref().join(RUNNER_SOCKET_NAME) +} + +/// Create a unique 0700 leaf. Never chmods an existing directory. +/// +/// * `Some(base)` → `$base/run--/` (`base` must not be a +/// forbidden leaf). +/// * `None` → `$TMPDIR/codespace-runner--/`. +pub fn allocate_private_runner_dir(base: Option<&Path>) -> Result { + match base { + Some(base) => { + if is_forbidden_runner_dir(base) { + return Err(Error::new( + ErrorKind::InvalidInput, + format!( + "runner dir must not be /, /tmp, /var/tmp, or $HOME (got {})", + base.display() + ), + )); + } + if !base.exists() { + std::fs::create_dir_all(base)?; + } + create_unique_leaf(base, "run") + } + None => create_unique_leaf(&std::env::temp_dir(), "codespace-runner"), + } +} + +/// Connect-probe a rendezvous path. Live listeners are not unlinked. +/// `ConnectionRefused` leftover files are removed. Missing paths are ok. +pub fn reclaim_leftover_socket(path: &Path) -> Result<()> { + match UnixStream::connect(path) { + Ok(_live) => Err(Error::new( + ErrorKind::AddrInUse, + format!("runner socket is already in use at {}", path.display()), + )), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) if err.kind() == ErrorKind::ConnectionRefused => { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(remove_err) if remove_err.kind() == ErrorKind::NotFound => Ok(()), + Err(remove_err) => Err(remove_err), + } + } + Err(err) => { + if !path.exists() { + Ok(()) + } else { + Err(err) + } + } + } +} + +fn create_unique_leaf(parent: &Path, prefix: &str) -> Result { + let pid = std::process::id(); + for _ in 0..64 { + let nonce = DIR_NONCE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let name = format!("{prefix}-{pid}-{nanos:x}-{nonce:x}"); + let path = parent.join(name); + let mut builder = DirBuilder::new(); + builder.mode(0o700); + match builder.create(&path) { + Ok(()) => return Ok(path), + Err(err) if err.kind() == ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(Error::other("could not allocate a unique runner directory")) +} + +fn forbidden_runner_bases() -> Vec { + let mut bases = vec![ + PathBuf::from("/"), + PathBuf::from("/tmp"), + PathBuf::from("/var/tmp"), + ]; + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + bases.push(PathBuf::from(home)); + } + } + bases + .into_iter() + .map(|path| normalize_path(&path)) + .collect() +} + +fn normalize_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + #[test] + fn refuses_tmp_and_home_as_the_leaf() { + assert!(is_forbidden_runner_dir(Path::new("/"))); + assert!(is_forbidden_runner_dir(Path::new("/tmp"))); + assert!(is_forbidden_runner_dir(Path::new("/var/tmp"))); + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + assert!(is_forbidden_runner_dir(Path::new(&home))); + } + } + let tmp = tempfile::tempdir().unwrap(); + assert!(!is_forbidden_runner_dir(tmp.path())); + } + + #[test] + fn allocate_creates_0700_unique_leaf_without_chmod_parent() { + let parent = tempfile::tempdir().unwrap(); + std::fs::set_permissions(parent.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let leaf = allocate_private_runner_dir(Some(parent.path())).unwrap(); + let leaf_mode = std::fs::metadata(&leaf).unwrap().permissions().mode() & 0o777; + let parent_mode = std::fs::metadata(parent.path()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(leaf_mode, 0o700); + assert_eq!(parent_mode, 0o755); + assert!(leaf + .file_name() + .and_then(|n| n.to_str()) + .unwrap() + .starts_with("run-")); + assert_eq!( + runner_socket_path(&leaf).file_name().unwrap(), + RUNNER_SOCKET_NAME + ); + std::fs::remove_dir_all(&leaf).unwrap(); + } + + #[test] + fn allocate_rejects_forbidden_base() { + let err = allocate_private_runner_dir(Some(Path::new("/tmp"))).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + } + + #[test] + fn live_socket_is_not_unlinked() { + let dir = tempfile::tempdir().unwrap(); + let sock = runner_socket_path(dir.path()); + let listener = UnixListener::bind(&sock).unwrap(); + let err = reclaim_leftover_socket(&sock).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::AddrInUse); + assert!(sock.exists()); + drop(listener); + } + + #[test] + fn leftover_refused_socket_is_unlinked() { + let dir = tempfile::tempdir().unwrap(); + let sock = runner_socket_path(dir.path()); + let listener = UnixListener::bind(&sock).unwrap(); + drop(listener); + reclaim_leftover_socket(&sock).unwrap(); + assert!(!sock.exists()); + } + + #[test] + fn missing_socket_is_ok() { + let dir = tempfile::tempdir().unwrap(); + reclaim_leftover_socket(&dir.path().join("missing.sock")).unwrap(); + } +} diff --git a/crates/runner/src/uds.rs b/crates/runner/src/uds.rs new file mode 100644 index 0000000..c41295a --- /dev/null +++ b/crates/runner/src/uds.rs @@ -0,0 +1,364 @@ +//! Unix-socket Runner client. Codex types stay out of this crate. +//! +//! `Host` + `UdsRunner` is opt-in **transport** on the same host. It does +//! not claim Linux container isolation. P0 is 1:1: one gateway connection +//! per worker. Replay is connection-local. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use codespace_domain::{ErrorBody, ErrorCode, FindResult, ProcessId, ReadResult}; +use codespace_policy::Workspace; +use tokio::io::AsyncWriteExt; +use tokio::net::UnixStream; +use tokio::sync::{oneshot, Mutex}; +use tokio::time::timeout; + +use crate::wire::{ + read_frame, write_frame, RunnerEvent, RunnerOp, RunnerOpResult, WireEnvelope, WireKind, + WIRE_PROTOCOL, +}; +use crate::{ + Runner, RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerError, RunnerExecRequest, + RunnerExecResult, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, ShellRelease, +}; + +/// Transport deadline for one RPC. Longer than the default exec timeout +/// so `apply_patch` is not cut short; short enough to fail a hung +/// mismatch instead of waiting forever. +pub const RUNNER_CALL_DEADLINE: Duration = Duration::from_secs(120); + +pub type DisconnectHook = Arc; + +struct Shared { + writer: Mutex, + pending: Mutex>>>, + alive: AtomicBool, + on_disconnect: DisconnectHook, +} + +pub struct UdsRunner { + shared: Arc, + next_id: Arc, +} + +impl Clone for UdsRunner { + fn clone(&self) -> Self { + Self { + shared: self.shared.clone(), + next_id: self.next_id.clone(), + } + } +} + +impl UdsRunner { + pub async fn connect( + path: impl AsRef, + on_process_exit: ShellRelease, + ) -> Result { + Self::connect_with_disconnect(path, on_process_exit, Arc::new(|| {})).await + } + + pub async fn connect_with_disconnect( + path: impl AsRef, + on_process_exit: ShellRelease, + on_disconnect: DisconnectHook, + ) -> Result { + let stream = UnixStream::connect(path.as_ref()) + .await + .map_err(|err| RunnerError::before_dispatch(err.to_string()))?; + let runner = Self::from_stream_with_disconnect(stream, on_process_exit, on_disconnect); + runner.handshake().await?; + Ok(runner) + } + + pub fn from_stream(stream: UnixStream, on_process_exit: ShellRelease) -> Self { + Self::from_stream_with_disconnect(stream, on_process_exit, Arc::new(|| {})) + } + + pub fn from_stream_with_disconnect( + stream: UnixStream, + on_process_exit: ShellRelease, + on_disconnect: DisconnectHook, + ) -> Self { + let (read, write) = stream.into_split(); + let shared = Arc::new(Shared { + writer: Mutex::new(write), + pending: Mutex::new(HashMap::new()), + alive: AtomicBool::new(true), + on_disconnect, + }); + let reader_shared = shared.clone(); + tokio::spawn(async move { + read_loop(read, reader_shared, on_process_exit).await; + }); + Self { + shared, + next_id: Arc::new(AtomicU64::new(1)), + } + } + + pub async fn handshake(&self) -> Result<(), RunnerError> { + match self.call(RunnerOp::Hello).await { + Ok(RunnerOpResult::Hello { protocol }) if protocol == WIRE_PROTOCOL => Ok(()), + Ok(other) => { + self.close().await; + Err(RunnerError::before_dispatch(format!( + "unexpected runner hello result: {other:?}" + ))) + } + Err(RunnerError::TransportBeforeDispatch { message }) => { + Err(RunnerError::before_dispatch(message)) + } + Err(RunnerError::TransportAmbiguous { message }) => { + self.close().await; + Err(RunnerError::before_dispatch(message)) + } + Err(RunnerError::Execution(body)) => { + self.close().await; + Err(RunnerError::before_dispatch(body.message)) + } + } + } + + pub async fn close(&self) { + self.shared.alive.store(false, Ordering::SeqCst); + { + let mut writer = self.shared.writer.lock().await; + let _ = writer.shutdown().await; + } + fail_pending(&self.shared, RunnerError::ambiguous("runner socket closed")).await; + } + + async fn call(&self, op: RunnerOp) -> Result { + if !self.shared.alive.load(Ordering::SeqCst) { + return Err(RunnerError::before_dispatch("runner socket closed")); + } + let request_id = format!("rrpc-{}", self.next_id.fetch_add(1, Ordering::SeqCst)); + let (tx, rx) = oneshot::channel(); + { + self.shared + .pending + .lock() + .await + .insert(request_id.clone(), tx); + } + let envelope = WireEnvelope::request(request_id.clone(), op); + { + let mut writer = self.shared.writer.lock().await; + if let Err(err) = write_frame(&mut *writer, &envelope).await { + self.shared.pending.lock().await.remove(&request_id); + return Err(RunnerError::before_dispatch(err.to_string())); + } + } + match timeout(RUNNER_CALL_DEADLINE, rx).await { + Ok(Ok(Ok(response))) => decode_response(response), + Ok(Ok(Err(err))) => Err(err), + Ok(Err(_)) => Err(RunnerError::ambiguous("runner socket closed")), + Err(_) => { + self.close().await; + Err(RunnerError::ambiguous("runner call deadline exceeded")) + } + } + } +} + +async fn read_loop( + mut read: tokio::net::unix::OwnedReadHalf, + shared: Arc, + on_process_exit: ShellRelease, +) { + loop { + match read_frame(&mut read).await { + Ok(Some(payload)) => match serde_json::from_slice::(&payload) { + Ok(envelope) => match envelope.kind { + WireKind::Event => { + if let Some(RunnerEvent::ProcessExited { process_id }) = envelope.event { + on_process_exit(&process_id.0); + } + } + WireKind::Response => { + if let Some(id) = envelope.request_id.clone() { + if let Some(tx) = shared.pending.lock().await.remove(&id) { + let _ = tx.send(Ok(envelope)); + } + } + } + WireKind::Request => {} + }, + Err(err) => { + fail_pending( + &shared, + RunnerError::ambiguous(format!("invalid runner rpc: {err}")), + ) + .await; + break; + } + }, + Ok(None) | Err(_) => { + fail_pending(&shared, RunnerError::ambiguous("runner socket closed")).await; + break; + } + } + } + shared.alive.store(false, Ordering::SeqCst); + (shared.on_disconnect)(); +} + +async fn fail_pending(shared: &Shared, err: RunnerError) { + let mut pending = shared.pending.lock().await; + for (_, tx) in pending.drain() { + let _ = tx.send(Err(err.clone())); + } +} + +fn decode_response(response: WireEnvelope) -> Result { + if response.ok == Some(true) { + response.result.ok_or_else(|| { + RunnerError::execution(ErrorBody::new( + ErrorCode::InvalidPatch, + "runner rpc missing result", + )) + }) + } else { + Err(RunnerError::execution(response.error.unwrap_or_else( + || ErrorBody::new(ErrorCode::InvalidPatch, "runner rpc failed"), + ))) + } +} + +fn unexpected(result: RunnerOpResult) -> RunnerError { + RunnerError::execution(ErrorBody::new( + ErrorCode::InvalidPatch, + format!("unexpected runner rpc result: {result:?}"), + )) +} + +impl Runner for UdsRunner { + async fn read(&self, ws: &Workspace, path: &str) -> Result { + match self + .call(RunnerOp::Read { + workspace: ws.clone(), + path: path.to_string(), + }) + .await? + { + RunnerOpResult::Read(result) => Ok(result), + other => Err(unexpected(other)), + } + } + + async fn find(&self, ws: &Workspace, glob: Option<&str>) -> Result { + match self + .call(RunnerOp::Find { + workspace: ws.clone(), + glob: glob.map(str::to_string), + }) + .await? + { + RunnerOpResult::Find(result) => Ok(result), + other => Err(unexpected(other)), + } + } + + async fn version(&self, ws: &Workspace, path: &str) -> Result { + match self + .call(RunnerOp::Version { + workspace: ws.clone(), + path: path.to_string(), + }) + .await? + { + RunnerOpResult::Version(result) => Ok(result), + other => Err(unexpected(other)), + } + } + + async fn apply_patch( + &self, + ws: &Workspace, + req: RunnerApplyPatchRequest, + ) -> Result { + match self + .call(RunnerOp::ApplyPatch { + workspace: ws.clone(), + request: req, + }) + .await? + { + RunnerOpResult::ApplyPatch(result) => Ok(result), + other => Err(unexpected(other)), + } + } + + async fn exec( + &self, + ws: &Workspace, + req: RunnerExecRequest, + ) -> Result { + match self + .call(RunnerOp::Exec { + workspace: ws.clone(), + request: req, + }) + .await? + { + RunnerOpResult::Exec(result) => Ok(result), + other => Err(unexpected(other)), + } + } + + async fn write_stdin(&self, req: RunnerWriteStdin) -> Result<(), RunnerError> { + match self.call(RunnerOp::WriteStdin { request: req }).await? { + RunnerOpResult::WriteStdin => Ok(()), + other => Err(unexpected(other)), + } + } + + async fn read_process(&self, req: RunnerReadProcess) -> Result { + match self.call(RunnerOp::ReadProcess { request: req }).await? { + RunnerOpResult::ReadProcess(result) => Ok(result), + other => Err(unexpected(other)), + } + } + + async fn terminate(&self, process_id: &ProcessId) -> Result<(), RunnerError> { + match self + .call(RunnerOp::Terminate { + process_id: process_id.clone(), + }) + .await? + { + RunnerOpResult::Terminate => Ok(()), + other => Err(unexpected(other)), + } + } + + async fn workspace_of(&self, process_id: &str) -> Option { + match self + .call(RunnerOp::WorkspaceOf { + process_id: process_id.to_string(), + }) + .await + .ok()? + { + RunnerOpResult::WorkspaceOf(value) => value, + _ => None, + } + } + + async fn terminate_workspace(&self, workspace_id: &str) -> Result { + match self + .call(RunnerOp::TerminateWorkspace { + workspace_id: workspace_id.to_string(), + }) + .await? + { + RunnerOpResult::TerminateWorkspace(count) => Ok(count), + other => Err(unexpected(other)), + } + } +} diff --git a/crates/runner/src/wire.rs b/crates/runner/src/wire.rs new file mode 100644 index 0000000..97266b6 --- /dev/null +++ b/crates/runner/src/wire.rs @@ -0,0 +1,499 @@ +//! CodeSpace-owned runner RPC. Not App Server and not MCP. +//! +//! Framing is `u32` big-endian length + JSON. Kinds share one stream: +//! request, response, and event. `request_id` (`rrpc-…`) is not an HTTP +//! id, `operation_id`, `operation_key`, or `process_id`. + +use std::collections::VecDeque; +use std::sync::Arc; + +use codespace_domain::{ErrorBody, ErrorCode, FindResult, ProcessId, ReadResult}; +use codespace_policy::Workspace; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::{mpsc, Mutex}; + +use crate::{ + InProcessRunner, Runner, RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerError, + RunnerExecRequest, RunnerExecResult, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, + ShellRelease, +}; + +pub const WIRE_PROTOCOL: u32 = 1; +const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; +const MAX_REPLAY: usize = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireKind { + Request, + Response, + Event, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireEnvelope { + pub protocol: u32, + pub kind: WireKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub op: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ok: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event: Option, +} + +impl WireEnvelope { + pub fn request(request_id: String, op: RunnerOp) -> Self { + Self { + protocol: WIRE_PROTOCOL, + kind: WireKind::Request, + request_id: Some(request_id), + op: Some(op), + ok: None, + result: None, + error: None, + event: None, + } + } + + pub fn response(request_id: String, result: Result) -> Self { + match result { + Ok(value) => Self { + protocol: WIRE_PROTOCOL, + kind: WireKind::Response, + request_id: Some(request_id), + op: None, + ok: Some(true), + result: Some(value), + error: None, + event: None, + }, + Err(error) => Self { + protocol: WIRE_PROTOCOL, + kind: WireKind::Response, + request_id: Some(request_id), + op: None, + ok: Some(false), + result: None, + error: Some(error), + event: None, + }, + } + } + + pub fn event(event: RunnerEvent) -> Self { + Self { + protocol: WIRE_PROTOCOL, + kind: WireKind::Event, + request_id: None, + op: None, + ok: None, + result: None, + error: None, + event: Some(event), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum RunnerEvent { + ProcessExited { process_id: ProcessId }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", content = "data", rename_all = "snake_case")] +pub enum RunnerOp { + Read { + workspace: Workspace, + path: String, + }, + Find { + workspace: Workspace, + glob: Option, + }, + Version { + workspace: Workspace, + path: String, + }, + ApplyPatch { + workspace: Workspace, + request: RunnerApplyPatchRequest, + }, + Exec { + workspace: Workspace, + request: RunnerExecRequest, + }, + WriteStdin { + request: RunnerWriteStdin, + }, + ReadProcess { + request: RunnerReadProcess, + }, + Terminate { + process_id: ProcessId, + }, + WorkspaceOf { + process_id: String, + }, + TerminateWorkspace { + workspace_id: String, + }, + Replay { + request_id: String, + }, + Hello, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum RunnerOpResult { + Read(ReadResult), + Find(FindResult), + Version(String), + ApplyPatch(RunnerApplyPatchResult), + Exec(RunnerExecResult), + WriteStdin, + ReadProcess(RunnerReadResult), + Terminate, + WorkspaceOf(Option), + TerminateWorkspace(u32), + Hello { protocol: u32 }, +} + +/// Host worker: `InProcessRunner` plus a `ProcessExited` event stream. +pub fn host_worker() -> (InProcessRunner, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + let on_release: ShellRelease = Arc::new(move |process_id: &str| { + let _ = tx.send(RunnerEvent::ProcessExited { + process_id: ProcessId(process_id.to_string()), + }); + }); + (InProcessRunner::new(on_release), rx) +} + +pub async fn serve_runner_connection( + stream: S, + runner: InProcessRunner, + mut events: mpsc::UnboundedReceiver, +) -> Result<(), std::io::Error> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (mut read, write) = tokio::io::split(stream); + let write = Arc::new(Mutex::new(write)); + let event_write = write.clone(); + tokio::spawn(async move { + while let Some(event) = events.recv().await { + let mut writer = event_write.lock().await; + if write_frame(&mut *writer, &WireEnvelope::event(event)) + .await + .is_err() + { + break; + } + } + }); + + let mut cache: VecDeque<(String, WireEnvelope)> = VecDeque::new(); + loop { + let payload = match read_frame(&mut read).await? { + Some(payload) => payload, + None => return Ok(()), + }; + let request: WireEnvelope = match serde_json::from_slice(&payload) { + Ok(request) => request, + Err(err) => { + let response = WireEnvelope::response( + String::new(), + Err(ErrorBody::new( + ErrorCode::InvalidPatch, + format!("invalid runner rpc: {err}"), + )), + ); + let mut writer = write.lock().await; + write_frame(&mut *writer, &response).await?; + return Ok(()); + } + }; + if request.protocol != WIRE_PROTOCOL || request.kind != WireKind::Request { + let response = WireEnvelope::response( + request.request_id.clone().unwrap_or_default(), + Err(ErrorBody::new( + ErrorCode::InvalidPatch, + "runner rpc protocol mismatch", + )), + ); + let mut writer = write.lock().await; + write_frame(&mut *writer, &response).await?; + return Ok(()); + } + let request_id = request.request_id.clone().unwrap_or_default(); + let response = match request.op { + Some(RunnerOp::Replay { + request_id: original, + }) => cache + .iter() + .find(|(id, _)| id == &original) + .map(|(_, cached)| { + let mut replayed = cached.clone(); + replayed.request_id = Some(request_id.clone()); + replayed + }) + .unwrap_or_else(|| { + WireEnvelope::response( + request_id.clone(), + Err(ErrorBody::new( + ErrorCode::InvalidPatch, + format!("unknown runner request_id `{original}`"), + )), + ) + }), + Some(op) => { + let dispatched = dispatch(&runner, op).await; + let envelope = WireEnvelope::response(request_id.clone(), dispatched); + if !request_id.is_empty() { + if cache.len() >= MAX_REPLAY { + cache.pop_front(); + } + cache.push_back((request_id, envelope.clone())); + } + envelope + } + None => WireEnvelope::response( + request_id, + Err(ErrorBody::new( + ErrorCode::InvalidPatch, + "runner rpc missing op", + )), + ), + }; + let mut writer = write.lock().await; + write_frame(&mut *writer, &response).await?; + } +} + +pub async fn write_frame( + write: &mut W, + envelope: &WireEnvelope, +) -> Result<(), std::io::Error> { + let payload = serde_json::to_vec(envelope).map_err(std::io::Error::other)?; + let len = u32::try_from(payload.len()).map_err(std::io::Error::other)?; + write.write_all(&len.to_be_bytes()).await?; + write.write_all(&payload).await?; + write.flush().await +} + +pub async fn read_frame( + read: &mut R, +) -> Result>, std::io::Error> { + let mut len_buf = [0u8; 4]; + match read.read_exact(&mut len_buf).await { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(err) => return Err(err), + } + let len = u32::from_be_bytes(len_buf) as usize; + if len == 0 || len > MAX_FRAME_BYTES { + return Err(std::io::Error::other(format!( + "invalid runner frame length {len}" + ))); + } + let mut payload = vec![0u8; len]; + read.read_exact(&mut payload).await?; + Ok(Some(payload)) +} + +async fn dispatch(runner: &InProcessRunner, op: RunnerOp) -> Result { + let result = match op { + RunnerOp::Read { workspace, path } => runner + .read(&workspace, &path) + .await + .map(RunnerOpResult::Read), + RunnerOp::Find { workspace, glob } => runner + .find(&workspace, glob.as_deref()) + .await + .map(RunnerOpResult::Find), + RunnerOp::Version { workspace, path } => runner + .version(&workspace, &path) + .await + .map(RunnerOpResult::Version), + RunnerOp::ApplyPatch { workspace, request } => runner + .apply_patch(&workspace, request) + .await + .map(RunnerOpResult::ApplyPatch), + RunnerOp::Exec { workspace, request } => runner + .exec(&workspace, request) + .await + .map(RunnerOpResult::Exec), + RunnerOp::WriteStdin { request } => runner + .write_stdin(request) + .await + .map(|_| RunnerOpResult::WriteStdin), + RunnerOp::ReadProcess { request } => runner + .read_process(request) + .await + .map(RunnerOpResult::ReadProcess), + RunnerOp::Terminate { process_id } => runner + .terminate(&process_id) + .await + .map(|_| RunnerOpResult::Terminate), + RunnerOp::WorkspaceOf { process_id } => Ok(RunnerOpResult::WorkspaceOf( + runner.workspace_of(&process_id).await, + )), + RunnerOp::TerminateWorkspace { workspace_id } => runner + .terminate_workspace(&workspace_id) + .await + .map(RunnerOpResult::TerminateWorkspace), + RunnerOp::Hello => Ok(RunnerOpResult::Hello { + protocol: WIRE_PROTOCOL, + }), + RunnerOp::Replay { .. } => unreachable!("replay is handled before dispatch"), + }; + result.map_err(RunnerError::into_error_body) +} + +#[cfg(test)] +mod tests { + use super::*; + use codespace_policy::Workspace; + + #[test] + fn request_id_prefix_is_rrpc() { + assert!(format!("rrpc-{}", 1).starts_with("rrpc-")); + } + + #[tokio::test] + async fn length_prefix_round_trip() { + let (client, server) = tokio::io::duplex(4096); + let (mut client_read, mut client_write) = tokio::io::split(client); + let (mut server_read, mut server_write) = tokio::io::split(server); + let envelope = WireEnvelope::request( + "rrpc-1".into(), + RunnerOp::Terminate { + process_id: ProcessId("proc-1".into()), + }, + ); + write_frame(&mut client_write, &envelope).await.unwrap(); + let payload = read_frame(&mut server_read).await.unwrap().unwrap(); + let parsed: WireEnvelope = serde_json::from_slice(&payload).unwrap(); + assert_eq!(parsed.protocol, WIRE_PROTOCOL); + assert_eq!(parsed.kind, WireKind::Request); + assert_eq!(parsed.request_id.as_deref(), Some("rrpc-1")); + write_frame( + &mut server_write, + &WireEnvelope::response("rrpc-1".into(), Ok(RunnerOpResult::Terminate)), + ) + .await + .unwrap(); + let reply = read_frame(&mut client_read).await.unwrap().unwrap(); + let parsed: WireEnvelope = serde_json::from_slice(&reply).unwrap(); + assert_eq!(parsed.kind, WireKind::Response); + assert_eq!(parsed.ok, Some(true)); + } + + #[tokio::test] + async fn replay_returns_cached_response() { + let (client, server) = tokio::net::UnixStream::pair().unwrap(); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve"); + }); + let (mut read, mut write) = client.into_split(); + let dir = tempfile::tempdir().unwrap(); + let ws = Workspace::new( + codespace_domain::WorkspaceId("demo".into()), + dir.path().to_path_buf(), + codespace_domain::Profile::ReadOnly, + ); + std::fs::write(dir.path().join("a.txt"), "cached").unwrap(); + write_frame( + &mut write, + &WireEnvelope::request( + "rrpc-orig".into(), + RunnerOp::Read { + workspace: ws.clone(), + path: "a.txt".into(), + }, + ), + ) + .await + .unwrap(); + let first = read_frame(&mut read).await.unwrap().unwrap(); + let first: WireEnvelope = serde_json::from_slice(&first).unwrap(); + assert_eq!(first.ok, Some(true)); + write_frame( + &mut write, + &WireEnvelope::request( + "rrpc-replay".into(), + RunnerOp::Replay { + request_id: "rrpc-orig".into(), + }, + ), + ) + .await + .unwrap(); + let second = read_frame(&mut read).await.unwrap().unwrap(); + let second: WireEnvelope = serde_json::from_slice(&second).unwrap(); + assert_eq!(second.request_id.as_deref(), Some("rrpc-replay")); + assert_eq!( + serde_json::to_value(&second.result).unwrap(), + serde_json::to_value(&first.result).unwrap() + ); + } + + #[tokio::test] + async fn protocol_mismatch_responds_then_closes() { + let (client, server) = tokio::net::UnixStream::pair().unwrap(); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve"); + }); + let (mut read, mut write) = client.into_split(); + let mut envelope = WireEnvelope::request("rrpc-bad".into(), RunnerOp::Hello); + envelope.protocol = 99; + write_frame(&mut write, &envelope).await.unwrap(); + let reply = read_frame(&mut read).await.unwrap().unwrap(); + let parsed: WireEnvelope = serde_json::from_slice(&reply).unwrap(); + assert_eq!(parsed.ok, Some(false)); + assert!(parsed.error.is_some()); + let eof = read_frame(&mut read).await.unwrap(); + assert!(eof.is_none()); + } + + #[tokio::test] + async fn hello_round_trip() { + let (client, server) = tokio::net::UnixStream::pair().unwrap(); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve"); + }); + let (mut read, mut write) = client.into_split(); + write_frame( + &mut write, + &WireEnvelope::request("rrpc-hello".into(), RunnerOp::Hello), + ) + .await + .unwrap(); + let reply = read_frame(&mut read).await.unwrap().unwrap(); + let parsed: WireEnvelope = serde_json::from_slice(&reply).unwrap(); + assert_eq!(parsed.ok, Some(true)); + match parsed.result { + Some(RunnerOpResult::Hello { protocol }) => assert_eq!(protocol, WIRE_PROTOCOL), + other => panic!("unexpected {other:?}"), + } + } +} diff --git a/crates/runner/tests/uds_runner.rs b/crates/runner/tests/uds_runner.rs new file mode 100644 index 0000000..2c22489 --- /dev/null +++ b/crates/runner/tests/uds_runner.rs @@ -0,0 +1,230 @@ +//! UdsRunner talks length-prefixed CodeSpace JSON over a Unix stream pair. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use codespace_domain::{ProcessId, Profile, WorkspaceId}; +use codespace_policy::Workspace; +use codespace_runner::{ + host_worker, serve_runner_connection, Runner, RunnerError, RunnerExecRequest, UdsRunner, +}; +use tempfile::tempdir; +use tokio::io::AsyncReadExt; +use tokio::net::UnixStream; + +fn workspace(root: &std::path::Path) -> Workspace { + Workspace::new( + WorkspaceId("demo".into()), + root.to_path_buf(), + Profile::WorkspaceWrite, + ) +} + +async fn drop_after_one_frame(stream: UnixStream) { + let (mut read, _write) = stream.into_split(); + let mut len_buf = [0u8; 4]; + if read.read_exact(&mut len_buf).await.is_err() { + return; + } + let len = u32::from_be_bytes(len_buf) as usize; + let mut payload = vec![0u8; len]; + let _ = read.read_exact(&mut payload).await; +} + +#[tokio::test] +async fn hello_handshake_succeeds() { + let (client, server) = UnixStream::pair().expect("unix pair"); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve runner"); + }); + let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); + runner.handshake().await.expect("hello"); +} + +#[tokio::test] +async fn uds_runner_read_and_exec_over_length_prefix() { + let (client, server) = UnixStream::pair().expect("unix pair"); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve runner"); + }); + let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "hi").unwrap(); + let ws = workspace(dir.path()); + let read = runner.read(&ws, "a.txt").await.unwrap(); + assert_eq!(read.content, "hi"); + + let process_id = ProcessId("proc-uds".into()); + runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "ok".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap(); + let mut chunk = String::new(); + for _ in 0..50 { + let result = runner + .read_process(codespace_runner::RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + chunk = result.chunk; + if result.eof { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert!(chunk.contains("ok"), "chunk={chunk:?}"); + + std::env::set_var( + "CODESPACE_PATCH_BIN", + codespace_runner::ensure_helper_for_tests(), + ); + let applied = runner + .apply_patch( + &ws, + codespace_runner::RunnerApplyPatchRequest { + patch: "*** Begin Patch\n*** Add File: b.txt\n+hello\n*** End Patch\n".into(), + expected_versions: Default::default(), + check_only: false, + }, + ) + .await + .unwrap(); + assert_eq!(applied.status, codespace_domain::PatchStatus::Applied); + assert_eq!( + std::fs::read_to_string(dir.path().join("b.txt")).unwrap(), + "hello\n" + ); +} + +#[tokio::test] +async fn uds_apply_patch_lost_response_is_ambiguous() { + let (client, server) = UnixStream::pair().expect("unix pair"); + tokio::spawn(async move { + drop_after_one_frame(server).await; + }); + let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let err = runner + .apply_patch( + &ws, + codespace_runner::RunnerApplyPatchRequest { + patch: "*** Begin Patch\n*** Add File: lost.txt\n+x\n*** End Patch\n".into(), + expected_versions: Default::default(), + check_only: false, + }, + ) + .await + .unwrap_err(); + assert!( + matches!(err, RunnerError::TransportAmbiguous { .. }), + "{err:?}" + ); +} + +#[tokio::test] +async fn uds_exec_lost_response_is_ambiguous() { + let (client, server) = UnixStream::pair().expect("unix pair"); + tokio::spawn(async move { + drop_after_one_frame(server).await; + }); + let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let err = runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "nope".into()], + ProcessId("proc-lost".into()), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap_err(); + assert!( + matches!(err, RunnerError::TransportAmbiguous { .. }), + "{err:?}" + ); +} + +#[tokio::test] +async fn process_exited_event_reaches_gateway_callback() { + let (client, server) = UnixStream::pair().expect("unix pair"); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve runner"); + }); + let hits = Arc::new(AtomicUsize::new(0)); + let flag = hits.clone(); + let runner = UdsRunner::from_stream( + client, + Arc::new(move |process_id: &str| { + if process_id == "proc-exit" { + flag.fetch_add(1, Ordering::SeqCst); + } + }), + ); + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "done".into()], + ProcessId("proc-exit".into()), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap(); + for _ in 0..50 { + if hits.load(Ordering::SeqCst) >= 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert!(hits.load(Ordering::SeqCst) >= 1); +} + +#[tokio::test] +async fn closed_socket_before_call_is_before_dispatch() { + let (client, server) = UnixStream::pair().expect("unix pair"); + drop(server); + let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let err = runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/echo".into()], + ProcessId("proc-closed".into()), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap_err(); + assert!( + matches!(err, RunnerError::TransportBeforeDispatch { .. }), + "{err:?}" + ); +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index edb88ee..d142ab6 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -28,7 +28,7 @@ codespace-policy = { path = "../policy" } codespace-runner = { path = "../runner" } codespace-store = { path = "../store" } rmcp = { workspace = true } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "io-std", "io-util", "net", "time"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "io-std", "io-util", "net", "time", "process", "sync"] } tokio-util = "0.7" serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 32d31c7..8389993 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -41,6 +41,26 @@ pub struct Cli { /// SQLite file for operations. Unset uses an in-memory database (no replay across restarts). #[arg(long, env = "CODESPACE_OPERATIONS_DB")] pub operations_db: Option, + + /// Runner backend. `in-process` is the default host supervisor. `uds` uses UdsRunner. + #[arg(long, env = "CODESPACE_RUNNER", default_value = "in-process")] + pub runner: String, + + /// Unix socket for `CODESPACE_RUNNER=uds` when connecting to an + /// already-running worker. Spawn path uses `--runner-dir` instead + /// and always binds `$dir/runner.sock`. + #[arg(long, env = "CODESPACE_RUNNER_SOCKET")] + pub runner_socket: Option, + + /// Base directory for a spawned UDS worker. CodeSpace creates a + /// unique `run--/` child (mode 0700) under this path. + /// `/`, `/tmp`, `/var/tmp`, and `$HOME` are rejected as this value. + #[arg(long, env = "CODESPACE_RUNNER_DIR")] + pub runner_dir: Option, + + /// `codespace-codex-runtime` binary for uds mode. + #[arg(long, env = "CODESPACE_RUNTIME_BIN")] + pub runtime_bin: Option, } impl Cli { diff --git a/crates/server/src/http.rs b/crates/server/src/http.rs index 3e6f57c..6a2fc99 100644 --- a/crates/server/src/http.rs +++ b/crates/server/src/http.rs @@ -5,6 +5,7 @@ use axum::middleware::{self, Next}; use axum::response::Response; use axum::Router; use codespace_policy::Registry; +use codespace_runner::RuntimeBackend; use codespace_store::Store; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; @@ -40,6 +41,28 @@ pub fn http_router( registry: Registry, store: Arc, cancel: CancellationToken, +) -> Router { + // Clone one handler so HTTP sessions share the runner. Request and + // session end are not process death; process_id stays server-minted. + let handler = CodeSpace::with_store(registry, store); + finish_http_router(config, handler, cancel) +} + +pub fn http_router_with_runner( + config: &HttpConfig, + registry: Registry, + store: Arc, + cancel: CancellationToken, + runner: RuntimeBackend, +) -> Router { + let handler = CodeSpace::with_store_and_runner(registry, store, runner); + finish_http_router(config, handler, cancel) +} + +fn finish_http_router( + config: &HttpConfig, + handler: CodeSpace, + cancel: CancellationToken, ) -> Router { let mut allowed_hosts = vec![ "localhost".into(), @@ -52,9 +75,6 @@ pub fn http_router( allowed_hosts.sort(); allowed_hosts.dedup(); - // Clone one handler so HTTP sessions share the runner. Request and - // session end are not process death; process_id stays server-minted. - let handler = CodeSpace::with_store(registry, store); let inbox = crate::inbox::router(handler.clone()); let service = StreamableHttpService::new( move || Ok(handler.clone()), @@ -76,11 +96,12 @@ pub async fn serve_http( config: HttpConfig, registry: Registry, store: Arc, + runner: RuntimeBackend, ) -> anyhow::Result<(std::net::SocketAddr, CancellationToken)> { let addr = format!("{}:{}", config.host, config.port); let cancel = CancellationToken::new(); let bearer_required = config.bearer_token.is_some(); - let router = http_router(&config, registry, store, cancel.clone()); + let router = http_router_with_runner(&config, registry, store, cancel.clone(), runner); let listener = tokio::net::TcpListener::bind(&addr).await?; let bound = listener.local_addr()?; let child = cancel.clone(); diff --git a/crates/server/src/inbox.rs b/crates/server/src/inbox.rs index 48e8e87..b05eec5 100644 --- a/crates/server/src/inbox.rs +++ b/crates/server/src/inbox.rs @@ -191,7 +191,8 @@ async fn stop_work( let killed = handler .runner .terminate_workspace(&work.workspace_id.0) - .map_err(InboxError)?; + .await + .map_err(|err| InboxError(err.into_error_body()))?; let reason = body.and_then(|Json(b)| b.reason).unwrap_or_default(); let intent = handler .store diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 8256d10..6bcb092 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -8,6 +8,7 @@ pub mod logging; pub mod mcp; pub mod patch_helper; pub mod protocol; +pub mod runtime; pub mod stdio; pub use config::{HttpConfig, INBOX_PATH, MCP_PATH}; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index ad05c09..4311ff1 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -3,9 +3,11 @@ use std::sync::Arc; use anyhow::Result; use clap::Parser; use codespace_policy::Registry; +use codespace_runner::{RuntimeBackend, ShellRelease}; use codespace_server::config::{Cli, TransportMode}; use codespace_server::http::serve_http; use codespace_server::logging; +use codespace_server::runtime::RuntimeProcess; use codespace_server::stdio; use codespace_store::Store; @@ -15,15 +17,57 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let registry = load_registry(&cli)?; let store = load_store(&cli)?; - match cli.mode() { - TransportMode::Stdio => stdio::serve_with(registry, store).await, + let started = start_runner(&cli, store.clone()).await?; + let runner = started.runner.clone(); + let result = match cli.mode() { + TransportMode::Stdio => stdio::serve_with_runner(registry, store, runner).await, TransportMode::Http => { - let (bound, _cancel) = serve_http(cli.http_config(), registry, store).await?; + let (bound, _cancel) = serve_http(cli.http_config(), registry, store, runner).await?; tracing::info!(%bound, "http ready"); tokio::signal::ctrl_c().await?; Ok(()) } + }; + drop(started); + result +} + +struct StartedRunner { + runner: RuntimeBackend, + _process: Option, +} + +async fn start_runner(cli: &Cli, store: Arc) -> Result { + let on_release: ShellRelease = { + let store_for_lease = store.clone(); + Arc::new(move |process_id: &str| { + store_for_lease.release_process(process_id); + }) + }; + if cli.runner != "uds" { + return Ok(StartedRunner { + runner: RuntimeBackend::in_process(on_release), + _process: None, + }); + } + if let Some(bin) = &cli.runtime_bin { + let (process, runner) = + RuntimeProcess::spawn(bin, cli.runner_dir.as_deref(), on_release, store).await?; + return Ok(StartedRunner { + runner: RuntimeBackend::Uds(runner), + _process: Some(process), + }); } + let socket = cli.runner_socket.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "uds runner requires --runtime-bin (spawn) or --runner-socket (connect to an existing worker)" + ) + })?; + let runner = RuntimeProcess::connect_existing(socket, on_release, store).await?; + Ok(StartedRunner { + runner: RuntimeBackend::Uds(runner), + _process: None, + }) } fn load_registry(cli: &Cli) -> Result { diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index a65c7b8..e613177 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -11,8 +11,8 @@ use codespace_domain::{ }; use codespace_policy::{allow, Action, ClientClaims, Registry}; use codespace_runner::{ - InProcessRunner, Runner, RunnerApplyPatchRequest, RunnerExecRequest, RunnerReadProcess, - RunnerWriteStdin, + Runner, RunnerApplyPatchRequest, RunnerError, RunnerExecRequest, RunnerReadProcess, + RunnerWriteStdin, RuntimeBackend, }; use codespace_store::{Begin, Store}; use rmcp::{ @@ -36,7 +36,7 @@ pub struct CodeSpace { tool_router: ToolRouter, pub(crate) registry: Registry, pub(crate) store: Arc, - pub(crate) runner: InProcessRunner, + pub(crate) runner: RuntimeBackend, } fn err_json(err: ErrorBody) -> String { @@ -61,9 +61,17 @@ impl CodeSpace { pub fn with_store(registry: Registry, store: Arc) -> Self { let store_for_lease = store.clone(); - let runner = InProcessRunner::new(Arc::new(move |workspace_id| { - store_for_lease.clear_shell(workspace_id); - })); + let on_release = Arc::new(move |process_id: &str| { + store_for_lease.release_process(process_id); + }); + Self::with_store_and_runner(registry, store, RuntimeBackend::in_process(on_release)) + } + + pub fn with_store_and_runner( + registry: Registry, + store: Arc, + runner: RuntimeBackend, + ) -> Self { Self { tool_router: Self::tool_router(), registry, @@ -99,11 +107,12 @@ impl CodeSpace { .map_err(err_json)?; self.runner .read(ws, ¶ms.path) + .await .map(|mut result| { result.coordination = self.hint(¶ms.workspace_id.0, params.work_id.as_ref()); Json(result) }) - .map_err(err_json) + .map_err(runner_err_json) } #[tool( @@ -120,11 +129,12 @@ impl CodeSpace { .map_err(err_json)?; self.runner .find(ws, params.glob.as_deref()) + .await .map(|mut result| { result.coordination = self.hint(¶ms.workspace_id.0, params.work_id.as_ref()); Json(result) }) - .map_err(err_json) + .map_err(runner_err_json) } #[tool( @@ -168,6 +178,7 @@ impl CodeSpace { .get(¶ms.workspace_id.0) .map_err(err_json)?; allow(ws, Action::Exec, &ClientClaims::default()).map_err(err_json)?; + ws.require_host_execution().map_err(err_json)?; if params.command.is_empty() || params.command[0].is_empty() { return Err(err_json(ErrorBody::new( ErrorCode::InvalidPatch, @@ -178,20 +189,25 @@ impl CodeSpace { self.store .mark_shell_busy(¶ms.workspace_id.0, &process_id.0) .map_err(err_json)?; - match self.runner.exec( - ws, - RunnerExecRequest { - argv: params.command, - process_id, - }, - ) { + match self + .runner + .exec( + ws, + RunnerExecRequest::for_host(params.command, process_id.clone(), ws.profile), + ) + .await + { Ok(result) => Ok(Json(ExecCommandResult { process_id: result.process_id, coordination: self.hint(¶ms.workspace_id.0, params.work_id.as_ref()), })), + Err(RunnerError::TransportAmbiguous { .. }) => Ok(Json(ExecCommandResult { + process_id, + coordination: self.hint(¶ms.workspace_id.0, params.work_id.as_ref()), + })), Err(err) => { - self.store.clear_shell(¶ms.workspace_id.0); - Err(err_json(err)) + self.store.release_process(&process_id.0); + Err(runner_err_json(err)) } } } @@ -210,13 +226,11 @@ impl CodeSpace { data: params.data, }) .await - .map(|_| { - Json(OkBody { - ok: true, - coordination: self.process_hint(¶ms.process_id.0), - }) - }) - .map_err(err_json) + .map_err(runner_err_json)?; + Ok(Json(OkBody { + ok: true, + coordination: self.process_hint(¶ms.process_id.0).await, + })) } #[tool( @@ -227,21 +241,21 @@ impl CodeSpace { &self, Parameters(params): Parameters, ) -> Result, String> { - self.runner + let result = self + .runner .read_process(RunnerReadProcess { process_id: params.process_id.clone(), cursor: params.cursor, }) - .map(|result| { - Json(ReadProcessResult { - process_id: result.process_id, - cursor: result.cursor, - chunk: result.chunk, - eof: result.eof, - coordination: self.process_hint(¶ms.process_id.0), - }) - }) - .map_err(err_json) + .await + .map_err(runner_err_json)?; + Ok(Json(ReadProcessResult { + process_id: result.process_id, + cursor: result.cursor, + chunk: result.chunk, + eof: result.eof, + coordination: self.process_hint(¶ms.process_id.0).await, + })) } #[tool( @@ -254,13 +268,12 @@ impl CodeSpace { ) -> Result, String> { self.runner .terminate(¶ms.process_id) - .map(|_| { - Json(OkBody { - ok: true, - coordination: self.process_hint(¶ms.process_id.0), - }) - }) - .map_err(err_json) + .await + .map_err(runner_err_json)?; + Ok(Json(OkBody { + ok: true, + coordination: self.process_hint(¶ms.process_id.0).await, + })) } #[tool( @@ -344,6 +357,7 @@ impl CodeSpace { ) -> Result { let ws = self.registry.get(¶ms.workspace_id.0)?; codespace_policy::allow(ws, Action::Write, &ClientClaims::default())?; + ws.require_host_execution()?; let _lease = self.store.try_acquire_write(¶ms.workspace_id.0)?; let fingerprint = Store::fingerprint(¶ms); match self.store.begin( @@ -378,12 +392,23 @@ impl CodeSpace { work_id: None, coordination: None, }, - Err(err) => { + Err(RunnerError::Execution(err)) => { let failed = ApplyPatchResult::new(PatchStatus::Rejected, operation_id.clone()); let _ = self.store.finish(&operation_id, &failed); return Err(err.with_operation_id(operation_id.0)); } + Err(RunnerError::TransportBeforeDispatch { .. }) + | Err(RunnerError::TransportAmbiguous { .. }) => { + let unknown = + ApplyPatchResult::new(PatchStatus::Unknown, operation_id.clone()); + self.store.finish(&operation_id, &unknown)?; + return Ok(self.with_hint( + unknown, + ¶ms.workspace_id.0, + params.work_id.as_ref(), + )); + } }; self.store.finish(&operation_id, &result)?; Ok(self.with_hint(result, ¶ms.workspace_id.0, params.work_id.as_ref())) @@ -398,8 +423,8 @@ impl CodeSpace { .flatten() } - fn process_hint(&self, process_id: &str) -> Option { - let ws = self.runner.workspace_of(process_id)?; + async fn process_hint(&self, process_id: &str) -> Option { + let ws = self.runner.workspace_of(process_id).await?; self.hint(&ws, None) } @@ -415,6 +440,10 @@ impl CodeSpace { } } +fn runner_err_json(err: RunnerError) -> String { + err_json(err.into_error_body()) +} + fn lookup(registry: &Registry, workspace_id: Option) -> Result { let Some(id) = workspace_id.filter(|s| !s.is_empty()) else { return Ok(workspace_info(None)); @@ -477,3 +506,166 @@ impl ServerHandler for CodeSpace { Ok(result) } } + +#[cfg(test)] +mod tests { + use super::*; + use codespace_domain::{OperationKey, WorkspaceId}; + use codespace_policy::{EnvironmentKind, Workspace}; + use codespace_runner::{host_worker, serve_runner_connection, UdsRunner}; + use std::collections::BTreeMap; + use tokio::io::AsyncReadExt; + use tokio::net::UnixStream; + + fn write_registry(root: std::path::PathBuf) -> Registry { + let mut registry = Registry::new(); + registry.insert(Workspace::new( + WorkspaceId("demo".into()), + root, + codespace_domain::Profile::WorkspaceWrite, + )); + registry + } + + async fn drop_after_one_frame(stream: UnixStream) { + let (mut read, _write) = stream.into_split(); + let mut len_buf = [0u8; 4]; + if read.read_exact(&mut len_buf).await.is_err() { + return; + } + let len = u32::from_be_bytes(len_buf) as usize; + let mut payload = vec![0u8; len]; + let _ = read.read_exact(&mut payload).await; + } + + #[tokio::test] + async fn apply_patch_uds_loss_records_unknown_not_rejected() { + let dir = tempfile::tempdir().unwrap(); + let ws_root = dir.path().join("ws"); + std::fs::create_dir(&ws_root).unwrap(); + let (client, server) = UnixStream::pair().unwrap(); + tokio::spawn(async move { + drop_after_one_frame(server).await; + }); + let store = Arc::new(Store::memory().unwrap()); + let runner = RuntimeBackend::Uds(UdsRunner::from_stream(client, Arc::new(|_| {}))); + let cs = CodeSpace::with_store_and_runner(write_registry(ws_root), store.clone(), runner); + let result = cs + .apply_patch_inner(ApplyPatchParams { + workspace_id: WorkspaceId("demo".into()), + patch: "*** Begin Patch\n*** Add File: lost.txt\n+x\n*** End Patch\n".into(), + expected_versions: BTreeMap::new(), + operation_key: Some(OperationKey("k-unknown".into())), + check_only: false, + work_id: None, + }) + .await + .unwrap(); + assert_eq!(result.status, PatchStatus::Unknown); + let stored = store.get(&result.operation_id).unwrap(); + assert_eq!(stored.status, PatchStatus::Unknown); + assert_ne!(stored.status, PatchStatus::Rejected); + assert!(!dir.path().join("ws/lost.txt").exists()); + } + + #[tokio::test] + async fn exec_ambiguous_keeps_workspace_busy() { + let dir = tempfile::tempdir().unwrap(); + let ws_root = dir.path().join("ws"); + std::fs::create_dir(&ws_root).unwrap(); + let (client, server) = UnixStream::pair().unwrap(); + tokio::spawn(async move { + drop_after_one_frame(server).await; + }); + let store = Arc::new(Store::memory().unwrap()); + let runner = RuntimeBackend::Uds(UdsRunner::from_stream(client, Arc::new(|_| {}))); + let cs = CodeSpace::with_store_and_runner(write_registry(ws_root), store.clone(), runner); + let started = cs + .exec_command(Parameters(ExecCommandParams { + workspace_id: WorkspaceId("demo".into()), + command: vec!["/bin/echo".into(), "x".into()], + work_id: None, + })) + .await + .unwrap(); + assert!(started.0.process_id.0.starts_with("proc-")); + let busy = cs + .apply_patch_inner(ApplyPatchParams { + workspace_id: WorkspaceId("demo".into()), + patch: "*** Begin Patch\n*** Add File: later.txt\n+x\n*** End Patch\n".into(), + expected_versions: BTreeMap::new(), + operation_key: None, + check_only: false, + work_id: None, + }) + .await + .unwrap_err(); + assert_eq!(busy.code, ErrorCode::WorkspaceBusy); + } + + #[tokio::test] + async fn process_exited_releases_exclusive_lease() { + let dir = tempfile::tempdir().unwrap(); + let ws_root = dir.path().join("ws"); + std::fs::create_dir(&ws_root).unwrap(); + let (client, server) = UnixStream::pair().unwrap(); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve"); + }); + let store = Arc::new(Store::memory().unwrap()); + let store_for_lease = store.clone(); + let runner = RuntimeBackend::Uds(UdsRunner::from_stream( + client, + Arc::new(move |process_id: &str| { + store_for_lease.release_process(process_id); + }), + )); + let cs = CodeSpace::with_store_and_runner(write_registry(ws_root), store.clone(), runner); + cs.exec_command(Parameters(ExecCommandParams { + workspace_id: WorkspaceId("demo".into()), + command: vec!["/bin/echo".into(), "done".into()], + work_id: None, + })) + .await + .unwrap(); + for _ in 0..50 { + if store.try_acquire_write("demo").is_ok() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let _lease = store.try_acquire_write("demo").expect("lease released"); + } + + #[tokio::test] + async fn linux_container_apply_patch_is_unauthorized_without_operation() { + let dir = tempfile::tempdir().unwrap(); + let ws_root = dir.path().join("ws"); + std::fs::create_dir(&ws_root).unwrap(); + let mut registry = Registry::new(); + let mut ws = Workspace::new( + WorkspaceId("demo".into()), + ws_root, + codespace_domain::Profile::WorkspaceWrite, + ); + ws.environment_kind = EnvironmentKind::LinuxContainer; + registry.insert(ws); + let cs = CodeSpace::new(registry); + let err = cs + .apply_patch_inner(ApplyPatchParams { + workspace_id: WorkspaceId("demo".into()), + patch: "*** Begin Patch\n*** Add File: a.txt\n+x\n*** End Patch\n".into(), + expected_versions: BTreeMap::new(), + operation_key: Some(OperationKey("k-box".into())), + check_only: false, + work_id: None, + }) + .await + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Unauthorized); + assert!(err.operation_id.is_none()); + } +} diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs new file mode 100644 index 0000000..0e52461 --- /dev/null +++ b/crates/server/src/runtime.rs @@ -0,0 +1,164 @@ +//! Gateway-owned Unix worker. One child, one private dir, one socket. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use codespace_runner::{allocate_private_runner_dir, runner_socket_path, ShellRelease, UdsRunner}; +use codespace_store::Store; +use tokio::net::UnixStream; +use tokio::process::Command; +use tokio::sync::Notify; +use tokio::task::JoinHandle; + +pub struct RuntimeProcess { + dir: PathBuf, + socket: PathBuf, + shutdown: Arc, + wait: Option>, +} + +impl RuntimeProcess { + pub async fn spawn( + bin: &Path, + runner_dir: Option<&Path>, + on_process_exit: ShellRelease, + store: Arc, + ) -> Result<(Self, UdsRunner)> { + let dir = allocate_private_runner_dir(runner_dir) + .map_err(|err| anyhow!("private runner dir: {err}"))?; + let socket = runner_socket_path(&dir); + let mut child = Command::new(bin) + .arg(&socket) + .stdin(Stdio::null()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("spawn {}", bin.display()))?; + let shutdown = Arc::new(Notify::new()); + let wait_shutdown = shutdown.clone(); + let wait_store = store.clone(); + let wait = tokio::spawn(async move { + tokio::select! { + _ = child.wait() => {} + _ = wait_shutdown.notified() => { + let _ = child.start_kill(); + let _ = child.wait().await; + } + } + wait_store.release_all_processes(); + }); + let stream = wait_for_socket(&socket, &wait).await?; + let kill = shutdown.clone(); + let runner = UdsRunner::from_stream_with_disconnect( + stream, + on_process_exit, + Arc::new(move || { + kill.notify_one(); + }), + ); + runner + .handshake() + .await + .map_err(|err| anyhow!("runner hello: {err}"))?; + Ok(( + Self { + dir, + socket, + shutdown, + wait: Some(wait), + }, + runner, + )) + } + + pub async fn connect_existing( + socket: &Path, + on_process_exit: ShellRelease, + store: Arc, + ) -> Result { + let stream = UnixStream::connect(socket) + .await + .with_context(|| format!("connect {}", socket.display()))?; + let runner = UdsRunner::from_stream_with_disconnect( + stream, + on_process_exit, + Arc::new(move || { + store.release_all_processes(); + }), + ); + runner + .handshake() + .await + .map_err(|err| anyhow!("runner hello: {err}"))?; + Ok(runner) + } + + pub fn socket(&self) -> &Path { + &self.socket + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + pub async fn wait_exit(&mut self) { + self.shutdown.notify_one(); + if let Some(wait) = self.wait.take() { + let _ = wait.await; + } + } +} + +impl Drop for RuntimeProcess { + fn drop(&mut self) { + self.shutdown.notify_one(); + let _ = std::fs::remove_file(&self.socket); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +async fn wait_for_socket(socket: &Path, wait: &JoinHandle<()>) -> Result { + for _ in 0..100 { + if wait.is_finished() { + return Err(anyhow!("worker exited before the runner socket was ready")); + } + match UnixStream::connect(socket).await { + Ok(stream) => return Ok(stream), + Err(_) => tokio::time::sleep(Duration::from_millis(20)).await, + } + } + Err(anyhow!("worker socket not ready at {}", socket.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use codespace_store::Store; + use std::sync::Arc; + + fn runtime_bin() -> Option { + std::env::var_os("CODESPACE_RUNTIME_BIN").map(PathBuf::from) + } + + #[tokio::test] + async fn spawn_hello_then_drop_kills_worker() { + let Some(bin) = runtime_bin() else { + return; + }; + let store = Arc::new(Store::memory().unwrap()); + store.mark_shell_busy("demo", "proc-keep").unwrap(); + let (mut proc, _runner) = + RuntimeProcess::spawn(&bin, None, Arc::new(|_| {}), store.clone()) + .await + .expect("spawn runtime"); + let dir = proc.dir().to_path_buf(); + proc.wait_exit().await; + drop(proc); + let _lease = store + .try_acquire_write("demo") + .expect("process leases released after worker death"); + assert!(!dir.exists() || std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0) == 0); + } +} diff --git a/crates/server/src/stdio.rs b/crates/server/src/stdio.rs index 379855b..51e64b0 100644 --- a/crates/server/src/stdio.rs +++ b/crates/server/src/stdio.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use anyhow::Result; use codespace_policy::Registry; +use codespace_runner::RuntimeBackend; use codespace_store::Store; use rmcp::{transport::stdio, ServiceExt}; @@ -24,3 +25,16 @@ pub async fn serve_with(registry: Registry, store: Arc) -> Result<()> { service.waiting().await?; Ok(()) } + +pub async fn serve_with_runner( + registry: Registry, + store: Arc, + runner: RuntimeBackend, +) -> Result<()> { + tracing::info!(transport = "stdio", "codespace mcp listening"); + let service = CodeSpace::with_store_and_runner(registry, store, runner) + .serve(stdio()) + .await?; + service.waiting().await?; + Ok(()) +} diff --git a/crates/server/tests/inbox.rs b/crates/server/tests/inbox.rs index cfb74a1..f0ee07e 100644 --- a/crates/server/tests/inbox.rs +++ b/crates/server/tests/inbox.rs @@ -16,11 +16,11 @@ async fn spawn_inbox(token: Option<&str>) -> (tempfile::TempDir, SocketAddr, Arc let ws = root.path().join("ws"); std::fs::create_dir(&ws).unwrap(); let mut registry = Registry::new(); - registry.insert(Workspace { - id: WorkspaceId("demo".into()), - root: ws, - profile: Profile::WorkspaceWrite, - }); + registry.insert(Workspace::new( + WorkspaceId("demo".into()), + ws, + Profile::WorkspaceWrite, + )); let store = Arc::new(Store::memory().expect("store")); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); diff --git a/crates/server/tests/process.rs b/crates/server/tests/process.rs index 1e24684..d154a60 100644 --- a/crates/server/tests/process.rs +++ b/crates/server/tests/process.rs @@ -408,6 +408,62 @@ async fn read_only_profile_rejects_exec() { client.cancel().await.expect("cancel"); } +#[tokio::test] +async fn linux_container_environment_rejects_exec() { + let root = tempfile::tempdir().unwrap(); + let ws = root.path().join("ws"); + std::fs::create_dir(&ws).unwrap(); + let cfg = root.path().join("workspaces.json"); + std::fs::write( + &cfg, + serde_json::json!({ + "environments": { "box": { "kind": "linux-container" } }, + "workspaces": { + "demo": { "root": ws, "profile": "workspace-write", "environment": "box" } + } + }) + .to_string(), + ) + .unwrap(); + let client = spawn_client(&cfg, false, &[]).await; + let denied = client + .call_tool( + CallToolRequestParams::new(TOOL_EXEC_COMMAND).with_arguments(object!({ + "workspace_id": "demo", + "command": ["/bin/echo", "nope"] + })), + ) + .await; + let text = err_text(&denied); + assert!( + text.contains("UNAUTHORIZED") || text.contains("linux-container"), + "{text}" + ); + assert!( + !text.contains("\"operation_id\""), + "linux-container must fail before minting operation_id: {text}" + ); + + let patch_denied = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": "*** Begin Patch\n*** Add File: a.txt\n+x\n*** End Patch\n" + })), + ) + .await; + let patch_text = err_text(&patch_denied); + assert!( + patch_text.contains("UNAUTHORIZED") || patch_text.contains("linux-container"), + "{patch_text}" + ); + assert!( + !patch_text.contains("\"operation_id\""), + "linux-container apply_patch must not mint operation_id: {patch_text}" + ); + client.cancel().await.expect("cancel"); +} + async fn spawn_http(cfg: &std::path::Path) -> std::net::SocketAddr { let registry = codespace_policy::Registry::load_path(cfg).expect("registry"); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); diff --git a/crates/server/tests/protocol_compat.rs b/crates/server/tests/protocol_compat.rs index afd9861..e1c79ee 100644 --- a/crates/server/tests/protocol_compat.rs +++ b/crates/server/tests/protocol_compat.rs @@ -244,11 +244,11 @@ async fn spawn_http_workspace() -> (tempfile::TempDir, SocketAddr, Arc) { std::fs::create_dir(&ws).unwrap(); std::fs::write(ws.join("hello.txt"), "hi\n").unwrap(); let mut registry = Registry::new(); - registry.insert(Workspace { - id: WorkspaceId("demo".into()), - root: ws, - profile: Profile::WorkspaceWrite, - }); + registry.insert(Workspace::new( + WorkspaceId("demo".into()), + ws, + Profile::WorkspaceWrite, + )); let store = Arc::new(Store::memory().expect("store")); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind http"); let addr = listener.local_addr().expect("local addr"); diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index ef7ea5e..b5ad65f 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true rust-version.workspace = true publish = false -description = "SQLite operation store and workspace write lease" +description = "SQLite operations plus in-memory resource serialization" [dependencies] codespace-domain = { path = "../domain" } diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 511ae2b..d455a39 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,7 +1,8 @@ //! Single-instance SQLite operations and in-process workspace write locks. //! HTTP/JSON-RPC request ids are never stored as [`OperationId`] values. -use std::collections::HashMap; +mod resource; + use std::path::Path; use std::sync::Mutex; @@ -12,6 +13,8 @@ use codespace_domain::{ use rusqlite::{params, Connection, OptionalExtension}; use sha2::{Digest, Sha256}; +pub use resource::{LockMode, Resource, ResourceGuard}; + #[derive(Debug, Clone)] pub struct StoredOperation { pub operation_id: OperationId, @@ -26,15 +29,9 @@ pub enum Begin { Replayed(StoredOperation), } -#[derive(Default)] -struct LeaseMap { - mutating: HashMap, - busy_shell: HashMap, -} - pub struct Store { conn: Mutex, - leases: Mutex, + locks: Mutex, } pub struct WriteGuard<'a> { @@ -108,7 +105,7 @@ impl Store { .map_err(|e| e.to_string())?; Ok(Self { conn: Mutex::new(conn), - leases: Mutex::new(LeaseMap::default()), + locks: Mutex::new(resource::ResourceSerializer::default()), }) } @@ -128,21 +125,10 @@ impl Store { &'a self, workspace_id: &str, ) -> Result, ErrorBody> { - let mut leases = self.leases.lock().expect("lease mutex"); - if leases.busy_shell.contains_key(workspace_id) { - return Err(ErrorBody::new( - ErrorCode::WorkspaceBusy, - "workspace has a busy shell", - )); - } - let count = leases.mutating.entry(workspace_id.to_string()).or_insert(0); - if *count > 0 { - return Err(ErrorBody::new( - ErrorCode::WorkspaceBusy, - "workspace write lock is held", - )); - } - *count = 1; + self.locks + .lock() + .expect("lock mutex") + .try_exclusive_write(workspace_id)?; Ok(WriteGuard { store: self, workspace_id: workspace_id.to_string(), @@ -150,32 +136,57 @@ impl Store { } fn release_write(&self, workspace_id: &str) { - let mut leases = self.leases.lock().expect("lease mutex"); - leases.mutating.remove(workspace_id); + self.locks + .lock() + .expect("lock mutex") + .release_write(workspace_id); } pub fn mark_shell_busy(&self, workspace_id: &str, process_id: &str) -> Result<(), ErrorBody> { - let mut leases = self.leases.lock().expect("lease mutex"); - if leases.mutating.get(workspace_id).copied().unwrap_or(0) > 0 - || leases.busy_shell.contains_key(workspace_id) - { - return Err(ErrorBody::new( - ErrorCode::WorkspaceBusy, - "workspace write lock is held", - )); - } - leases - .busy_shell - .insert(workspace_id.to_string(), process_id.to_string()); - Ok(()) + self.locks + .lock() + .expect("lock mutex") + .mark_shell_busy(workspace_id, process_id) } pub fn clear_shell(&self, workspace_id: &str) { - self.leases + self.locks + .lock() + .expect("lock mutex") + .clear_shell(workspace_id); + } + + pub fn release_process(&self, process_id: &str) { + self.locks + .lock() + .expect("lock mutex") + .release_process(process_id); + } + + pub fn release_all_processes(&self) { + self.locks + .lock() + .expect("lock mutex") + .release_all_processes(); + } + + pub fn try_lock( + &self, + resource: Resource, + mode: LockMode, + ) -> Result, ErrorBody> { + self.locks + .lock() + .expect("lock mutex") + .try_lock(resource.clone(), mode)?; + Ok(ResourceGuard::new(self, resource, mode)) + } + + fn release_resource(&self, resource: &Resource, mode: LockMode) { + self.locks .lock() - .expect("lease mutex") - .busy_shell - .remove(workspace_id); + .expect("lock mutex") + .unlock(resource, mode); } pub fn begin( @@ -491,6 +502,49 @@ mod tests { ); } + #[test] + fn process_owned_lease_releases_by_process_id() { + let store = Store::memory().unwrap(); + store.mark_shell_busy("demo", "proc-1").unwrap(); + assert_eq!( + store.try_acquire_write("demo").err().map(|e| e.code), + Some(ErrorCode::WorkspaceBusy) + ); + store.release_process("proc-other"); + assert_eq!( + store.try_acquire_write("demo").err().map(|e| e.code), + Some(ErrorCode::WorkspaceBusy) + ); + store.release_process("proc-1"); + let _guard = store.try_acquire_write("demo").unwrap(); + } + + #[test] + fn request_owned_write_is_not_cleared_by_release_process() { + let store = Store::memory().unwrap(); + let _guard = store.try_acquire_write("demo").unwrap(); + store.release_process("proc-1"); + assert_eq!( + store.try_acquire_write("demo").err().map(|e| e.code), + Some(ErrorCode::WorkspaceBusy) + ); + } + + #[test] + fn release_all_processes_keeps_request_owned_writes() { + let store = Store::memory().unwrap(); + store.mark_shell_busy("demo", "proc-1").unwrap(); + store.mark_shell_busy("other", "proc-2").unwrap(); + let _guard = store.try_acquire_write("held").unwrap(); + store.release_all_processes(); + let _demo = store.try_acquire_write("demo").unwrap(); + let _other = store.try_acquire_write("other").unwrap(); + assert_eq!( + store.try_acquire_write("held").err().map(|e| e.code), + Some(ErrorCode::WorkspaceBusy) + ); + } + #[test] fn minted_operation_id_is_not_a_jsonrpc_id() { let store = Store::memory().unwrap(); diff --git a/crates/store/src/resource.rs b/crates/store/src/resource.rs new file mode 100644 index 0000000..76191ec --- /dev/null +++ b/crates/store/src/resource.rs @@ -0,0 +1,240 @@ +//! In-memory resource serialization. Not a SQLite schema and not a thread queue. + +use std::collections::HashMap; + +use codespace_domain::{ErrorBody, ErrorCode}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Resource { + Environment(String), + Workspace(String), + Path { workspace: String, path: String }, + Process(String), + Operation(String), + Watch(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LockMode { + Exclusive, + /// Typed but unused by live `read` / `find` (those stay unlocked). + SharedRead, +} + +#[derive(Debug, Clone)] +enum ExclusiveHolder { + /// Request-owned RAII exclusive (`WriteGuard`). + Request, + /// Process-owned exclusive (`ProcessId`). + Process(String), +} + +#[derive(Default)] +pub(crate) struct ResourceSerializer { + exclusive: HashMap, + shared: HashMap, +} + +impl ResourceSerializer { + pub(crate) fn try_exclusive_write(&mut self, workspace_id: &str) -> Result<(), ErrorBody> { + let resource = Resource::Workspace(workspace_id.to_string()); + if let Some(ExclusiveHolder::Process(_)) = self.exclusive.get(&resource) { + return Err(ErrorBody::new( + ErrorCode::WorkspaceBusy, + "workspace has a busy shell", + )); + } + if self.exclusive.contains_key(&resource) || self.shared_count(&resource) > 0 { + return Err(ErrorBody::new( + ErrorCode::WorkspaceBusy, + "workspace write lock is held", + )); + } + self.exclusive.insert(resource, ExclusiveHolder::Request); + Ok(()) + } + + pub(crate) fn release_write(&mut self, workspace_id: &str) { + let resource = Resource::Workspace(workspace_id.to_string()); + if matches!( + self.exclusive.get(&resource), + Some(ExclusiveHolder::Request) + ) { + self.exclusive.remove(&resource); + } + } + + pub(crate) fn mark_shell_busy( + &mut self, + workspace_id: &str, + process_id: &str, + ) -> Result<(), ErrorBody> { + let resource = Resource::Workspace(workspace_id.to_string()); + if self.exclusive.contains_key(&resource) || self.shared_count(&resource) > 0 { + return Err(ErrorBody::new( + ErrorCode::WorkspaceBusy, + "workspace write lock is held", + )); + } + self.exclusive + .insert(resource, ExclusiveHolder::Process(process_id.to_string())); + Ok(()) + } + + pub(crate) fn clear_shell(&mut self, workspace_id: &str) { + let resource = Resource::Workspace(workspace_id.to_string()); + if matches!( + self.exclusive.get(&resource), + Some(ExclusiveHolder::Process(_)) + ) { + self.exclusive.remove(&resource); + } + } + + pub(crate) fn release_process(&mut self, process_id: &str) { + self.exclusive.retain(|_, holder| match holder { + ExclusiveHolder::Process(id) => id != process_id, + ExclusiveHolder::Request => true, + }); + } + + pub(crate) fn release_all_processes(&mut self) { + self.exclusive.retain(|_, holder| match holder { + ExclusiveHolder::Process(_) => false, + ExclusiveHolder::Request => true, + }); + } + + pub(crate) fn try_lock(&mut self, resource: Resource, mode: LockMode) -> Result<(), ErrorBody> { + match mode { + LockMode::SharedRead => { + if self.exclusive.contains_key(&resource) { + return Err(busy(&resource)); + } + *self.shared.entry(resource).or_insert(0) += 1; + Ok(()) + } + LockMode::Exclusive => { + if self.exclusive.contains_key(&resource) || self.shared_count(&resource) > 0 { + return Err(busy(&resource)); + } + self.exclusive.insert(resource, ExclusiveHolder::Request); + Ok(()) + } + } + } + + pub(crate) fn unlock(&mut self, resource: &Resource, mode: LockMode) { + match mode { + LockMode::SharedRead => { + if let Some(count) = self.shared.get_mut(resource) { + *count = count.saturating_sub(1); + if *count == 0 { + self.shared.remove(resource); + } + } + } + LockMode::Exclusive => { + self.exclusive.remove(resource); + } + } + } + + fn shared_count(&self, resource: &Resource) -> u32 { + self.shared.get(resource).copied().unwrap_or(0) + } +} + +fn busy(resource: &Resource) -> ErrorBody { + let detail = match resource { + Resource::Workspace(_) => "workspace write lock is held", + Resource::Environment(_) => "environment lock is held", + Resource::Path { .. } => "path lock is held", + Resource::Process(_) => "process lock is held", + Resource::Operation(_) => "operation lock is held", + Resource::Watch(_) => "watch lock is held", + }; + ErrorBody::new(ErrorCode::WorkspaceBusy, detail) +} + +pub struct ResourceGuard<'a> { + store: &'a super::Store, + resource: Resource, + mode: LockMode, +} + +impl<'a> ResourceGuard<'a> { + pub(crate) fn new(store: &'a super::Store, resource: Resource, mode: LockMode) -> Self { + Self { + store, + resource, + mode, + } + } +} + +impl Drop for ResourceGuard<'_> { + fn drop(&mut self) { + self.store.release_resource(&self.resource, self.mode); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Store; + + #[test] + fn shared_read_does_not_take_exclusive() { + let store = Store::memory().unwrap(); + let first = store + .try_lock( + Resource::Path { + workspace: "demo".into(), + path: "a.txt".into(), + }, + LockMode::SharedRead, + ) + .unwrap(); + let second = store + .try_lock( + Resource::Path { + workspace: "demo".into(), + path: "a.txt".into(), + }, + LockMode::SharedRead, + ) + .unwrap(); + drop(first); + drop(second); + let _exclusive = store + .try_lock( + Resource::Path { + workspace: "demo".into(), + path: "a.txt".into(), + }, + LockMode::Exclusive, + ) + .unwrap(); + } + + #[test] + fn exclusive_blocks_shared() { + let store = Store::memory().unwrap(); + let resource = Resource::Environment("local".into()); + let _guard = store + .try_lock(resource.clone(), LockMode::Exclusive) + .unwrap(); + assert!(store.try_lock(resource, LockMode::SharedRead).is_err()); + } + + #[test] + fn read_find_do_not_require_shared_lock() { + let store = Store::memory().unwrap(); + let _write = store.try_acquire_write("demo").unwrap(); + // SharedRead exists as a mode only. Live read/find do not take it. + assert!(store + .try_lock(Resource::Workspace("demo".into()), LockMode::SharedRead) + .is_err()); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index e420c39..4fd304e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,9 +38,12 @@ outbound model client. Execution-only invariant: ## Current layout -MVP is **one host process**: `codespace-mcp`. `exec_command` is not -dispatched into a container. [`deploy/compose.yml`](../deploy/compose.yml) -is an isolation **fixture** only. +MVP default is **one host process**: `codespace-mcp` plus in-process +`Runner`. `exec_command` is not dispatched into compose. +[`deploy/compose.yml`](../deploy/compose.yml) is an isolation **fixture** +only. Opt-in Unix-socket transport (`CODESPACE_RUNNER=uds`) talks +CodeSpace JSON to `codespace-codex-runtime`; that is not Linux +isolation and not the default. ```text CURRENT @@ -53,39 +56,51 @@ codespace-mcp (host gateway) ├─ structured logging (stderr tracing) │ │ Runner execution DTO - │ (no work_id / operation_id / coordination) + │ (command/exec shape; no work_id / operation_id / coordination) ▼ -InProcessRunner - ├─ read / find / version (PathSandbox) - ├─ apply_patch (one transaction) - │ expected versions → preflight → snapshot - │ → helper apply → verify → rollback - │ │ JSON stdin/stdout - │ ▼ - │ codespace-patch (host child) - │ └─ Codex Rust crate in-process - └─ exec / stdin / read / terminate - └─ host process (tokio::process::Command, - workspace cwd, env_clear) +RuntimeBackend + ├─ default: InProcessRunner + └─ opt-in: UdsRunner (CODESPACE_RUNNER=uds, 1:1) + │ length-prefixed CodeSpace JSON (protocol 1, Hello, request_id rrpc-…) + ▼ + codespace-codex-runtime (owned by gateway RuntimeProcess) + ├─ unique 0700 dir + runner.sock (bind only; no parent chmod) + ├─ codex-process-hardening (main first line, not a command sandbox) + ├─ disconnect / gateway shutdown → kill worker (host children die) + └─ one InProcessRunner + ├─ read / find / version (PathSandbox) + ├─ apply_patch (one transaction) + │ expected versions → preflight → snapshot + │ → helper apply → verify → rollback + │ │ JSON stdin/stdout + │ ▼ + │ codespace-patch (host child) + │ └─ Codex Rust crate in-process + └─ exec / stdin / read / terminate + └─ host process (tokio::process::Command, + cwd = workspace root, env from runner-local defaults) deploy/compose.yml └─ isolation fixture only; not connected to exec_command ``` ```text -MCP JSON → domain params → gateway (policy/store) → Runner DTO → InProcessRunner +MCP JSON → domain params → gateway (policy/store) → Runner DTO → RuntimeBackend │ └─ rmcp / JsonSchema stay on MCP types, not on runner DTOs ``` The gateway owns **who may do what in which workspace**. Tokens, server -config, workspace registry, and the operations database live here. It -maps MCP params onto runner DTOs and does **not** pass -`ExecCommandParams` into the runner. +config, workspace registry, environments, and the operations database +live here. It maps MCP params onto runner DTOs and does **not** pass +`ExecCommandParams` into the runner. Tools still have no +`environment_id`. -`crates/runner` owns the in-process `Runner` (filesystem, one -`apply_patch` transaction, host process supervisor) plus compose-fixture -checks. It does **not** start a container or open a control socket. +`crates/runner` owns the `Runner` trait, execution DTOs, +`InProcessRunner` (filesystem, one `apply_patch` transaction, host +process supervisor), `UdsRunner` (Unix-socket client), and +compose-fixture checks. Default backend is in-process. The worker +binary is isolated `crates/codex-runtime` / `codespace-codex-runtime`. `codespace-patch` is a product helper process, not the upstream standalone `apply_patch` binary and not `native/patch-worker`. @@ -93,11 +108,12 @@ Runner ↔ helper is JSON stdin/stdout. Codex itself runs in-process **inside that helper**. Single instance is enough for MVP. SQLite stores **patch operations** -plus works/intents. Process handles and write/shell leases are -in-memory. No message broker. +plus works/intents. Process handles and resource locks (workspace +exclusive write / shell occupancy) are in-memory. No message broker. Gateway unit tests may run on the macOS development host. A Linux container is the **target** isolation OS, not the current exec boundary. +Registered `linux-container` environments fail closed (`UNAUTHORIZED`). ## Target layout @@ -127,13 +143,14 @@ Runner process boundary Target **domain** (not live MCP fields): Environment (where), Workspace (what), PermissionProfile (may), Operation (this RPC). Do not add -`environment_id` to tools until that WP. See -[execution-substrate.md](execution-substrate.md). +`environment_id` to tools. Operator config may register environments. +See [execution-substrate.md](execution-substrate.md). -The next **implementation** work package is **transport** (Unix socket / -`ContainerRunner`) behind the existing `Runner` / `InProcessRunner` -types. It must **not** split patch apply into multiple gateway-driven -RPCs: +Unix-socket **transport** (`UdsRunner`) exists behind the existing +`Runner` / `InProcessRunner` types as an opt-in. Host + UDS is the same +host; it does not claim Linux isolation. `LinuxContainer` stays +fail-closed (`UNAUTHORIZED`, no `operation_id`). It must **not** split +patch apply into multiple gateway-driven RPCs: ```text Runner.apply_patch(request) @@ -142,14 +159,20 @@ Runner.apply_patch(request) ``` Gateway keeps authorization, `operation_key` replay, the write lock, -dispatch, and persistence. There is no runner control socket today. - -Sandbox, PTY, UDS, and network isolation are **not** “reimplement -Codex OS engineering by default.” Prefer a cohesive execution -subgraph isolated behind the Runner, same pattern as `crates/patch` -(later `crates/codex-runtime` / `codespace-codex-runtime`). Codex -types stay in the adapter. Do not embed App Server or `codex-exec`. -`codex-exec-server` is a future measurement, not a current backend. +dispatch, and persistence. Default remains `InProcessRunner`. Opt-in +`CODESPACE_RUNNER=uds` uses a private Unix socket (unique 0700 leaf, +`runner.sock`), not the compose fixture. The gateway owns the worker +1:1 (`RuntimeProcess`, `kill_on_drop`). There is no reconnect. Live +sockets are connect-probed; leftovers are unlinked only on +`ConnectionRefused`. Runner `Replay` is same-connection only. + +Sandbox, PTY, and network isolation are **not** “reimplement Codex OS +engineering by default.” Prefer a cohesive execution subgraph isolated +behind the Runner, same pattern as `crates/patch` and +`crates/codex-runtime` / `codespace-codex-runtime`. This WP takes +`codex-process-hardening` and `codex-uds`. Codex types stay in the +adapter. Do not embed App Server or `codex-exec`. `codex-exec-server` +is a future measurement, not a current backend. ## Protocol compatibility @@ -203,10 +226,11 @@ Execution-only substrate: [execution-substrate.md](execution-substrate.md). ## IDs -HTTP/JSON-RPC request id, `operation_id`, `process_id`, `work_id`, and -`intent_id` are different identifiers. A lost HTTP response is not an -execution failure. Clients call `operation_status` instead of replaying -a mutating tool. +HTTP/JSON-RPC request id, `operation_id`, `operation_key`, `process_id`, +`work_id`, `intent_id`, and runner `request_id` (`rrpc-…`) are different +identifiers. A lost HTTP response is not an execution failure. A lost +UDS `apply_patch` response is recorded as `unknown`, not `rejected`. +Clients call `operation_status` instead of replaying a mutating tool. ```text Workspace (workspace_id) @@ -251,8 +275,10 @@ the parser. `apply_patch` never silently falls back to `git apply`. Status values are `applied` / `checked` / `rejected` / `failed_rolled_back` / `failed_partial` / `unknown`. `checked` is a successful `check_only` -preview (no writes). `rejected` is an actual refusal. Success copy -without after-version verification is forbidden. +preview (no writes). `rejected` is an actual refusal. Transport +ambiguity (socket drop after dispatch) finishes `unknown` and must not +be stored as `rejected`. Success copy without after-version verification +is forbidden. Rollback must not use `git reset --hard` and must not overwrite a whole directory tree as a substitute for per-file restore. @@ -263,10 +289,11 @@ directory tree as a substitute for per-file restore. Cargo.toml workspace root crates/server/ bin codespace-mcp: rmcp stdio + Streamable HTTP + /inbox crates/domain/ workspace, capabilities, operation, errors (no rmcp) -crates/policy/ registry and path policy +crates/policy/ registry, PermissionProfile, Environment crates/patch/ Codex adapter + codespace-patch helper (own workspace) -crates/store/ SQLite operations, works, and intents -crates/runner/ Runner trait + execution DTOs, PathSandbox, patch transaction, host process supervisor, fixture checks +crates/codex-runtime/ isolated worker: hardening + UDS + InProcessRunner +crates/store/ SQLite operations, works, intents; in-memory resource locks +crates/runner/ Runner trait + execution DTOs, PathSandbox, patch transaction, host supervisor, UdsRunner, fixture checks third_party/codex/ git submodule, pinned revision (W06) tests/{security,recovery,e2e}/ docs/ including operations.md (W12), codex-reuse.md, diff --git a/docs/codex-reuse.md b/docs/codex-reuse.md index a7902c3..e13c0f0 100644 --- a/docs/codex-reuse.md +++ b/docs/codex-reuse.md @@ -46,17 +46,16 @@ CodeSpace Core ← only authorization authority │ ▼ isolated adapter workspace - │ crates/patch today (codespace-patch) - │ crates/codex-runtime later - │ crate name: codespace-codex-runtime - │ (documented only; not created in this WP) + │ crates/patch (codespace-patch) + │ crates/codex-runtime (codespace-codex-runtime) + │ process-hardening + UDS worker; opt-in ▼ Codex execution subgraph (pinned) → OS ``` Codex is an **implementation** dependency behind the adapter, not an architectural dependency of the Gateway. `InProcessRunner`, -`ContainerRunner`, a later remote runner, or another sandbox backend +`UdsRunner`, a later remote runner, or another sandbox backend can change if Codex types never leave the adapter. ## Unit of reuse is a subgraph @@ -96,7 +95,7 @@ CodeSpace core isolated adapter (crates/patch today; -crates/codex-runtime later) +crates/codex-runtime today) ────────────────────────────────────────── approved execution subgraph allowed including transitive codex-protocol @@ -140,15 +139,14 @@ runtime adapter: - Pin stays [upstream-lock.md](upstream-lock.md) (`6b9826e3aa83b1a5947db50f4332cb9c65f1b340`). - Path dependency from an **isolated** Cargo workspace, not the repo - root. Today: `crates/patch`. Later: `crates/codex-runtime` - (`codespace-codex-runtime`; documented only; **not created in this - work package**). + root. Today: `crates/patch` and `crates/codex-runtime` + (`codespace-codex-runtime`). - NOTICE + Apache-2.0 attribution. - Product policy stays in front of and behind the subgraph. - Do not file-copy a crate out of the Codex workspace. ```text -Gateway → Runner trait → (later) ContainerRunner +Gateway → Runner trait → UdsRunner (opt-in) → codespace-codex-runtime helper → Codex execution crates ``` @@ -163,7 +161,7 @@ checkout and cargo, not this grep. | core manifests | root + `crates/{domain,policy,runner,store,server}/Cargo.toml` | tiny | crate in the update range | | core sources | those crates’ trees | low | same | | server tests | `tests/` | low | `crates/server` or `tests/` changed | -| adapter manifests | `crates/patch/Cargo.toml`; later `crates/codex-runtime` | tiny | adapter in the update range; allowlist only | +| adapter manifests | `crates/patch/Cargo.toml`; `crates/codex-runtime` | tiny | adapter in the update range; allowlist only | | upstream | `third_party/codex` | huge / false positives | never | Update range is `SCAN_BASE` (PR base / previous `main`). Unknown range @@ -174,7 +172,8 @@ job still runs. Core manifests forbid any `codex-` dependency key. Adapter manifests allow only the approved subgraph (`crates/patch` today: `codex-apply-patch`, `codex-exec-server` as apply-patch workspace -graph, `codex-utils-path-uri`). Sources keep the agent/model patterns +graph, `codex-utils-path-uri`, `codex-process-hardening`; +`crates/codex-runtime`: `codex-process-hardening`, `codex-uds`). Sources keep the agent/model patterns (`api.openai.com`, Responses, `codex-login`, `codex-core`, `codex-app-server`, `async-openai`). Comments that mention a crate name are not cargo deps. @@ -185,10 +184,10 @@ boundary. Do **not** wrap Codex App Server as an internal backend. ## Why supervisor code still exists `process_id`, stdin, terminate, and timeout converge because request -lifetime is not process lifetime. The in-process supervisor stays -CodeSpace until a Runner transport exists. `operation_key` / -`operation_status` recover a lost **remote MCP mutating RPC**, not a -Codex thread. +lifetime is not process lifetime. The in-process supervisor remains the +**default**. Opt-in `UdsRunner` still runs that supervisor inside +`codespace-codex-runtime`. `operation_key` / `operation_status` recover +a lost **remote MCP mutating RPC**, not a Codex thread. Pulling `codex-core` / `codex-exec` / App Server to get spawn+PTY also pulls login, models, plugins, and rollout. That blast radius is still @@ -196,7 +195,8 @@ rejected. ## Staged take (when those WPs exist) -Documented order; **no crate is added in this work package**: +Documented order. **Taken in code this WP:** process-hardening and UDS. +**Not taken:** PTY, filesystem, linux-sandbox, network. ```text process-hardening → PTY → UDS / path → filesystem → linux-sandbox → network @@ -214,14 +214,15 @@ Judged from the pin’s `Cargo.toml` files, not from Codex `main`. **`codex-apply-patch`** via `crates/patch`. Parse, hunk verify, apply, parity subset. -### Prefer reuse (when that WP) +**`codex-process-hardening`** via `codespace-patch` and +`codespace-codex-runtime` `pre_main_hardening()`. This hardens the +worker/helper **process**, not command sandbox. Keep it as the first +line of `main`; do not add `ctor` unless the dependency fan-out is +justified. -**`codex-process-hardening`** -([`codex-rs/process-hardening/Cargo.toml`](../third_party/codex/codex-rs/process-hardening/Cargo.toml)) +**`codex-uds`** via `codespace-codex-runtime` bind. RPC stays CodeSpace. -Runtime dep is `libc`. Core dumps, `PR_SET_DUMPABLE`, macOS debugger -attach, `LD_*` / `DYLD_*` stripping. First reuse candidate: CodeSpace -gains almost no independence by rewriting it. +### Prefer reuse (when that WP) **`codex-utils-pty`** ([`codex-rs/utils/pty/Cargo.toml`](../third_party/codex/codex-rs/utils/pty/Cargo.toml)) @@ -230,12 +231,11 @@ Unix: `portable-pty`, `tokio`, `libc`. Prefer upstream over a CodeSpace PTY. Wiring it does **not** add a PTY MCP tool. Gateway still mints `process_id`. -**`codex-uds`** +**`codex-uds`** (already in `codespace-codex-runtime`) ([`codex-rs/uds/Cargo.toml`](../third_party/codex/codex-rs/uds/Cargo.toml)) -Unix: Tokio `fs` / `net` / `rt`. Fits the next Runner Unix-socket -transport. **RPC protocol stays CodeSpace-owned**; this crate is the -transport primitive. +Unix: Tokio `fs` / `net` / `rt`. Socket primitive for the opt-in Runner +Unix-socket worker. **RPC protocol stays CodeSpace-owned**. **`codex-utils-absolute-path` / `codex-utils-path-uri`** @@ -303,7 +303,7 @@ not appear on MCP or in `crates/domain`. HTTP/WS plus `codex-api`, `codex-config`, OTel, protocol, sandboxing, PTY. Too heavy as today’s Runner backend. Not a forever reject. Later -compare ContainerRunner + low-level crates vs Gateway adapter → +compare UdsRunner + low-level crates vs Gateway adapter → exec-server. Measure compile graph and upgrade cost. ### Future Environment (not P0) @@ -339,25 +339,24 @@ second authorizer. - Workspace registry, **meaning** of profiles, path policy. - Write lock, shell occupancy, `WORKSPACE_BUSY` (until a scheduler WP). - `operation_key` replay, `operation_id`, `operation_status`. -- Host/in-process process supervisor until a Runner transport exists. +- Host/in-process process supervisor as the default; UDS worker is opt-in. - Container lifecycle and workspace bind-mount **policy**. -- Isolated adapter workspaces (`crates/patch`, later +- Isolated adapter workspaces (`crates/patch`, `crates/codex-runtime` / `codespace-codex-runtime`). ## Next implementation WP -The next **code** work package is still Runner **transport** (Unix -socket / `ContainerRunner`) behind the existing `Runner` trait. That -WP must not split `apply_patch` into multiple gateway-driven RPCs. +The next **code** work packages are remaining execution subgraph crates +(PTY, filesystem, linux-sandbox, network) behind the existing `Runner` +trait. Do not split `apply_patch` into multiple gateway-driven RPCs. -Do not default to a homegrown PTY / Landlock / seccomp / UDS stack. -Take the execution subgraph through an isolated workspace after the -table above. Pin bump is a deliberate release +Do not default to a homegrown PTY / Landlock / seccomp stack. Take the +execution subgraph through an isolated workspace after the table above. +Pin bump is a deliberate release ([upstream-update.md](upstream-update.md)): SHA + patch parity now; -later runtime-adapter build plus PTY / sandbox / process regressions -when that workspace exists. +runtime-adapter build plus PTY / sandbox / process regressions when +those crates are taken. -Domain expansion (PermissionProfile axes, Environment, scheduler, -approval tools) is sequenced in -[execution-substrate.md](execution-substrate.md). None of that changes -live MCP schemas in this work package. +Domain expansion remaining (scheduler queue, approval tools) is +sequenced in [execution-substrate.md](execution-substrate.md). None of +that changes live MCP schemas in this work package. diff --git a/docs/execution-substrate.md b/docs/execution-substrate.md index 26191b7..713a403 100644 --- a/docs/execution-substrate.md +++ b/docs/execution-substrate.md @@ -41,7 +41,7 @@ bump. Re-check graphs after a deliberate W13 update. CI: `policy-scan` job runs `scripts/check-no-model-deps.sh` **without** submodules, in parallel with `rust`. Core manifests may not declare `codex-*` deps. Core sources keep the agent/model grep. Isolated -adapter manifests (`crates/patch`, later `crates/codex-runtime`) use an +adapter manifests (`crates/patch`, `crates/codex-runtime`) use an **allowlist**; `third_party/codex` sources are never scanned. `SCAN_BASE` limits the tree to the update range; unknown range scans all core crates and adapter manifests. Clippy/tests still always run. @@ -57,14 +57,20 @@ The rust job checks the pin SHA (`PIN_ONLY=1`) **before** fmt/clippy. | Process handles that outlive an MCP connection | Copy App Server “kill on connection close” | | Isolated `crates/patch` → `codex-apply-patch` | Embed `codex-app-server` / `codex-exec` / `codex-core` | -`read-only` / `workspace-write` stay the live profiles. Richer -filesystem glob + network axes are a **future `crates/policy` type**, -not an import of Codex user config. +`read-only` / `workspace-write` stay the live MCP profiles. Richer +filesystem glob + network axes live in `crates/policy` as +`PermissionProfile`, mapped from those profiles. `process_exec` is the +Exec axis (`read-only` denies, `workspace-write` allows). Path globs are +**domain only**; live enforcement stays coarse `allow(Write|Exec)` plus +PathSandbox. The network axis is recorded only; it does not grant. This +is not an import of Codex user config. ## Four axes (target domain) Not all of these are MCP fields today. **Do not add `environment_id` -to live tools in this work package.** +to live tools.** Operator JSON may register environments. Omitted +environment is the implicit local host. Unknown environment ids fail +config load. `linux-container` loads but exec/patch fail closed. ```text Environment where command and filesystem ops run @@ -81,8 +87,9 @@ Process / Patch / FS - **Workspace** stays the selector on MCP (`workspace_id` + relative path). Internally the runner may resolve to an absolute path. - **PermissionProfile** shape (Read / Write / Deny on path, glob, or - special roots; separate network axis) may follow App Server. The - **engine that grants** is CodeSpace policy. + special roots; `process_exec`; separate network axis) may follow App + Server. Globs are expressed, not live-enforced. The **engine that + grants** is CodeSpace policy. - **Operation** is already `operation_id` / `operation_key` / `operation_status`. Diff/audit ledger is P1, not conversation history. @@ -106,8 +113,11 @@ optional process id, tty, stdin/stdout streaming, output cap, timeout, cwd, env, PTY size, `sandboxPolicy` / `permissionProfile`. Follow-ups: write, resize, terminate. Streaming is `outputDelta`. -That **shape** is the long-term Runner DTO target (plus -`process_resize` when PTY exists). Live MCP remains: +That **shape** is on Runner DTOs today (plus `process_resize` when PTY +exists). Gateway fills `cwd: WorkspaceRoot`, runner-local env defaults +(`PATH` / `HOME` / `LANG` applied in the runner process; not the +gateway `PATH` or a host absolute cwd), timeout, output cap, +`tty: false`, and a policy summary. Live MCP remains: ```text exec_command / write_stdin / read_process / terminate_process @@ -127,10 +137,13 @@ keep that out of the product graph) plus `codex-network-proxy` when a network axis exists. A container does not replace that subgraph. App Server streaming processes are connection-scoped and die when that -connection closes. CodeSpace keeps **request lifetime ≠ process +connection closes. CodeSpace keeps **MCP request lifetime ≠ process lifetime**. `process_id` is server-minted and stored as application -state. Later disconnect policy may be continue / terminate / -grace-period — not “socket closed ⇒ kill.” +state. Ending an MCP request does not kill a live process. The opt-in +UDS path is different: it is 1:1 Gateway ↔ worker. UDS disconnect or +gateway shutdown kills the worker (host children die). `process_id` +does not survive worker death. Runner `Replay` is a same-connection +primitive, not disconnect recovery. ## Approval and MCP revision @@ -161,7 +174,14 @@ handles. Today one workspace write lock plus shell occupancy is enough. App Server serializes by resource (exclusive vs shared read). The target scopes are Environment, Workspace, Path, Process, Operation, Watch — -not Thread. Do not change `crates/store` in this work package. +not Thread. `crates/store` now uses an in-memory resource serializer +for those keys. SQLite schema is unchanged. MVP takes request-owned +exclusive for `apply_patch` and process-owned exclusive for live shells +(`WORKSPACE_BUSY`); `ProcessExited` (or in-process exit) calls +`release_process`. Confirmed UDS worker death releases **all** +process-owned leases; a lost/ambiguous response by itself does not. +`read` / `find` stay unlocked. Shared-read is typed +only. ## `fs/watch` and search @@ -211,14 +231,19 @@ resource. ## Roadmap (implementation later) -This work package is documentation and a dependency check only. +P0 code for this substrate is in: Runner exec DTO **shape** +(`RunnerCwd::WorkspaceRoot`, runner-local env defaults), +`PermissionProfile` (`process_exec`) and Environment in `crates/policy`, +resource serializer (request vs process owners), opt-in `UdsRunner` + +`codespace-codex-runtime` (process-hardening + UDS). MCP schemas stay +frozen. -**P0** — already or next code WPs: `codex-apply-patch` (done), exec -runtime **shape** on Runner DTOs, PermissionProfile domain in -`crates/policy`, Environment domain (operator-registered; not a tool -arg yet), resource serializer, then transport (`ContainerRunner`) -taking the staged subgraph (process-hardening → PTY → UDS/path → -filesystem → linux-sandbox → network). +**P0** — landed or next subgraph WPs: `codex-apply-patch` (done), exec +runtime **shape** on Runner DTOs (done), PermissionProfile domain in +`crates/policy` (done), Environment domain (operator-registered; not a +tool arg) (done), resource serializer (done), transport +(`UdsRunner`) with process-hardening + UDS (done, opt-in). Still +out: PTY → filesystem → linux-sandbox → network. **P1** — operation state machine / diff ledger, approval fallback tools, internal watch, richer process handles (resize, caps), @@ -229,7 +254,7 @@ MCP contract, deterministic hooks, skills as resources or prompts. **P3** — remote environment, MCP federation, artifact registry. -The next **code** WP remains Runner **transport** behind the existing -trait, without splitting `apply_patch` into gateway RPCs. Sandbox / PTY -/ network are not a default homegrown OS stack +The next **code** WPs are remaining execution subgraph crates behind +the existing trait, without splitting `apply_patch` into gateway RPCs. +Sandbox / PTY / network are not a default homegrown OS stack ([codex-reuse.md](codex-reuse.md)). diff --git a/docs/ko/architecture.md b/docs/ko/architecture.md index c20bf7f..c6f92b1 100644 --- a/docs/ko/architecture.md +++ b/docs/ko/architecture.md @@ -39,9 +39,12 @@ TypeScript MCP 게이트웨이, 폐기된 `native/patch-worker` 트리, 또는 ## 현재 배치 -MVP는 **호스트 프로세스 하나**입니다: `codespace-mcp`. `exec_command`는 -컨테이너로 디스패치되지 않습니다. [`deploy/compose.yml`](../../deploy/compose.yml)은 -격리 **픽스처**일 뿐입니다. +MVP 기본은 **호스트 프로세스 하나**입니다: `codespace-mcp`와 프로세스 +내부 `Runner`. `exec_command`는 compose로 디스패치되지 않습니다. +[`deploy/compose.yml`](../../deploy/compose.yml)은 격리 **픽스처**일 +뿐입니다. 선택적 Unix 소켓 전송(`CODESPACE_RUNNER=uds`)은 CodeSpace +JSON으로 `codespace-codex-runtime`과 대화합니다. 그것은 Linux 격리가 +아니고 기본값도 아닙니다. ```text CURRENT @@ -54,39 +57,51 @@ codespace-mcp (host gateway) ├─ structured logging (stderr tracing) │ │ Runner execution DTO - │ (no work_id / operation_id / coordination) + │ (command/exec shape; no work_id / operation_id / coordination) ▼ -InProcessRunner - ├─ read / find / version (PathSandbox) - ├─ apply_patch (one transaction) - │ expected versions → preflight → snapshot - │ → helper apply → verify → rollback - │ │ JSON stdin/stdout - │ ▼ - │ codespace-patch (host child) - │ └─ Codex Rust crate in-process - └─ exec / stdin / read / terminate - └─ host process (tokio::process::Command, - workspace cwd, env_clear) +RuntimeBackend + ├─ default: InProcessRunner + └─ opt-in: UdsRunner (CODESPACE_RUNNER=uds, 1:1) + │ length-prefixed CodeSpace JSON (protocol 1, Hello, request_id rrpc-…) + ▼ + codespace-codex-runtime (게이트웨이 RuntimeProcess가 소유) + ├─ unique 0700 dir + runner.sock (bind only; 부모 chmod 없음) + ├─ codex-process-hardening (main 첫 줄, command sandbox 아님) + ├─ 연결 끊김 / 게이트웨이 종료 → 워커 종료 (호스트 자식도 죽음) + └─ InProcessRunner 하나 + ├─ read / find / version (PathSandbox) + ├─ apply_patch (one transaction) + │ expected versions → preflight → snapshot + │ → helper apply → verify → rollback + │ │ JSON stdin/stdout + │ ▼ + │ codespace-patch (host child) + │ └─ Codex Rust crate in-process + └─ exec / stdin / read / terminate + └─ host process (tokio::process::Command, + cwd = workspace root, env from runner-local defaults) deploy/compose.yml └─ isolation fixture only; not connected to exec_command ``` ```text -MCP JSON → domain params → gateway (policy/store) → Runner DTO → InProcessRunner +MCP JSON → domain params → gateway (policy/store) → Runner DTO → RuntimeBackend │ └─ rmcp / JsonSchema stay on MCP types, not on runner DTOs ``` 게이트웨이는 **어느 워크스페이스에서 무엇을 해도 되는지**를 소유합니다. -토큰, 서버 설정, 워크스페이스 레지스트리, operations 데이터베이스가 +토큰, 서버 설정, 워크스페이스 레지스트리, 환경, operations 데이터베이스가 여기에 있습니다. MCP 파라미터를 러너 DTO로 매핑하며 -`ExecCommandParams`를 러너에 넘기지 **않습니다**. +`ExecCommandParams`를 러너에 넘기지 **않습니다**. 도구에는 여전히 +`environment_id`가 없습니다. -`crates/runner`는 프로세스 내부 `Runner`(파일시스템, `apply_patch` -트랜잭션 하나, 호스트 프로세스 감독)와 compose 픽스처 검사를 -소유합니다. 컨테이너를 시작하거나 제어 소켓을 열지 **않습니다**. +`crates/runner`는 `Runner` 트레이트, 실행 DTO, `InProcessRunner` +(파일시스템, `apply_patch` 트랜잭션 하나, 호스트 프로세스 감독), +`UdsRunner`(Unix 소켓 클라이언트), compose 픽스처 검사를 +소유합니다. 기본 백엔드는 프로세스 내부입니다. 워커 바이너리는 격리된 +`crates/codex-runtime` / `codespace-codex-runtime`입니다. `codespace-patch`는 제품 헬퍼 프로세스이며, 업스트림 독립 `apply_patch` 바이너리가 아니고 `native/patch-worker`도 아닙니다. Runner ↔ 헬퍼는 @@ -94,11 +109,12 @@ JSON stdin/stdout입니다. Codex 자체는 **그 헬퍼 안에서** 프로세 내부로 실행됩니다. MVP에는 단일 인스턴스로 충분합니다. SQLite는 **패치 작업**과 -works/intents를 저장합니다. 프로세스 핸들과 write/shell 리스는 -메모리에 있습니다. 메시지 브로커는 없습니다. +works/intents를 저장합니다. 프로세스 핸들과 자원 잠금(워크스페이스 +배타 쓰기 / 셸 점유)은 메모리에 있습니다. 메시지 브로커는 없습니다. 게이트웨이 단위 시험은 macOS 개발 호스트에서 실행할 수 있습니다. Linux -컨테이너는 **목표** 격리 OS이며 현재 exec 경계가 아닙니다. +컨테이너는 **목표** 격리 OS이며 현재 exec 경계가 아닙니다. 등록된 +`linux-container` 환경은 닫힌 실패입니다(`UNAUTHORIZED`). ## 목표 배치 @@ -127,12 +143,15 @@ Runner process boundary ``` 목표 **도메인**(실제 MCP 필드 아님): Environment(어디), Workspace(무엇), -PermissionProfile(해도 되는지), Operation(이 RPC). 그 WP 전까지 도구에 -`environment_id`를 넣지 마세요. +PermissionProfile(해도 되는지), Operation(이 RPC). 도구에 +`environment_id`를 넣지 마세요. 운영자 설정은 환경을 등록할 수 +있습니다. [execution-substrate.md](execution-substrate.md)를 보세요. -다음 **구현** 작업 패키지는 기존 `Runner` / `InProcessRunner` 타입 뒤의 -**전송**(Unix 소켓 / `ContainerRunner`)입니다. 패치 적용을 게이트웨이가 +Unix 소켓 **전송**(`UdsRunner`)은 기존 `Runner` / `InProcessRunner` +타입 뒤에 선택적으로 있습니다. Host + UDS는 같은 호스트이며 Linux +격리를 주장하지 않습니다. `LinuxContainer`는 닫힌 실패입니다 +(`UNAUTHORIZED`, `operation_id` 없음). 패치 적용을 게이트웨이가 구동하는 여러 RPC로 **쪼개면 안 됩니다**. ```text @@ -142,14 +161,21 @@ Runner.apply_patch(request) ``` 게이트웨이는 인가, `operation_key` 재실행, 쓰기 잠금, 디스패치, -영속을 유지합니다. 지금은 러너 제어 소켓이 없습니다. - -Sandbox, PTY, UDS, 네트워크 격리는 **“기본적으로 Codex OS 공학을 -재구현”이 아닙니다.** `crates/patch`(이후 `crates/codex-runtime` / -`codespace-codex-runtime`)와 같은 패턴으로, Runner 뒤에 격리된 응집력 -있는 실행 서브그래프를 선호합니다. Codex 타입은 어댑터에 남습니다. -App Server나 `codex-exec`를 넣지 마세요. `codex-exec-server`는 미래 -측정이며 현재 백엔드가 아닙니다. +영속을 유지합니다. 기본은 `InProcessRunner`입니다. 선택적 +`CODESPACE_RUNNER=uds`는 비공개 Unix 소켓(unique 0700 leaf, +`runner.sock`)을 쓰며 compose 픽스처가 아닙니다. 게이트웨이가 워커를 +1:1로 소유합니다(`RuntimeProcess`, `kill_on_drop`). 재연결은 없습니다. +살아있는 소켓은 connect-probe로 확인하고 leftover는 +`ConnectionRefused`일 때만 unlink합니다. 러너 `Replay`는 같은 연결에서만 +동작합니다. + +Sandbox, PTY, 네트워크 격리는 **“기본적으로 Codex OS 공학을 +재구현”이 아닙니다.** `crates/patch`와 `crates/codex-runtime` / +`codespace-codex-runtime`과 같은 패턴으로, Runner 뒤에 격리된 응집력 +있는 실행 서브그래프를 선호합니다. 이 WP는 `codex-process-hardening`과 +`codex-uds`를 가져옵니다. Codex 타입은 어댑터에 남습니다. App Server나 +`codex-exec`를 넣지 마세요. `codex-exec-server`는 미래 측정이며 현재 +백엔드가 아닙니다. ## 프로토콜 호환성 @@ -204,9 +230,11 @@ Codex 제품 대 프리미티브: [codex-reuse.md](codex-reuse.md). ## ID -HTTP/JSON-RPC 요청 id, `operation_id`, `process_id`, `work_id`, -`intent_id`는 서로 다른 식별자입니다. 잃어버린 HTTP 응답은 실행 실패가 -아닙니다. 클라이언트는 변경 도구를 재실행하는 대신 `operation_status`를 +HTTP/JSON-RPC 요청 id, `operation_id`, `operation_key`, `process_id`, +`work_id`, `intent_id`, 러너 `request_id`(`rrpc-…`)는 서로 다른 +식별자입니다. 잃어버린 HTTP 응답은 실행 실패가 아닙니다. 유실된 UDS +`apply_patch` 응답은 `rejected`가 아니라 `unknown`으로 기록됩니다. +클라이언트는 변경 도구를 재실행하는 대신 `operation_status`를 호출합니다. ```text @@ -251,8 +279,9 @@ validate request `apply_patch`는 `git apply`로 조용히 폴백하지 않습니다. 상태 값은 `applied` / `checked` / `rejected` / `failed_rolled_back` / `failed_partial` / `unknown`입니다. `checked`는 성공한 `check_only` -미리보기(쓰기 없음)입니다. `rejected`는 실제 거절입니다. after-version -검증 없는 성공 문구는 금지입니다. +미리보기(쓰기 없음)입니다. `rejected`는 실제 거절입니다. 전송이 모호하면 +(디스패치 후 소켓 끊김) `unknown`으로 끝내며 `rejected`로 저장하면 안 +됩니다. after-version 검증 없는 성공 문구는 금지입니다. 롤백은 `git reset --hard`를 쓰면 안 되고, 파일별 복원 대신 디렉터리 트리 전체를 덮어쓰면 안 됩니다. @@ -263,10 +292,11 @@ validate request Cargo.toml workspace root crates/server/ bin codespace-mcp: rmcp stdio + Streamable HTTP + /inbox crates/domain/ workspace, capabilities, operation, errors (no rmcp) -crates/policy/ registry and path policy +crates/policy/ registry, PermissionProfile, Environment crates/patch/ Codex adapter + codespace-patch helper (own workspace) -crates/store/ SQLite operations, works, and intents -crates/runner/ Runner trait + execution DTOs, PathSandbox, patch transaction, host process supervisor, fixture checks +crates/codex-runtime/ isolated worker: hardening + UDS + InProcessRunner +crates/store/ SQLite operations, works, intents; in-memory resource locks +crates/runner/ Runner trait + execution DTOs, PathSandbox, patch transaction, host supervisor, UdsRunner, fixture checks third_party/codex/ git submodule, pinned revision (W06) tests/{security,recovery,e2e}/ docs/ including operations.md (W12), codex-reuse.md, diff --git a/docs/ko/codex-reuse.md b/docs/ko/codex-reuse.md index b5e609c..3befa45 100644 --- a/docs/ko/codex-reuse.md +++ b/docs/ko/codex-reuse.md @@ -44,16 +44,15 @@ CodeSpace Core ← only authorization authority │ ▼ isolated adapter workspace - │ crates/patch today (codespace-patch) - │ crates/codex-runtime later - │ crate name: codespace-codex-runtime - │ (documented only; not created in this WP) + │ crates/patch (codespace-patch) + │ crates/codex-runtime (codespace-codex-runtime) + │ process-hardening + UDS worker; opt-in ▼ Codex execution subgraph (pinned) → OS ``` Codex는 어댑터 뒤의 **구현** 의존성이며 Gateway의 아키텍처 의존성이 -아닙니다. `InProcessRunner`, `ContainerRunner`, 이후 원격 러너, 또는 +아닙니다. `InProcessRunner`, `UdsRunner`, 이후 원격 러너, 또는 다른 샌드박스 백엔드는 Codex 타입이 어댑터를 떠나지 않으면 바뀔 수 있습니다. @@ -94,7 +93,7 @@ CodeSpace core isolated adapter (crates/patch today; -crates/codex-runtime later) +crates/codex-runtime today) ────────────────────────────────────────── approved execution subgraph allowed including transitive codex-protocol @@ -139,15 +138,14 @@ Runner helper - 핀은 [upstream-lock.md](upstream-lock.md)에 남습니다 (`6b9826e3aa83b1a5947db50f4332cb9c65f1b340`). - **격리된** Cargo 워크스페이스에서의 경로 의존성이며 저장소 루트가 - 아닙니다. 오늘: `crates/patch`. 이후: `crates/codex-runtime` - (`codespace-codex-runtime`. 문서만. **이 작업 패키지에서 만들지 - 않음**). + 아닙니다. 오늘: `crates/patch`와 `crates/codex-runtime` + (`codespace-codex-runtime`). - NOTICE + Apache-2.0 귀속. - 제품 정책은 서브그래프 앞과 뒤에 남습니다. - Codex 워크스페이스에서 크레이트를 파일 복사하지 마세요. ```text -Gateway → Runner trait → (later) ContainerRunner +Gateway → Runner trait → UdsRunner (opt-in) → codespace-codex-runtime helper → Codex execution crates ``` @@ -162,7 +160,7 @@ checkout과 cargo가 지배합니다. | core manifests | root + `crates/{domain,policy,runner,store,server}/Cargo.toml` | tiny | crate in the update range | | core sources | those crates’ trees | low | same | | server tests | `tests/` | low | `crates/server` or `tests/` changed | -| adapter manifests | `crates/patch/Cargo.toml`; later `crates/codex-runtime` | tiny | adapter in the update range; allowlist only | +| adapter manifests | `crates/patch/Cargo.toml`; `crates/codex-runtime` | tiny | adapter in the update range; allowlist only | | upstream | `third_party/codex` | huge / false positives | never | 갱신 범위는 `SCAN_BASE`(PR base / 이전 `main`)입니다. 범위를 모르면 @@ -173,7 +171,8 @@ checkout과 cargo가 지배합니다. 핵심 매니페스트는 어떤 `codex-` 의존성 키도 금지합니다. 어댑터 매니페스트는 승인된 서브그래프만 허용합니다(오늘 `crates/patch`: `codex-apply-patch`, apply-patch 워크스페이스 그래프로서 -`codex-exec-server`, `codex-utils-path-uri`). 소스는 에이전트/모델 +`codex-exec-server`, `codex-utils-path-uri`, `codex-process-hardening`; +`crates/codex-runtime`: `codex-process-hardening`, `codex-uds`). 소스는 에이전트/모델 패턴을 유지합니다(`api.openai.com`, Responses, `codex-login`, `codex-core`, `codex-app-server`, `async-openai`). 크레이트 이름을 언급하는 주석은 cargo 의존성이 아닙니다. @@ -184,10 +183,10 @@ Server를 내부 백엔드로 감싸지 **마세요**. ## 감독 코드가 아직 있는 이유 `process_id`, stdin, terminate, timeout이 모이는 이유는 요청 수명이 -프로세스 수명이 아니기 때문입니다. 프로세스 내부 감독은 Runner 전송이 -생길 때까지 CodeSpace에 남습니다. `operation_key` / `operation_status`는 -잃어버린 **원격 MCP 변경 RPC**를 복구하며, Codex 스레드를 복구하지 -않습니다. +프로세스 수명이 아니기 때문입니다. 프로세스 내부 감독이 **기본**입니다. +선택적 `UdsRunner`도 그 감독을 `codespace-codex-runtime` 안에서 +돌립니다. `operation_key` / `operation_status`는 잃어버린 **원격 MCP +변경 RPC**를 복구하며, Codex 스레드를 복구하지 않습니다. spawn+PTY를 얻으려고 `codex-core` / `codex-exec` / App Server를 끌어오면 login, models, plugins, rollout도 따라옵니다. 그 폭발 반경은 여전히 @@ -195,7 +194,8 @@ login, models, plugins, rollout도 따라옵니다. 그 폭발 반경은 여전 ## 단계적 가져오기 (그 WP가 생길 때) -문서화된 순서입니다. **이 작업 패키지에서 크레이트를 추가하지 않습니다.** +문서화된 순서입니다. **이 WP에서 코드로 가져옴:** process-hardening과 +UDS. **아직 안 가져옴:** PTY, filesystem, linux-sandbox, network. ```text process-hardening → PTY → UDS / path → filesystem → linux-sandbox → network @@ -213,14 +213,14 @@ Codex `main`이 아니라 핀의 `Cargo.toml` 파일로 판단합니다. **`codex-apply-patch`** via `crates/patch`. 파싱, 헝크 검증, 적용, 패리티 부분집합. -### 재사용 선호 (그 WP가 올 때) +**`codex-process-hardening`** via `codespace-patch`와 +`codespace-codex-runtime` `pre_main_hardening()`. 워커/헬퍼 **프로세스** +강화이지 command sandbox가 아닙니다. `main` 첫 줄로 유지하고, 의존성 +폭이 커지지 않는 한 `ctor`는 넣지 않습니다. -**`codex-process-hardening`** -([`codex-rs/process-hardening/Cargo.toml`](../../third_party/codex/codex-rs/process-hardening/Cargo.toml)) +**`codex-uds`** via `codespace-codex-runtime` bind. RPC는 CodeSpace. -런타임 의존성은 `libc`입니다. 코어 덤프, `PR_SET_DUMPABLE`, macOS -디버거 연결, `LD_*` / `DYLD_*` 제거. 첫 재사용 후보: 다시 짜도 -CodeSpace가 얻는 독립은 거의 없습니다. +### 재사용 선호 (그 WP가 올 때) **`codex-utils-pty`** ([`codex-rs/utils/pty/Cargo.toml`](../../third_party/codex/codex-rs/utils/pty/Cargo.toml)) @@ -229,12 +229,11 @@ Unix: `portable-pty`, `tokio`, `libc`. CodeSpace PTY보다 업스트림을 선호하세요. 연결해도 PTY MCP 도구가 추가되지는 **않습니다**. 게이트웨이가 여전히 `process_id`를 발급합니다. -**`codex-uds`** +**`codex-uds`** (이미 `codespace-codex-runtime`에 있음) ([`codex-rs/uds/Cargo.toml`](../../third_party/codex/codex-rs/uds/Cargo.toml)) -Unix: Tokio `fs` / `net` / `rt`. 다음 Runner Unix 소켓 전송에 맞습니다. -**RPC 프로토콜은 CodeSpace 소유로 남습니다.** 이 크레이트는 전송 -프리미티브입니다. +Unix: Tokio `fs` / `net` / `rt`. 선택적 Runner Unix 소켓 워커의 소켓 +프리미티브입니다. **RPC 프로토콜은 CodeSpace 소유로 남습니다.** **`codex-utils-absolute-path` / `codex-utils-path-uri`** @@ -303,7 +302,7 @@ Linux에서 Landlock/seccompiler. **핵심에서 금지.** 어댑터에서는 HTTP/WS plus `codex-api`, `codex-config`, OTel, protocol, sandboxing, PTY. 오늘의 Runner 백엔드로는 너무 무겁습니다. 영구 거절은 아닙니다. -나중에 ContainerRunner + 저수준 크레이트 대 Gateway 어댑터 → +나중에 UdsRunner + 저수준 크레이트 대 Gateway 어댑터 → exec-server를 비교하세요. 컴파일 그래프와 업그레이드 비용을 재세요. ### 이후 Environment (P0 아님) @@ -339,24 +338,24 @@ rollout, history. 제품 exec 흐름이지 `spawn`이 아닙니다. - 워크스페이스 레지스트리, 프로필의 **의미**, 경로 정책. - 쓰기 잠금, 셸 점유, `WORKSPACE_BUSY`(스케줄러 WP까지). - `operation_key` 재실행, `operation_id`, `operation_status`. -- Runner 전송이 생길 때까지 호스트/프로세스 내부 프로세스 감독. +- 호스트/프로세스 내부 프로세스 감독이 기본. UDS 워커는 선택적. - 컨테이너 수명주기와 워크스페이스 바인드 마운트 **정책**. -- 격리된 어댑터 워크스페이스(`crates/patch`, 이후 +- 격리된 어댑터 워크스페이스(`crates/patch`, `crates/codex-runtime` / `codespace-codex-runtime`). ## 다음 구현 WP -다음 **코드** 작업 패키지는 여전히 기존 `Runner` 트레이트 뒤의 Runner -**전송**(Unix 소켓 / `ContainerRunner`)입니다. 그 WP는 `apply_patch`를 -게이트웨이가 구동하는 여러 RPC로 쪼개면 안 됩니다. +다음 **코드** 작업 패키지는 기존 `Runner` 트레이트 뒤의 남은 실행 +서브그래프(PTY, filesystem, linux-sandbox, network)입니다. +`apply_patch`를 게이트웨이가 구동하는 여러 RPC로 쪼개면 안 됩니다. -기본으로 자체 PTY / Landlock / seccomp / UDS 스택을 두지 마세요. +기본으로 자체 PTY / Landlock / seccomp 스택을 두지 마세요. 위 표 이후 격리된 워크스페이스를 통해 실행 서브그래프를 가져오세요. 핀 범프는 의도적 릴리스입니다 ([upstream-update.md](upstream-update.md)): 지금은 SHA + 패치 패리티. -그 워크스페이스가 생기면 런타임 어댑터 빌드와 PTY / sandbox / process +그 크레이트를 가져가면 런타임 어댑터 빌드와 PTY / sandbox / process 회귀를 더합니다. -도메인 확장(PermissionProfile 축, Environment, 스케줄러, 승인 도구)은 +남은 도메인 확장(스케줄러 큐, 승인 도구)은 [execution-substrate.md](execution-substrate.md)에서 순서를 정합니다. 이 작업 패키지에서 실제 MCP 스키마는 바뀌지 않습니다. diff --git a/docs/ko/execution-substrate.md b/docs/ko/execution-substrate.md index fe588d8..f4acf97 100644 --- a/docs/ko/execution-substrate.md +++ b/docs/ko/execution-substrate.md @@ -41,7 +41,7 @@ Codex `main` `4701aa4b`를 언급하는 조사 노트는 핀 범프가 **아닙 CI: `policy-scan` job은 서브모듈 **없이** `scripts/check-no-model-deps.sh`를 `rust`와 병렬로 실행합니다. 핵심 매니페스트는 `codex-*` 의존성을 선언하면 안 됩니다. 핵심 소스는 에이전트/모델 grep을 유지합니다. 격리된 어댑터 -매니페스트(`crates/patch`, 이후 `crates/codex-runtime`)는 +매니페스트(`crates/patch`, `crates/codex-runtime`)는 **허용 목록**을 사용합니다. `third_party/codex` 소스는 절대 스캔하지 않습니다. `SCAN_BASE`는 트리를 갱신 범위로 제한합니다. 범위를 모르면 모든 핵심 크레이트와 어댑터 매니페스트를 스캔합니다. Clippy/시험은 @@ -58,14 +58,20 @@ CI: `policy-scan` job은 서브모듈 **없이** `scripts/check-no-model-deps.sh | Process handles that outlive an MCP connection | Copy App Server “kill on connection close” | | Isolated `crates/patch` → `codex-apply-patch` | Embed `codex-app-server` / `codex-exec` / `codex-core` | -`read-only` / `workspace-write`가 실제 프로필로 남습니다. 더 풍부한 -파일시스템 glob + 네트워크 축은 **이후 `crates/policy` 타입**이며, -Codex 사용자 설정을 가져오는 것이 아닙니다. +`read-only` / `workspace-write`가 실제 MCP 프로필로 남습니다. 더 풍부한 +파일시스템 glob + 네트워크 축은 `crates/policy`의 `PermissionProfile`에 +있고 그 프로필에서 매핑됩니다. `process_exec`가 Exec 축입니다 +(`read-only`는 거부, `workspace-write`는 허용). 경로 glob은 **표현만** +있고 live enforcement는 기존 coarse `allow(Write|Exec)` + PathSandbox입니다. +네트워크 축은 기록만 하며 허용을 올리지 않습니다. Codex 사용자 설정을 +가져오는 것이 아닙니다. ## 네 축 (목표 도메인) -이 모두가 오늘 MCP 필드는 아닙니다. **이 작업 패키지에서 실제 도구에 -`environment_id`를 추가하지 마세요.** +이 모두가 오늘 MCP 필드는 아닙니다. **실제 도구에 `environment_id`를 +추가하지 마세요.** 운영자 JSON은 환경을 등록할 수 있습니다. 생략하면 +암시적 로컬 호스트입니다. 알 수 없는 environment id는 설정 로드에 +실패합니다. `linux-container`는 로드되지만 exec/patch는 닫힌 실패입니다. ```text Environment where command and filesystem ops run @@ -82,7 +88,8 @@ Process / Patch / FS - **Workspace**는 MCP의 선택자로 남습니다(`workspace_id` + 상대 경로). 내부에서 러너는 절대 경로로 해석할 수 있습니다. - **PermissionProfile** 형태(경로, glob, 또는 특수 루트에 대한 Read / - Write / Deny, 별도 네트워크 축)는 App Server를 따를 수 있습니다. + Write / Deny, `process_exec`, 별도 네트워크 축)는 App Server를 따를 + 수 있습니다. glob은 표현만 있고 live enforce 하지 않습니다. **부여하는 엔진**은 CodeSpace 정책입니다. - **Operation**은 이미 `operation_id` / `operation_key` / `operation_status`입니다. Diff/감사 원장은 P1이며 대화 이력이 @@ -107,8 +114,11 @@ Runner / patch helper cwd, env, PTY 크기, `sandboxPolicy` / `permissionProfile`이 있습니다. 후속: write, resize, terminate. 스트리밍은 `outputDelta`입니다. -그 **형태**가 장기 Runner DTO 목표입니다(PTY가 생기면 `process_resize` -포함). 실제 MCP는 그대로입니다. +그 **형태**가 오늘 Runner DTO에 있습니다(PTY가 생기면 `process_resize` +포함). 게이트웨이가 `cwd: WorkspaceRoot`, 러너 로컬 env 기본값(`PATH` / +`HOME` / `LANG`은 러너 프로세스에서 적용, 게이트웨이 `PATH`나 호스트 +절대 cwd를 직렬화하지 않음), 타임아웃, 출력 한도, `tty: false`, 정책 +요약을 채웁니다. 실제 MCP는 그대로입니다. ```text exec_command / write_stdin / read_process / terminate_process @@ -128,10 +138,12 @@ exec_command / write_stdin / read_process / terminate_process 대체하지 않습니다. App Server 스트리밍 프로세스는 연결 범위이며 그 연결이 닫히면 죽습니다. -CodeSpace는 **요청 수명 ≠ 프로세스 수명**을 유지합니다. `process_id`는 -서버가 발급하고 애플리케이션 상태로 저장합니다. 이후 연결 끊김 정책은 -continue / terminate / grace-period일 수 있으며, “소켓이 닫힘 ⇒ 종료”가 -아닙니다. +CodeSpace는 **MCP 요청 수명 ≠ 프로세스 수명**을 유지합니다. `process_id`는 +서버가 발급하고 애플리케이션 상태로 저장합니다. MCP 요청이 끝나도 살아 +있는 프로세스를 죽이지 않습니다. 선택적 UDS 경로는 다릅니다. 게이트웨이 ↔ +워커는 1:1입니다. UDS 연결 끊김이나 게이트웨이 종료는 워커를 죽입니다 +(호스트 자식도 죽습니다). `process_id`는 워커 죽음 이후 살아남지 않습니다. +러너 `Replay`는 같은 연결 프리미티브이며 연결 끊김 복구가 아닙니다. ## 승인과 MCP 리비전 @@ -159,8 +171,14 @@ continue / terminate / grace-period일 수 있으며, “소켓이 닫힘 ⇒ 오늘은 워크스페이스 쓰기 잠금 하나와 셸 점유로 충분합니다. App Server는 자원별로 직렬화합니다(배타 대 공유 읽기). 목표 범위는 Environment, -Workspace, Path, Process, Operation, Watch이며 Thread가 아닙니다. 이 -작업 패키지에서 `crates/store`를 바꾸지 마세요. +Workspace, Path, Process, Operation, Watch이며 Thread가 아닙니다. +`crates/store`는 이제 그 키를 위한 메모리 자원 직렬화기를 씁니다. +SQLite 스키마는 그대로입니다. MVP는 `apply_patch`에 요청 소유 배타, +라이브 셸에 프로세스 소유 배타를 씁니다(`WORKSPACE_BUSY`). +`ProcessExited`(또는 프로세스 내부 종료)가 `release_process`를 호출합니다. +확인된 UDS 워커 죽음은 프로세스 소유 임대를 **모두** 풉니다. 응답 +유실/모호함만으로는 풀지 않습니다. +`read` / `find`는 잠금이 없습니다. Shared-read는 타입만 유지합니다. ## `fs/watch`와 검색 @@ -208,14 +226,19 @@ Approval → policy + human, Attachment → artifact 리소스. ## 로드맵 (구현은 나중) -이 작업 패키지는 문서와 의존성 검사뿐입니다. +이 기반의 P0 코드는 들어와 있습니다. Runner exec DTO **형태** +(`RunnerCwd::WorkspaceRoot`, 러너 로컬 env 기본값), +`crates/policy`의 `PermissionProfile`(`process_exec`)과 Environment, +자원 직렬화기(요청 vs 프로세스 소유), 선택적 `UdsRunner` + +`codespace-codex-runtime`(process-hardening + UDS). MCP 스키마는 +그대로입니다. -**P0** — 이미 있거나 다음 코드 WP: `codex-apply-patch`(완료), Runner -DTO의 exec 런타임 **형태**, `crates/policy`의 PermissionProfile 도메인, -Environment 도메인(운영자 등록, 아직 도구 인자 아님), 자원 직렬화기, -그다음 단계적 서브그래프를 받는 전송(`ContainerRunner`) -(process-hardening → PTY → UDS/path → filesystem → linux-sandbox → -network). +**P0** — 착수했거나 다음 서브그래프 WP: `codex-apply-patch`(완료), +Runner DTO의 exec 런타임 **형태**(완료), `crates/policy`의 +PermissionProfile 도메인(완료), Environment 도메인(운영자 등록, 도구 +인자 아님)(완료), 자원 직렬화기(완료), process-hardening + UDS를 받는 +전송(`UdsRunner`)(완료, 선택적). 아직 밖: PTY → filesystem → +linux-sandbox → network. **P1** — 작업 상태 기계 / diff 원장, 승인 폴백 도구, 내부 watch, 더 풍부한 프로세스 핸들(resize, caps), 연결 끊김 정책. @@ -225,7 +248,7 @@ network). **P3** — 원격 환경, MCP 연합, 아티팩트 레지스트리. -다음 **코드** WP는 기존 트레이트 뒤의 Runner **전송**으로 남으며, +다음 **코드** WP는 기존 트레이트 뒤의 남은 실행 서브그래프이며, `apply_patch`를 게이트웨이 RPC로 쪼개지 않습니다. Sandbox / PTY / network는 기본 자체 OS 스택이 아닙니다 ([codex-reuse.md](codex-reuse.md)). diff --git a/docs/ko/operations.md b/docs/ko/operations.md index 0d797e3..bedb584 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -27,8 +27,9 @@ Codex 핀은 [upstream-lock.md](upstream-lock.md)의 커밋에 있는 [codex-reuse.md](codex-reuse.md)와 [execution-substrate.md](execution-substrate.md)를 보세요. -게이트웨이가 패치 헬퍼를 자기 옆에서 찾을 수 있도록 두 바이너리를 -**같은** 디렉터리에 빌드하세요(`CODESPACE_PATCH_BIN`을 설정해도 됩니다). +게이트웨이가 패치 헬퍼를 자기 옆에서 찾을 수 있도록 게이트웨이와 패치 +헬퍼를 **같은** 디렉터리에 빌드하세요(`CODESPACE_PATCH_BIN`을 설정해도 +됩니다). UDS 워커는 선택입니다(`CODESPACE_RUNTIME_BIN`). ```bash cargo build -p codespace-server --bin codespace-mcp --release @@ -36,11 +37,20 @@ cargo build --manifest-path crates/patch/Cargo.toml --bin codespace-patch --rele mkdir -p dist cp target/release/codespace-mcp dist/ cp crates/patch/target/release/codespace-patch dist/ +# Optional Unix-socket worker (not the default exec path): +cargo build --manifest-path crates/codex-runtime/Cargo.toml --bin codespace-codex-runtime --release +cp crates/codex-runtime/target/release/codespace-codex-runtime dist/ ``` `codespace-patch`는 호스트 자식 프로세스입니다. 핀된 Codex 크레이트를 **프로세스 내부에서** 호스팅합니다. 업스트림 독립 `apply_patch` 바이너리가 -아니고 폐기된 `native/patch-worker`도 아닙니다. +아니고 폐기된 `native/patch-worker`도 아닙니다. `codespace-codex-runtime`은 +`codex-process-hardening`과 `codex-uds`로 비공개 Unix 소켓을 바인드한 뒤 +`InProcessRunner`를 실행합니다. hardening은 워커/헬퍼 **프로세스** +강화입니다(`main` 첫 줄 `pre_main_hardening()`, `ctor` 없음). command +sandbox가 아닙니다. 기본 `exec_command`는 여전히 프로세스 내부 호스트 +spawn입니다. Exec DTO cwd는 `WorkspaceRoot`이며 `PATH` / `HOME` / +`LANG`은 러너 프로세스에서 적용합니다. ## 워크스페이스 레지스트리 @@ -60,7 +70,10 @@ cp crates/patch/target/release/codespace-patch dist/ ``` 프로필: `read-only`(기본 의도) 또는 `workspace-write`. `host-admin`은 -제품 프로필이 아닙니다. +제품 프로필이 아닙니다. 선택적 운영자 `environments`는 `host` 또는 +`linux-container`를 등록할 수 있습니다. 생략하면 암시적 로컬 호스트입니다. +`linux-container`는 exec 경로가 아닙니다. 도구와 `workspace_info`에는 +`environment_id`가 없습니다. ## 게이트웨이 실행 @@ -95,6 +108,21 @@ export CODESPACE_OPERATIONS_DB="$PWD/data/operations.sqlite" 재시작 후 **남지 않습니다**. 프로세스 핸들은 재시작 후 절대 남지 않습니다. +선택적 러너 워커(여전히 호스트 exec이며 Linux 격리가 아님). UDS는 +1:1입니다. 게이트웨이가 `RuntimeProcess`(자식, 비공개 0700 디렉터리, +`$dir/runner.sock`)를 소유합니다. 재연결은 없습니다. +`--runner-dir` / `CODESPACE_RUNNER_DIR`은 그 unique leaf의 부모가 될 +수 있습니다. `/`, `/tmp`, `/var/tmp`, `$HOME`을 디렉터리 자체로 주면 +거절합니다. `--runner-socket`은 이미 떠 있는 워커에 연결할 때만 쓰며 +부모 path를 chmod하지 않습니다. + +```bash +export CODESPACE_RUNNER=uds +export CODESPACE_RUNNER_DIR="$PWD/data/runner" +export CODESPACE_RUNTIME_BIN="$PWD/dist/codespace-codex-runtime" +./dist/codespace-mcp +``` + ## MVP 흐름 재현 자동 커버리지(ChatGPT 계정 불필요): @@ -140,7 +168,7 @@ docker compose -f deploy/compose.yml up --build 마세요. - stderr 캡처는 직접 순환하거나 잘라내세요. 로그 SaaS는 없습니다. - operations SQLite 파일은 **패치 작업** 행과 works/intents와 함께 - 커집니다. 프로세스 핸들과 write/shell 리스는 휘발성 메모리입니다. + 커집니다. 프로세스 핸들과 자원 잠금은 휘발성 메모리입니다. 데이터베이스를 이후 러너 마운트에서 빼 두세요. 삭제하면 멱등 키를 잊습니다. @@ -153,11 +181,18 @@ HTTP 응답은 실행 실패가 아닙니다. `operation_id` 또는 클라이언트 `operation_key` **정확히 하나**로 `operation_status`를 호출하세요. 둘 다 주거나 둘 다 안 주면 오류입니다. - 충돌 후 미완료 행은 `unknown`입니다. 서버는 이를 자동 재실행하지 - **않습니다**. 워크스페이스를 검사한 뒤, 그 변경이 여전히 필요하면 - **새** `operation_key`를 시작하세요. + **않습니다**. UDS에서 `apply_patch` 응답이 유실되면 DB는 `unknown`이며 + 디스크와 모순되는 `rejected`를 쓰지 않습니다. 워크스페이스를 검사한 뒤, + 그 변경이 여전히 필요하면 **새** `operation_key`를 시작하세요. - 살아있는 `exec_command` 프로세스는 MCP 요청보다 오래 살 수 있습니다. 발급된 `process_id`로 `read_process` / `terminate_process`를 사용하세요. - 게이트웨이 재시작 후 옛 OS PID는 CodeSpace 핸들로 재사용되지 않습니다. + 전송이 모호하면 셸 임대를 유지합니다. 워커 `ProcessExited` 뒤에 + `release_process`가 풀어 무한 `WORKSPACE_BUSY`를 막습니다. UDS 연결 + 끊김이나 게이트웨이 종료는 **워커를 죽입니다**(호스트 자식도 함께 + 죽습니다). 확인된 워커 죽음만 프로세스 소유 임대를 풀며 `process_id`는 + 살아남지 않습니다. 러너 `Replay`는 같은 연결 안의 프리미티브이며 + 연결 끊김 복구가 아닙니다. 게이트웨이 재시작 후 옛 OS PID는 + CodeSpace 핸들로 재사용되지 않습니다. ## 이 문서가 검증하지 않는 것 diff --git a/docs/ko/runner-isolation.md b/docs/ko/runner-isolation.md index 41ff2e3..3df0cb4 100644 --- a/docs/ko/runner-isolation.md +++ b/docs/ko/runner-isolation.md @@ -21,8 +21,15 @@ - `/var/run/docker.sock` - gateway `.env`, Bearer files, or SQLite -지금은 러너 제어 소켓이 없습니다. 이 픽스처에 호스트 Docker 소켓이나 -이후 제어 소켓을 실수로 추가하지 마세요. +지금은 compose 픽스처에 러너 제어 소켓이 없습니다. 이 픽스처에 호스트 +Docker 소켓이나 이후 제어 소켓을 실수로 추가하지 마세요. 선택적 +`CODESPACE_RUNNER=uds`는 이 픽스처 밖의 **비공개** 게이트웨이↔워커 +Unix 소켓을 씁니다. 게이트웨이가 unique 0700 leaf를 만듭니다 +(`$TMPDIR/codespace-runner--/` 또는 +`$CODESPACE_RUNNER_DIR/run--/`). 소켓은 항상 +`$dir/runner.sock`입니다. 살아있는 소켓은 `connect`로 조사하며 +`ConnectionRefused` leftover만 unlink합니다. `/tmp` 자체는 chmod하지 +않습니다. ## macOS / Docker 없음 @@ -33,12 +40,25 @@ seccomp/AppArmor와 Docker Desktop 대 Linux 엔진 차이도 검증되지 않 ## 이후 프로세스 분리 -오늘은 `codespace-mcp`가 한 프로세스입니다. `crates/runner`가 프로세스 -내부 `Runner`를 호스팅합니다(`PathSandbox`, `apply_patch` 트랜잭션 하나, -호스트 감독). 이후 Unix 소켓 / `ContainerRunner` 워커는 같은 크레이트에 -살며, 양쪽 모두 Rust로 남습니다. 그 **전송** 분리가 다음 구현 WP이며 -이 문서가 아닙니다. 소켓 프리미티브로 `codex-uds`를 선호하세요. Runner -RPC는 CodeSpace 계약으로 남습니다. +오늘은 기본으로 `codespace-mcp`가 한 프로세스입니다. `crates/runner`가 +`InProcessRunner`(`PathSandbox`, `apply_patch` 트랜잭션 하나, 호스트 +감독)와 선택적 Unix 소켓 `UdsRunner` 클라이언트를 호스팅합니다. +워커는 격리된 `crates/codex-runtime`(`codespace-codex-runtime`)입니다. +`codex_process_hardening::pre_main_hardening()`은 `main`의 첫 줄로 +유지합니다(워커/헬퍼 **프로세스** 강화이지 command sandbox가 아닙니다. +`ctor` 없음). 그다음 `$dir/runner.sock`에 bind만 합니다(부모 chmod +없음). 프로세스당 `InProcessRunner`는 **하나**입니다. 와이어는 **u32 +length-prefix + CodeSpace JSON**입니다 (`protocol: 1`, Hello 핸드셰이크, +`request_id` `rrpc-…`, 이벤트에 `ProcessExited`). App Server가 아닙니다. +P0 UDS는 1:1입니다. 게이트웨이가 워커 자식을 소유합니다(`kill_on_drop`). +연결 끊김이나 게이트웨이 종료는 워커와 호스트 자식을 죽입니다. +`process_id`는 살아남지 않으며 재연결은 없습니다. 러너 `Replay`는 같은 +연결에서만 동작합니다. 그 **전송**은 구현되어 있으며 선택적입니다 +(`CODESPACE_RUNNER=uds` / `CODESPACE_RUNTIME_BIN`). **같은 호스트**이며 +Linux 격리를 주장하지 않습니다. 다음 WP는 PTY / filesystem / +linux-sandbox / network이며 두 번째 전송 재작성이 아닙니다. 소켓 +프리미티브로 `codex-uds`를 선호하세요. Runner RPC는 CodeSpace 계약으로 +남습니다. Linux 격리는 여전히 목표 OS입니다. Landlock, seccomp, PTY 헬퍼, UDS, 파일시스템 역학, 네트워크 격리는 **기본 자체 스택이 아닙니다**. 업스트림 diff --git a/docs/ko/upstream-lock.md b/docs/ko/upstream-lock.md index 56322e0..7623b74 100644 --- a/docs/ko/upstream-lock.md +++ b/docs/ko/upstream-lock.md @@ -7,14 +7,15 @@ CodeSpace는 OpenAI Codex를 **핀된** git 서브모듈로 재사용합니다. 감싸지 않으며, `main`을 따라가지 않습니다. **현재 코드 재사용**은 `crates/patch`를 통한 `codex-apply-patch`(V4A의 -파싱 / 검증 / 적용)입니다. 그 핀은 실행 구현 공급이며, 그만큼 좁은 -크레이트만 따라온다는 서약이 아닙니다. 제품 런타임(App Server, -`codex-core`, `codex-exec`, login, models)은 **핵심** 밖에 둡니다. -응집력 있는 **실행 서브그래프**는 격리된 어댑터에서 가져올 수 있습니다 +파싱 / 검증 / 적용), 그리고 `codex-process-hardening`(`codespace-patch`와 +`codespace-codex-runtime`)과 `codex-uds`(런타임 워커 bind)입니다. +그 핀은 실행 구현 공급이며, 그만큼 좁은 크레이트만 따라온다는 서약이 +아닙니다. 제품 런타임(App Server, `codex-core`, `codex-exec`, login, +models)은 **핵심** 밖에 둡니다. 응집력 있는 **실행 서브그래프**는 +격리된 어댑터에서 가져올 수 있습니다 ([codex-reuse.md](codex-reuse.md)). Codex 타입은 `crates/domain`이나 MCP 표면으로 새면 안 됩니다. 실행 전용 규칙: -[execution-substrate.md](execution-substrate.md). 오늘은 그래프에 추가 -Codex 크레이트가 없습니다. +[execution-substrate.md](execution-substrate.md). ## 배포 핀 (W06) @@ -69,7 +70,9 @@ git -C third_party/codex checkout 6b9826e3aa83b1a5947db50f4332cb9c65f1b340 ## 재사용하는 것과 거절하는 것 **이 핀에서, 코드로:** 파싱, 헝크 검증, 적용 API, 그리고 패리티용으로 -고른 업스트림 픽스처(`crates/patch` → `codex-apply-patch`). +고른 업스트림 픽스처(`crates/patch` → `codex-apply-patch`); +`pre_main_hardening()`(`codex-process-hardening`); 비공개 소켓 디렉터리 + +bind(`codex-uds`). **그 크레이트가 허용해도 제품 기본값으로 거절:** 심링크 follow, sandbox `None` 독립 CLI, 모델의 호스트 절대 경로, 조용한 `git apply`. @@ -78,12 +81,12 @@ sandbox `None` 독립 CLI, 모델의 호스트 절대 경로, 조용한 `git app 어떤 Codex 크레이트 경로 의존성. 제품 런타임은 어디에나 빼 둡니다. App Server, `codex-core`, `codex-exec`, login, models. [codex-reuse.md](codex-reuse.md)를 보세요. 이 SHA에서 재사용 선호 -(아직 연결 안 함): `codex-process-hardening`, `codex-utils-pty`, -`codex-uds`, path/URI utils, `codex-file-search`. 적극 평가: +(**아직 연결 안 함**): `codex-utils-pty`, `codex-file-search`. 적극 평가: `codex-file-system`, `codex-shell-command`, `codex-linux-sandbox` (전이 `codex-sandboxing`, `codex-network-proxy`; `codex-protocol`은 어댑터에서만 허용). `codex-exec-server-protocol`은 내부 DTO 후보입니다. `codex-exec-server`는 참고 / 이후 백엔드이며 영구 거절은 아닙니다. +`codex-git-utils` / `codex-worktree`는 연결하지 않습니다. `codex-rs` 독립 `apply_patch`를 감싸고 그것을 샌드박스라고 부르지 마세요. Preview / `check_only`는 라이브러리 파싱과 CodeSpace diff --git a/docs/operations.md b/docs/operations.md index bbbbb7d..26206db 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -27,8 +27,9 @@ to Codex `main`. Product runtime stays out of the gateway; see [codex-reuse.md](codex-reuse.md) and [execution-substrate.md](execution-substrate.md). -Build both binaries into the **same** directory so the gateway can find -the patch helper next to itself (or set `CODESPACE_PATCH_BIN`): +Build the gateway and patch helper into the **same** directory so the +gateway can find the helper next to itself (or set `CODESPACE_PATCH_BIN`). +The UDS worker is optional (`CODESPACE_RUNTIME_BIN`). ```bash cargo build -p codespace-server --bin codespace-mcp --release @@ -36,11 +37,20 @@ cargo build --manifest-path crates/patch/Cargo.toml --bin codespace-patch --rele mkdir -p dist cp target/release/codespace-mcp dist/ cp crates/patch/target/release/codespace-patch dist/ +# Optional Unix-socket worker (not the default exec path): +cargo build --manifest-path crates/codex-runtime/Cargo.toml --bin codespace-codex-runtime --release +cp crates/codex-runtime/target/release/codespace-codex-runtime dist/ ``` `codespace-patch` is a host child process. It hosts the pinned Codex crate **in-process**. It is not the upstream standalone `apply_patch` -binary and not the retired `native/patch-worker`. +binary and not the retired `native/patch-worker`. `codespace-codex-runtime` +binds a private Unix socket with `codex-process-hardening` and +`codex-uds`, then runs `InProcessRunner`. Hardening is **worker/helper +process** hardening (`pre_main_hardening()` as the first line of +`main`; no `ctor`), not a command sandbox. Default `exec_command` still +uses in-process host spawn. Exec DTO cwd is `WorkspaceRoot`; `PATH` / +`HOME` / `LANG` are applied inside the runner process. ## Workspace registry @@ -59,7 +69,10 @@ at a **real directory you registered**. Models cannot add workspaces. ``` Profiles: `read-only` (default intent) or `workspace-write`. `host-admin` -is not a product profile. +is not a product profile. Optional operator `environments` may register +`host` or `linux-container`. Omitted environment is implicit local host. +`linux-container` is not an exec path. Tools and `workspace_info` have +no `environment_id`. ## Run the gateway @@ -94,6 +107,21 @@ If `CODESPACE_OPERATIONS_DB` is unset, operations and the intent queue live in memory and **do not survive restart**. Process handles never survive restart. +Opt-in runner worker (still host exec, not Linux isolation). UDS is +1:1: the gateway owns `RuntimeProcess` (child, private 0700 directory, +`$dir/runner.sock`). There is no reconnect. `--runner-dir` / +`CODESPACE_RUNNER_DIR` may name a parent for that unique leaf; `/`, +`/tmp`, `/var/tmp`, and `$HOME` are rejected as the directory itself. +`--runner-socket` is only for connecting to an already-running worker +and does not chmod the parent path. + +```bash +export CODESPACE_RUNNER=uds +export CODESPACE_RUNNER_DIR="$PWD/data/runner" +export CODESPACE_RUNTIME_BIN="$PWD/dist/codespace-codex-runtime" +./dist/codespace-mcp +``` + ## Reproduce the MVP flow Automated coverage (no ChatGPT account required): @@ -137,7 +165,7 @@ The gateway still runs on the host. `exec_command` is a host history docs you share. - Rotate or truncate stderr capture yourself. There is no log SaaS. - The operations SQLite file grows with **patch operation** rows plus - works/intents. Process handles and write/shell leases are volatile + works/intents. Process handles and resource locks are volatile memory. Keep the database off any future runner mount. Deleting it forgets idempotency keys. @@ -150,12 +178,19 @@ response is not an execution failure. `operation_id` or the client `operation_key` instead of blindly re-running `apply_patch`. Providing both or neither is an error. - After a crash, unfinished rows are `unknown`. The server does **not** - auto-replay them. Inspect the workspace, then start a **new** - `operation_key` if you still want the change. + auto-replay them. A lost UDS `apply_patch` response is stored as + `unknown`, never a disk-contradicting `rejected`. Inspect the + workspace, then start a **new** `operation_key` if you still want the + change. - A live `exec_command` process can outlive the MCP request. Use `read_process` / `terminate_process` with the issued `process_id`. - After gateway restart, old OS PIDs are not reused as CodeSpace - handles. + Ambiguous transport keeps the process-owned lease. `ProcessExited` + from the worker calls `release_process` so `WORKSPACE_BUSY` does not + stick forever. UDS disconnect or gateway shutdown **kills the worker** + (host children die with it). Confirmed worker death releases + process-owned leases; `process_id` does not survive. Runner `Replay` + is a same-connection primitive, not disconnect recovery. After + gateway restart, old OS PIDs are not reused as CodeSpace handles. ## What this document does not verify diff --git a/docs/runner-isolation.md b/docs/runner-isolation.md index 594f2a8..79aab97 100644 --- a/docs/runner-isolation.md +++ b/docs/runner-isolation.md @@ -22,8 +22,14 @@ It does not mount: - `/var/run/docker.sock` - gateway `.env`, Bearer files, or SQLite -There is no runner control socket today. Do not add a host Docker -socket or a future control socket to this fixture by accident. +There is no runner control socket on the compose fixture. Do not add a +host Docker socket or a future control socket to this fixture by +accident. Opt-in `CODESPACE_RUNNER=uds` uses a **private** gateway↔worker +Unix socket off this fixture. The gateway creates a unique 0700 leaf +(`$TMPDIR/codespace-runner--/` or +`$CODESPACE_RUNNER_DIR/run--/`) and binds `$dir/runner.sock`. +Live sockets are probed with `connect`; only `ConnectionRefused` +leftovers are unlinked. `/tmp` itself is never chmodded. ## macOS / no Docker @@ -35,12 +41,25 @@ unverified. ## Later process split -Today `codespace-mcp` is one process. `crates/runner` hosts the in-process -`Runner` (`PathSandbox`, one `apply_patch` transaction, host supervisor). -A later Unix-socket / `ContainerRunner` worker would live in the same -crate; both sides remain Rust. That **transport** split is the next -implementation WP; it is not this document. Prefer `codex-uds` as the -socket primitive; the Runner RPC stays a CodeSpace contract. +Today `codespace-mcp` is one process by default. `crates/runner` hosts +`InProcessRunner` (`PathSandbox`, one `apply_patch` transaction, host +supervisor) and the opt-in Unix-socket `UdsRunner` client. The +worker is isolated `crates/codex-runtime` (`codespace-codex-runtime`): +`codex_process_hardening::pre_main_hardening()` stays the first line of +`main` (process hardening of the worker/helper, **not** a command +sandbox; no `ctor`). Then bind `$dir/runner.sock` (no parent chmod), +then **one** `InProcessRunner` for the process. Wire format is **u32 +length-prefix + CodeSpace JSON** (`protocol: 1`, Hello handshake, +`request_id` `rrpc-…`, events include `ProcessExited`), not App Server. +P0 UDS is 1:1: the gateway owns the worker child (`kill_on_drop`); +disconnect or gateway shutdown kills the worker and host children; +`process_id` does not survive; there is no reconnect. Runner `Replay` +is same-connection only. That **transport** is implemented; it +is opt-in (`CODESPACE_RUNNER=uds` / `CODESPACE_RUNTIME_BIN`) on the +**same host**. It does not claim Linux isolation. Next WPs are PTY / +filesystem / linux-sandbox / network, not a second transport rewrite. +Prefer `codex-uds` as the socket primitive; the Runner RPC stays a +CodeSpace contract. Linux isolation is still the target OS. Landlock, seccomp, PTY helpers, UDS, filesystem mechanics, and network isolation are **not** a default diff --git a/docs/translations.json b/docs/translations.json index 07c6107..85f460b 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -61,8 +61,8 @@ "상태", "실행" ], - "source_sha256": "6066cd16dfc9559987a276b484cb2154384e46497b20b078e9263b17cfe5a905", - "translation_sha256": "cef4867649f9052da08158a3ce6e941b9e882e77179c799d6afc4c3575d61201" + "source_sha256": "1aa10bfe05a0d694c09cf472c150040d4cad046cd72c5f03613965310c3dbebd", + "translation_sha256": "dbafbaf7197cfa0844ba6fed773366660fb3c218bcb2f255365056a94ec147a6" }, { "id": "operations", @@ -91,8 +91,8 @@ "워크스페이스-레지스트리", "이-문서가-검증하지-않는-것" ], - "source_sha256": "0a31118f3079f05527613e22a57455e5c5ba0159026aa5bbcc3d7b1186461640", - "translation_sha256": "f839d595db274cccfb2790379410062fdb8ccb90f9dc9f3811f1f942774e693f" + "source_sha256": "ea0ce5511b84242e0988ce4eda7db0e91acd110c8d0e3b99d4e23e2ef58fae94", + "translation_sha256": "e83905f5420b58679530037f7aa56574af242e7b9cefced658a127bcf992c5e0" }, { "id": "chatgpt-connector", @@ -145,8 +145,8 @@ "프로토콜-호환성", "현재-배치" ], - "source_sha256": "812858a1a21d31d06f7223e198527442ad8f3c50a635ec6ce96f1cf62a90f24d", - "translation_sha256": "e1d82ce7a1231df987c4bdcba4bd01612810b746f3b605e5fe6073cc844e83da" + "source_sha256": "10a2fe76b16659c5c5b68defe6f2d74b7ea41a2983e48616db9f9564477b6a88", + "translation_sha256": "bc4fbddc20492fa75c4102f64774b6b1b453884e90569b7c82046925a38619d9" }, { "id": "execution-substrate", @@ -177,8 +177,8 @@ "실행-기반", "훅과-스킬" ], - "source_sha256": "497c710bdb17ebbfcdad4cca8002f0551fe45af02d929b0520b0861069dc6278", - "translation_sha256": "a7499a65adaf2e1b6fc93aaa0bf4ed41555535b1f0da906ca1cd4449c252c003" + "source_sha256": "17be76e2628e1bd1781ef6364a9592491209e3974cf255672a9c41bac5d036a3", + "translation_sha256": "6d186b817511482adc3432025f4d6895f6cb413d54ffbd849166dac5f2071665" }, { "id": "protocol-compatibility", @@ -272,8 +272,8 @@ "러너-격리", "이후-프로세스-분리" ], - "source_sha256": "39464d076b856527d363c2031b04eb9228ecc0abbefac4416035fbff807e67f0", - "translation_sha256": "80b1342ab219fcc4dfd501f156fe86e8078cb5d107f884314f9c3a0e271d616a" + "source_sha256": "41b30338185931fa3ed00b72ee212d2fface9491210e5bef41e4016db715fc24", + "translation_sha256": "e0527ae6dcef28eaf21c4ee1452b3263afa997c1dea1408fc5c5ab91bc451366" }, { "id": "error-codes", @@ -340,8 +340,8 @@ "핀-6b9826e의-후보", "핵심-대-어댑터" ], - "source_sha256": "309d465d5a4b5e09ab34097a6415dc2a1e833e2fd26cf721611d3031580d6feb", - "translation_sha256": "f0717d1d40fa67a5117cf5eb0f5d8601d205979c1513d27545a1e1179c35c68b" + "source_sha256": "07b559d3cc6884d405d6ed73cdeab0c9dd5700bd8fb3da16e7f93babeb3eefa7", + "translation_sha256": "cd32cbada29d34a531b815d24b7df6b8bbecf188a0edad2eba697eea11e19b2c" }, { "id": "upstream-lock", @@ -362,8 +362,8 @@ "재사용하는-것과-거절하는-것", "파일-복사-벤더가-금지인-이유" ], - "source_sha256": "bf539fbb2d8a34af688e99c7fb2db6a748627990d9c95bd65fad9506af28a088", - "translation_sha256": "cae3e0c2f07b4a9942afd0f58a0dddefe790784c29dd21ac5dcbff55e74261ab" + "source_sha256": "ec45c721e9db9742a77b7e1256ec1502eb6725bc7cb452311779e57a0bb7be60", + "translation_sha256": "96b64234af21fad87b12c146530f60ac024868603fcb374c05c20d72e23e298d" }, { "id": "upstream-update", diff --git a/docs/upstream-lock.md b/docs/upstream-lock.md index 0190ef3..5dc5421 100644 --- a/docs/upstream-lock.md +++ b/docs/upstream-lock.md @@ -7,15 +7,15 @@ not vendor a single source file, wrap the standalone `apply_patch` binary as the security boundary, or follow `main`. **Current code reuse** is `codex-apply-patch` (parse / verify / apply of -V4A) via `crates/patch`. That pin is the execution-implementation -supply; it is not a vow that only equally narrow crates may follow. -Product runtime (App Server, `codex-core`, `codex-exec`, login, -models) stays out of **core**. A cohesive **execution subgraph** may -be taken in an isolated adapter +V4A) via `crates/patch`, plus `codex-process-hardening` (`codespace-patch` +and `codespace-codex-runtime`) and `codex-uds` (runtime worker bind). +That pin is the execution-implementation supply; it is not a vow that +only equally narrow crates may follow. Product runtime (App Server, +`codex-core`, `codex-exec`, login, models) stays out of **core**. A +cohesive **execution subgraph** may be taken in an isolated adapter ([codex-reuse.md](codex-reuse.md)). Codex types must not leak into `crates/domain` or the MCP surface. Execution-only rules: -[execution-substrate.md](execution-substrate.md). No extra Codex crate -is on the graph today. +[execution-substrate.md](execution-substrate.md). ## Deployment pin (W06) @@ -70,7 +70,8 @@ and a path dependency, not a crates.io moving version. **On this pin, in code:** parse, hunk verification, apply APIs, and selected upstream fixtures for parity (`crates/patch` → -`codex-apply-patch`). +`codex-apply-patch`); `pre_main_hardening()` (`codex-process-hardening`); +private socket directory + bind (`codex-uds`). **Rejected as product defaults** even if that crate allows them: symlink follow, sandbox `None` standalone CLI, host-absolute paths from @@ -79,14 +80,14 @@ the model, silent `git apply`. **Rejected as CodeSpace *core* dependencies:** any Codex crate path dep, including `codex-protocol` types. Product runtime stays out everywhere: App Server, `codex-core`, `codex-exec`, login, models. -See [codex-reuse.md](codex-reuse.md). Prefer-reuse at this SHA (not -wired): `codex-process-hardening`, `codex-utils-pty`, `codex-uds`, -path/URI utils, `codex-file-search`. Active evaluation: +See [codex-reuse.md](codex-reuse.md). Prefer-reuse at this SHA, **not +yet wired:** `codex-utils-pty`, `codex-file-search`. Active evaluation: `codex-file-system`, `codex-shell-command`, `codex-linux-sandbox` (transitives `codex-sandboxing`, `codex-network-proxy`; `codex-protocol` allowed only in the adapter). `codex-exec-server-protocol` is an internal DTO candidate. `codex-exec-server` is a reference / -future backend, not a forever reject. +future backend, not a forever reject. `codex-git-utils` / `codex-worktree` +stay unwired. Do not wrap `codex-rs` standalone `apply_patch` and call that a sandbox. Preview / `check_only` is implemented through library parse plus diff --git a/scripts/check-no-model-deps.sh b/scripts/check-no-model-deps.sh index 90aec8a..cf44cad 100755 --- a/scripts/check-no-model-deps.sh +++ b/scripts/check-no-model-deps.sh @@ -35,13 +35,14 @@ SOURCE_PATTERNS=( # codex-exec-server here is compile graph, not a product backend choice. patch_key_allowed() { case "$1" in - codex-apply-patch|codex-exec-server|codex-utils-path-uri) return 0 ;; + codex-apply-patch|codex-exec-server|codex-utils-path-uri|codex-process-hardening) return 0 ;; *) return 1 ;; esac } # Future crates/codex-runtime: prefer / evaluate / protocol from # docs/codex-reuse.md. Not agent/product: no core, login, app-server, exec. +# Current runtime keys in code: codex-process-hardening, codex-uds. runtime_key_allowed() { case "$1" in codex-apply-patch|codex-process-hardening|codex-utils-pty|codex-uds|\