diff --git a/NOTICE b/NOTICE index a677e09..618e85d 100644 --- a/NOTICE +++ b/NOTICE @@ -19,6 +19,11 @@ under the Apache License, Version 2.0. apply) via git submodule `third_party/codex` at `6b9826e3aa83b1a5947db50f4332cb9c65f1b340` (`rust-v0.154.0`). The pin is recorded in docs/upstream-lock.md. + CodeSpace modifies one pinned crate, `codex-utils-pty`, with the + patch in third_party/codex-patches/ (bounded observation of an + exited PTY child before its reap), which is applied to the + submodule's files before building. Each file the patch modifies + carries a notice saying so. File-copy vendoring of a single crate is not used because the crate depends on other Codex workspace crates (including `codex-exec-server`, path utilities, and tree-sitter bindings). diff --git a/docs/codex-reuse.md b/docs/codex-reuse.md index 55b42f6..433eb79 100644 --- a/docs/codex-reuse.md +++ b/docs/codex-reuse.md @@ -62,7 +62,7 @@ Current entrypoints do not embed `codex-core`, `codex-exec`, or Codex App Server ## Updates and future work -All reused components currently come from the [same pinned revision](upstream-lock.md). Upstream fixes arrive only after an explicit pin update and validation; they are not automatically inherited. [Update checks](upstream-update.md) include each connected adapter, not only patch tests. +All reused components currently come from the [same pinned revision](upstream-lock.md), and one [recorded patch](upstream-lock.md#codex-downstream-patch) to `codex-utils-pty` is applied to its files. Upstream fixes arrive only after an explicit pin update and validation; they are not automatically inherited. When the pinned revision blocks work CodeSpace needs, follow the [policy for changing Codex at a pin](upstream-patches.md). [Update checks](upstream-update.md) include each connected adapter, not only patch tests. `codex-file-search`, shell-command parsing, worktree provisioning, and a general `codex-exec-server` backend remain candidates, not connected features. Evaluate a candidate by the execution function it supplies, its build/upgrade cost, and whether model or permission authority would cross the adapter boundary. Current user-facing limitations are listed in [Agent Loop integration](agent-integration.md). diff --git a/docs/index.md b/docs/index.md index 796e4a5..ae87a7c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ Use CodeSpace as the execution layer beneath your own coding agent. Start with a | Understand module responsibilities | [Architecture](architecture.md) and [execution contracts](execution-substrate.md) | | Check permissions and isolation | [Security model](security-model.md) and [runner isolation](runner-isolation.md) | | Handle protocol and tool results | [Protocol compatibility](protocol-compatibility.md), [patch behavior](behavior-differences.md), [error codes](error-codes.md) | -| Maintain Codex dependencies | [Reuse scope](codex-reuse.md), [pinned revision](upstream-lock.md), [update procedure](upstream-update.md) | +| Maintain Codex dependencies | [Reuse scope](codex-reuse.md), [pinned revision](upstream-lock.md), [update procedure](upstream-update.md), [patch policy](upstream-patches.md) | | Edit or publish these guides | [Documentation maintenance](documentation.md) | The implementation defines supported behavior. A test description identifies coverage; it is not a claim that a particular installation or external account was tested. Read the execution contract returned by `workspace_info` for the workspace you actually use. diff --git a/docs/ko/codex-reuse.md b/docs/ko/codex-reuse.md index 0c823a8..6617ad0 100644 --- a/docs/ko/codex-reuse.md +++ b/docs/ko/codex-reuse.md @@ -80,7 +80,7 @@ Linux 샌드박스 도우미는 실행 파일만 제공합니다. `codespace-lin ## 업데이트와 향후 검토 -재사용하는 구성 요소는 현재 모두 [같은 고정 버전](upstream-lock.md)에서 가져옵니다. 업스트림 수정은 버전 갱신과 검증을 거쳐야 반영되며 자동으로 들어오지 않습니다. [업데이트 검사](upstream-update.md)는 패치뿐 아니라 연결된 모든 어댑터를 포함합니다. +재사용하는 구성 요소는 현재 모두 [같은 고정 버전](upstream-lock.md)에서 가져오며, 그 파일에 `codex-utils-pty`의 [기록된 패치](upstream-lock.md#codex-downstream-patch) 하나를 적용합니다. 업스트림 수정은 버전 갱신과 검증을 거쳐야 반영되며 자동으로 들어오지 않습니다. 고정 버전이 CodeSpace에 필요한 작업을 막으면 [고정 버전의 Codex 수정 정책](upstream-patches.md)을 따릅니다. [업데이트 검사](upstream-update.md)는 패치뿐 아니라 연결된 모든 어댑터를 포함합니다. `codex-file-search`, 셸 명령 파싱, worktree 준비, 범용 `codex-exec-server` 백엔드는 아직 연결되지 않은 후보입니다. 도입 시 제공하는 실행 기능, 빌드·업데이트 비용, 모델이나 권한 결정 책임이 어댑터 경계를 넘는지를 검토합니다. 사용자에게 영향을 주는 현재 제약은 [Agent Loop 연동](agent-integration.md)에 정리되어 있습니다. diff --git a/docs/ko/index.md b/docs/ko/index.md index ecf6d19..1e2a1ed 100644 --- a/docs/ko/index.md +++ b/docs/ko/index.md @@ -21,7 +21,7 @@ CodeSpace를 직접 구성한 코딩 에이전트의 실행 계층으로 연결 | 모듈별 책임 이해 | [아키텍처](architecture.md), [실행 계약](execution-substrate.md) | | 권한과 격리 조건 확인 | [보안 모델](security-model.md), [러너 격리](runner-isolation.md) | | 프로토콜과 도구 결과 처리 | [프로토콜 호환성](protocol-compatibility.md), [패치 동작](behavior-differences.md), [오류 코드](error-codes.md) | -| Codex 의존성 유지보수 | [재사용 범위](codex-reuse.md), [고정 버전](upstream-lock.md), [업데이트 절차](upstream-update.md) | +| Codex 의존성 유지보수 | [재사용 범위](codex-reuse.md), [고정 버전](upstream-lock.md), [업데이트 절차](upstream-update.md), [패치 정책](upstream-patches.md) | | 문서 수정과 게시 | [문서 유지보수](documentation.md) | 지원 동작은 실제 구현을 기준으로 설명합니다. 테스트 안내는 검사 범위를 나타내며, 특정 설치 환경이나 외부 계정에서 검증을 마쳤다는 뜻은 아닙니다. 실제 사용할 작업 공간의 실행 조건은 `workspace_info` 응답에서 확인하세요. diff --git a/docs/ko/operations.md b/docs/ko/operations.md index b69c4ba..46de268 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -11,13 +11,15 @@ macOS 또는 Linux에서 개인용 단일 사용자 서버를 준비하는 방 ## 설치 -Git과 CI에서 사용하는 최신 Rust stable 도구 체인을 준비하세요. 루트 manifest에는 Rust 1.88이 선언되어 있지만 Codex 어댑터 전체의 최소 빌드 버전으로 검증된 값은 아닙니다. 고정된 업스트림 소스는 Rust 1.95.0을 지정합니다. macOS에는 Xcode 명령줄 도구를 설치하세요. Linux 명령 격리에는 샌드박스 도우미, bubblewrap, 필요한 네임스페이스를 생성할 권한이 추가로 필요합니다. Docker는 별도의 [컨테이너 실험 구성](../../deploy/README.md)을 사용할 때만 필요합니다. +Git, Python 3, CI에서 사용하는 최신 Rust stable 도구 체인을 준비하세요. 루트 manifest에는 Rust 1.88이 선언되어 있지만 Codex 어댑터 전체의 최소 빌드 버전으로 검증된 값은 아닙니다. 고정된 업스트림 소스는 Rust 1.95.0을 지정합니다. macOS에는 Xcode 명령줄 도구를 설치하세요. Linux 명령 격리에는 샌드박스 도우미, bubblewrap, 필요한 네임스페이스를 생성할 권한이 추가로 필요합니다. Docker는 별도의 [컨테이너 실험 구성](../../deploy/README.md)을 사용할 때만 필요합니다. ```bash git clone --recurse-submodules https://github.com/novelKR/CodeSpace.git cd CodeSpace # 이미 복제한 저장소에서도 서브모듈을 초기화합니다. git submodule update --init --recursive +# CodeSpace가 기록한 Codex 패치를 적용합니다. 서브모듈을 갱신할 때마다 다시 실행합니다. +python3 scripts/codex_patch.py apply cargo build --locked -p codespace-server --bin codespace-mcp --release cargo build --locked --manifest-path crates/patch/Cargo.toml --bin codespace-patch --release mkdir -p dist diff --git a/docs/ko/upstream-lock.md b/docs/ko/upstream-lock.md index 1eecd54..4e720f4 100644 --- a/docs/ko/upstream-lock.md +++ b/docs/ko/upstream-lock.md @@ -19,11 +19,33 @@ | 태그 | `rust-v0.154.0` | | 커밋 | `6b9826e3aa83b1a5947db50f4332cb9c65f1b340` | | 경로 | `third_party/codex` | +| 하위 패치 | 1개. 이 커밋의 파일에 적용함. [하위 패치](#codex-downstream-patch) 참고 | | 사용하는 구성 요소 | 패치, 런타임 worker, PTY, 파일 시스템, Linux 샌드박스·프록시 어댑터 | | 패치 옵션 | `PreserveLineEndings`, `follow_symlinks: false` | 정확한 역할은 [연결된 구성 요소](codex-reuse.md)를 참고하세요. 패치 테스트는 선택된 동작 일치 사례를 검사하며 업스트림 전체 테스트나 Codex의 모든 동작을 검증하지는 않습니다. + + +## 하위 패치 + +CodeSpace는 [고정 버전의 Codex 수정 정책](upstream-patches.md)에 따라 `codex-utils-pty`에 대한 패치 하나를 유지합니다. 서브모듈은 `openai/codex`의 위 커밋에 그대로 두고 그 파일에 패치를 적용하며, 나머지 Codex crate는 모두 태그와 같습니다. `scripts/codex_patch.py`는 이 표에서 패치 파일, 그 digest, 패치를 적용한 tree를 읽습니다. + +| 항목 | 값 | +| --- | --- | +| 패치 | `third_party/codex-patches/0001-utils-pty-pre-reap-observation.patch`, SHA-256 `6f61579830f311e3c259c0642864ea9eee3c653f12c7e838e97e9b1dd35b1fba` | +| 패치 적용 tree | `1999937194e3a68d982be85679557f0e50fe5408` | +| 파일 | `codex-rs/utils/pty/src`의 `observe.rs`(신규), `observe_tests.rs`(신규, 테스트 전용), `pty.rs`, `lib.rs`. 제품 코드 304줄 추가, 5줄 삭제, 테스트 9개 | +| 기능 | spawn 전에 `observe::ExitObserver`를 등록하는 `spawn_pty_process_observed`. 자식의 단일 waiter가 회수하지 않고 종료를 보고(`WNOWAIT`를 쓴 `waitid`), 관찰자에게 그 bound 안에서 한 번의 기회를 준 뒤 전과 같이 회수하고 `Completed`, `TimedOut`, `Failed`, `Unavailable` 중 하나를 보고. 관찰자가 없으면 모든 경로가 그대로. Unix가 아닌 플랫폼은 관찰 spawn을 거부 | +| 이유 | Codex가 루트를 회수하기 전에 관리 PTY 실행의 scope를 관찰하기 위함([CSRG-U5](devguard-integration.md#devguard-lifecycle)). 2026-10-11 소유자 결정. 계약은 [업스트림 어댑터 작업 명세](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-cs-dg-upstream-adapter-work-spec-1.md#8-preferred-investigation-b-an-upstream-owned-bounded-pre-reap-observation-contract) 8절 | +| CodeSpace 사용 | 아직 없음. PTY 어댑터는 여전히 `spawn_pty_process`를 호출하며, 관찰자 연결은 별도 변경에서 함 | +| 출처 | W3 실험 B의 관찰자([DevGuard 결정 패킷 3.2절](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132))를 이 릴리스의 두 Unix PTY 경로로 옮긴 것. 수정한 파일마다 CodeSpace 수정 고지가 있음(Apache-2.0 4(b)) | +| 검증 | 패치 적용 tree에서 함. Linux x86_64: crate 테스트 36개를 30회 반복해 모두 통과. macOS: 테스트 38개를 30회 반복해 모두 통과, DevGuard `6e7e065`로 W3 실험 B의 B1-B13 통과. Linux, macOS, Windows 대상 clippy 통과 | +| 업스트림 | Codex는 외부 pull request를 받지 않음. 기능 요청 초안만 있고 제출하지 않음 | +| 제거 조건 | Codex 릴리스가 같은 관찰(단일 waiter, bound, 결과 보고)을 제공하고 CodeSpace가 그것으로 옮기거나, CodeSpace가 더 이상 회수 전에 PTY 실행을 관찰하지 않으면 패치를 뺌 | +| 핀 변경 시 | 패치를 되돌리고 서브모듈을 옮긴 뒤, 후보에 패치를 다시 옮기거나 제거 조건에 따라 빼고, Linux와 macOS에서 테스트를 다시 실행하고 이 기록을 갱신 | +| 되돌리기 | CodeSpace의 관찰자 사용을 먼저 되돌린 뒤, 패치 파일과 이 기록을 함께 되돌림 | + ## DevGuard client 고정 버전 @@ -60,7 +82,8 @@ gateway([CSRG-U1](devguard-integration.md#devguard-status-connection))와 UDS wo ## 확인과 갱신 ```bash +python3 scripts/codex_patch.py apply PIN_ONLY=1 ./scripts/check-upstream-pin.sh ``` -이 명령은 서브모듈 커밋과 문서의 일치 여부만 검사합니다. `PIN_ONLY`를 생략하면 패치 테스트도 실행하지만 다른 어댑터 검증을 대신하지는 않습니다. 버전을 바꾸기 전에 [전체 업데이트 절차](upstream-update.md)를 따르세요. 배포 과정에서 `git submodule update --remote`로 최신 버전을 따라가지 않습니다. +clone하거나 서브모듈을 갱신할 때마다 빌드 전에 [기록된 패치](#codex-downstream-patch)를 적용합니다. `scripts/validate-upstream.py`는 검증할 때마다 먼저 적용합니다. 고정 버전 검사는 서브모듈 커밋이 이 문서와 일치하는지, 서브모듈의 파일이 기록된 패치 적용 tree와 정확히 같은지만 검사합니다. `PIN_ONLY`를 생략하면 패치 테스트도 실행하지만 다른 어댑터 검증을 대신하지는 않습니다. 서브모듈을 옮기기 전에는 `python3 scripts/codex_patch.py revert`를 실행하고, 버전을 바꾸기 전에 [전체 업데이트 절차](upstream-update.md)를 따르세요. 배포 과정에서 `git submodule update --remote`로 최신 버전을 따라가지 않습니다. diff --git a/docs/ko/upstream-patches.md b/docs/ko/upstream-patches.md new file mode 100644 index 0000000..d12a13c --- /dev/null +++ b/docs/ko/upstream-patches.md @@ -0,0 +1,145 @@ + + +# 고정 버전의 Codex 수정 + +[English](../upstream-patches.md) | [한국어](upstream-patches.md) + +CodeSpace는 [고정한 릴리스](upstream-lock.md) 하나의 Codex 코드를 실행합니다. 이 문서는 그 릴리스로 CodeSpace에 필요한 일을 할 수 없을 때 무엇을 할지, 그리고 답이 Codex 코드를 바꾸는 것이라면 어떻게 바꿀지를 정합니다. 바꾸는 방식은 공식 릴리스 위에 기록된 패치를 얹는 것 하나이며, fork는 쓰지 않습니다. 아래 [이전 사례](#earlier-cases)는 CodeSpace가 이런 차이를 지금까지 어떻게 처리했는지 보여 줍니다. 정책은 그 사례에서 나왔고, 이후의 결정도 같은 길을 따르게 하려는 것입니다. 지금 적용 중인 패치는 [버전 기록](upstream-lock.md#codex-downstream-patch)에 있습니다. + + + +## 정책 + + + +### 패치를 쓰는 경우 + +선택지를 다음 순서로 검토하고, 앞의 선택지가 맞지 않는 이유를 기록합니다. + +1. 고정한 릴리스의 공개 API를 쓰고, 모자란 부분은 CodeSpace 쪽 어댑터에서 처리 +2. 그 기능이 이미 있는 Codex 릴리스로, 검토를 거친 [버전 갱신](upstream-update.md)을 통해 옮김 +3. 이 정책에 따라 고정한 릴리스에 기록된 패치를 얹음 +4. 해당 Codex 메커니즘을 CodeSpace가 직접 구현해 대체 + +작업을 미룰 수 있는 동안에는 모자란 부분을 문서화된 제약으로 두고 테스트로 고정해 둘 수도 있습니다. + +패치는 CodeSpace가 지금 하기로 한 일을 고정한 릴리스가 막고 있고, 어댑터로 메울 수 없으며, 그 일만을 위해 버전을 옮기는 것이 타당하지 않을 때만 고릅니다. 범위는 프로세스, PTY, 터미널, descriptor, 입출력 도구 같은 실행 메커니즘으로 제한합니다. `codex-core`의 제품 의미, 세션 권한, 에이전트 루프는 바꾸지 않으며, 검사가 봐야 할 의존성을 숨기지 않습니다. + +패치가 CodeSpace의 직접 구현보다 앞서는 이유는 CodeSpace가 Codex에 맡긴 메커니즘 위에서 얇은 어댑터로 남기 때문입니다. DevGuard 연동만을 이유로 그런 메커니즘, 특히 PTY를 CodeSpace 안으로 옮기지 않습니다([경계 재검토](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-27-cs-rg-boundary-revalidation.md#1-why-this-review-happened) 1절). [Codex 재사용 범위](codex-reuse.md)의 '관리 실행의 재사용 결정' 절도 관리 실행에 대해 같은 순서를 기록했지만, 그 절은 구현 지시로서 효력이 중지된 상태입니다. 따라서 고정 버전에서 하는 모든 변경의 순서는 이 문서가 정합니다. + + + +### 형식 + +- 서브모듈은 `openai/codex`의 공식 릴리스 커밋에 그대로 둡니다. gitlink는 수정한 커밋을 가리키지 않고, CodeSpace는 Codex fork를 두지 않습니다. +- 패치 하나는 `third_party/codex-patches/`의 `git format-patch` 파일 하나입니다. 고정 커밋을 기준으로 만들고, 적용 순서대로 번호를 붙입니다. +- `scripts/codex_patch.py`가 서브모듈의 파일에 패치를 적용하며, 결과는 기록된 패치 적용 tree와 같아야 합니다. 고정 버전 검사와 `scripts/validate-upstream.py`는 패치가 빠졌거나 체크아웃에 다른 변경이 있으면 거부합니다. +- Codex crate를 CodeSpace로 복사하지 않고, Cargo `[patch]` 항목으로 Codex crate를 수정한 사본에 연결하지 않습니다. Codex 자체 workspace에서 가져온 `[patch]` 항목은 서드파티 crate를 OpenAI의 fork에서 빌드하게 하는 것으로, CodeSpace의 변경이 아니므로 그대로 둡니다. +- 패치는 호출하는 쪽이 선택하지 않는 한 업스트림 동작을 바꾸지 않습니다. 수정한 파일마다 수정 고지(Apache-2.0 4(b))를 넣고, Linux와 macOS에서 실행되는 테스트를 패치한 crate에 더합니다. + + + +### 기록 + +[버전 기록](upstream-lock.md#codex-downstream-patch)에는 패치마다 파일과 SHA-256, 패치 적용 tree, 바꾼 파일, 기능, 이유, CodeSpace에서의 사용, 출처, Linux와 macOS 검증, 업스트림 상태, 제거 조건, 버전 변경 때의 처리, 되돌리기를 적습니다. 기록과 패치는 같은 PR에서 바뀝니다. `scripts/codex_patch.py`가 기록을 읽으므로 둘이 서로 어긋날 수 없습니다. + + + +### 업스트림 우선 + +Codex는 코드 변경을 자기 팀에서만 받고, 기능 요청은 issue tracker에서 받습니다([기여 안내](https://github.com/openai/codex/blob/rust-v0.154.0/docs/contributing.md)). 패치마다 그 기능을 그곳에 기능 요청으로 제안하고 패치를 참고 자료로 붙입니다. 요청을 제출할지는 소유자가 정합니다. 그 상태는 기록에 남기고, Codex 릴리스가 같은 기능을 제공하고 CodeSpace가 그 릴리스로 옮기면 패치를 제거합니다. + + + +### 버전 변경 + +버전 갱신은 패치를 되돌리고(`python3 scripts/codex_patch.py revert`) 서브모듈을 옮깁니다. 같은 PR에서 각 패치를 후보 버전으로 옮기거나 제거 조건에 따라 빼고, 패치의 테스트와 영향받는 모든 어댑터를 Linux와 macOS에서 다시 실행하고, 기록을 갱신합니다. 패치 때문에 버전이 저절로 바뀌는 일은 없습니다. + + + +### 승인 + +패치를 더하거나 바꾸거나 빼는 일은 버전 변경과 같이 검토를 거친 CodeSpace PR로 하며, 소유자의 승인을 받아 정확한 헤드로 병합합니다. + + + +## 이전 사례 + +2026년 9월 처음 버전을 고정한 때부터 2026년 10월 PTY 관찰 패치까지, 고정한 릴리스에는 어댑터에 필요한 것이 빠져 있는 경우가 많았습니다. PTY 관찰 패치 전까지는 모든 사례를 Codex 소스를 바꾸지 않고, 버전도 옮기지 않고 해결했습니다. 사례는 여섯 갈래로 나뉩니다. 각 링크는 그 결정을 기록한 issue나 PR입니다. + + + +### Codex 코드를 가져오는 방식 + +- **문제.** `codex-apply-patch`는 `codex-exec-server`, 경로 도구, tree-sitter 같은 Codex workspace의 다른 crate에 의존하므로 그것만 떼어 복사할 수 없습니다. +- **검토한 것.** crate 하나를 복사하는 방식은 처음부터 범위 밖이었습니다([#1](https://github.com/novelKR/CodeSpace/issues/1)). [#25](https://github.com/novelKR/CodeSpace/pull/25)는 Codex의 독립 실행 파일 `apply_patch`를 감싸는 방식, 패치 형식을 다시 구현하는 방식, `git apply`로 대신하는 방식도 배제했습니다. +- **결정.** `rust-v0.154.0`의 `third_party/codex` 서브모듈([#25](https://github.com/novelKR/CodeSpace/pull/25)). 고정 버전 검사는 다른 커밋을 거부하며, `git submodule update --remote`는 배포 단계로 쓰지 않습니다. +- **이후 릴리스.** `rust-v0.157.1`, `rust-v0.159.0-alpha.11`, 정식 `rust-v0.159.0`부터 `rust-v0.159.2`까지는 Codex의 descriptor 정책이나 `ChildFds`처럼 CodeSpace가 쓸 만한 기능이 있었습니다. 어느 것도 다른 작업의 일부로 들이지 않았습니다([#76](https://github.com/novelKR/CodeSpace/issues/76)). 2026-09-30 소유자의 계획 방향은 별도의 검증된 버전 PR이 CodeSpace를 검토한 `rust-v0.159.x`로 옮길 때까지 `rust-v0.154.0`을 유지하며, PTY 어댑터의 `ChildFds` 수정은 그 PR에 함께 넣습니다([DevGuard #14](https://github.com/novelKR/DevGuard/issues/14#issuecomment-5914489840)). + + + +### Codex workspace 안에서 빌드하기 + +- **문제.** 고정한 crate는 Codex 자체 Cargo workspace를 전제로 합니다. 공유 의존성 버전과, `tungstenite`·`tokio-tungstenite`를 crates.io 릴리스에 없는 기능과 함께 OpenAI의 fork에서 빌드하게 하는 `[patch]` 항목이 그 예입니다. Cargo는 빌드의 루트 manifest에 있는 `[patch]`만 적용합니다. +- **결정.** 어댑터마다 독립된 Cargo workspace와 잠금 파일을 두고, 루트 workspace는 이를 제외합니다([#25](https://github.com/novelKR/CodeSpace/pull/25)). Codex에 닿는 workspace는 필요한 `[patch]` 항목을 Codex manifest에서 그대로 가져오고, 이 항목은 버전과 함께 바뀌고 함께 되돌립니다([#25](https://github.com/novelKR/CodeSpace/pull/25), [#51](https://github.com/novelKR/CodeSpace/pull/51)). +- **Rama.** Codex는 `rama` crate를 `=0.3.0-alpha.4`로 고정하지만, 그 crate들은 하위 crate를 `^0.3.0-alpha.4`로 받아들입니다. 그래서 새로 의존성을 풀면 정식 0.3.0 하위 crate가 섞여 빌드가 깨졌습니다. 파일 시스템 어댑터와 Linux 샌드박스 어댑터는 `rama-error`, `rama-macros`, `rama-utils`를 정확한 버전으로 고정하고, CI는 `--locked`로 빌드합니다([#53](https://github.com/novelKR/CodeSpace/pull/53), [#54](https://github.com/novelKR/CodeSpace/pull/54)). +- **잠금 파일.** 러너의 의존성이 바뀌면 워커의 잠금 파일에도 반영해야 합니다([#53](https://github.com/novelKR/CodeSpace/pull/53), [#54](https://github.com/novelKR/CodeSpace/pull/54)). glob이 서브모듈 안까지 들어가 CI가 깨진 뒤로 CI 캐시 키는 잠금 파일을 하나씩 명시하며([#67](https://github.com/novelKR/CodeSpace/pull/67)), 테스트 빌드는 오래된 잠금 파일을 더 이상 다시 쓰지 않습니다([#69](https://github.com/novelKR/CodeSpace/pull/69)). + + + +### Codex 의존성 그래프를 핵심 crate 밖에 두기 + +- **패치 엔진.** `tokio-tungstenite` 기능 충돌 때문에 게이트웨이가 루트 workspace에서 `codex-apply-patch`를 링크할 수 없었습니다. 그래서 엔진은 stdin으로 JSON을 받고 stdout으로 답하는 `codespace-patch` 도우미 프로세스에서 실행합니다([#27](https://github.com/novelKR/CodeSpace/pull/27)). +- **파일 시스템.** 링크를 따라가지 않는 파일 입출력(`LOCAL_FS`)은 `codex-exec-server`에서 오며, 이 때문에 `codex-protocol`을 비롯한 Codex crate가 루트 잠금 파일에 들어옵니다. 공개 타입은 CodeSpace 것으로 두고, 핵심 crate는 Codex에 직접 의존하지 않으며, 의존성 검사는 모든 제품 그래프에서 `codex-core`, `codex-exec`, `codex-app-server`, `codex-login`을 거부합니다([#51](https://github.com/novelKR/CodeSpace/pull/51), [#57](https://github.com/novelKR/CodeSpace/pull/57)). +- **Linux 샌드박스.** `codex-linux-sandbox`는 bubblewrap 안에서 자기 실행 파일을 다시 실행합니다. 이를 실행 파일 전용 도우미로 감싸고, 러너는 작은 프로토콜 crate에만 의존하며, 의존성 검사가 도우미의 그래프를 러너 밖에 둡니다([#53](https://github.com/novelKR/CodeSpace/pull/53), [#54](https://github.com/novelKR/CodeSpace/pull/54), [#57](https://github.com/novelKR/CodeSpace/pull/57)). + + + +### CodeSpace가 바꿔 쓰는 업스트림 기본값 + +- Codex의 workspace-write 프로필은 `.git`, `.agents`, `.codex`를 읽기 전용으로 두고, 기본 `NO_PROXY`는 loopback 주소로 가는 트래픽이 프록시를 거치지 않게 합니다. CodeSpace의 작업 공간은 전체가 쓰기 가능하며, loopback 트래픽도 CodeSpace의 프록시를 거칩니다([#53](https://github.com/novelKR/CodeSpace/pull/53), [#55](https://github.com/novelKR/CodeSpace/pull/55)). +- `codex-uds`는 이미 있는 소켓 디렉터리의 권한을 0700으로 바꿉니다. CodeSpace 게이트웨이는 대신 새 0700 디렉터리를 만들고, 워커는 그 안에서 bind만 합니다([#46](https://github.com/novelKR/CodeSpace/pull/46)). +- PTY 크기는 업스트림 기본값에 기대지 않고 24×80으로 명시합니다([#49](https://github.com/novelKR/CodeSpace/pull/49)). + + + +### 고정 버전에 없는 기능 + +모두 CodeSpace 안에서 메우거나, 문서화하고 테스트로 고정했습니다. 버전을 옮긴 경우는 없습니다. + +- **파일 권한.** Codex의 파일 시스템 인터페이스에는 chmod가 없고, 링크를 따라가지 않는 `chmodat`은 Linux에서 실패했으며, `/`부터 내려가는 Codex의 경로 탐색은 `/var`가 심볼릭 링크인 macOS에서 실패합니다. CodeSpace는 링크를 따라가지 않고 파일을 연 뒤 열린 descriptor로 권한을 바꾸고, 작업 공간 루트를 정규화합니다. 경로 기반 chmod로 대신하는 방식은 링크를 따라가지 않는다는 보장을 약하게 하므로 배제했습니다([#25](https://github.com/novelKR/CodeSpace/pull/25), [#51](https://github.com/novelKR/CodeSpace/pull/51), [#52](https://github.com/novelKR/CodeSpace/pull/52)). +- **PTY의 신호 종료.** 고정한 PTY는 신호로 끝난 프로세스를 종료 코드 1로 보고합니다. pipe 경로는 이제 신호를 보고하고, PTY의 동작은 문서화해 테스트로 고정했습니다([#85](https://github.com/novelKR/CodeSpace/issues/85), [#87](https://github.com/novelKR/CodeSpace/pull/87)). +- **macOS의 descriptor 상속.** 동시에 실행한 spawn이 서로의 descriptor를 넘길 수 있었고, Codex 자체 descriptor 정책은 이후 릴리스에만 있습니다. CodeSpace는 자기 spawn에서 상속된 descriptor에 close-on-exec를 표시합니다. 버전 변경은 대안 가운데 하나였지만 고르지 않았습니다([#79](https://github.com/novelKR/CodeSpace/issues/79), [#81](https://github.com/novelKR/CodeSpace/pull/81)). +- **macOS의 fork 안전성.** fork한 자식이 libnotify 안에서 중단될 수 있었고, PTY의 fork는 고정한 코드에 속합니다. CodeSpace는 fork 전에 libnotify 초기화를 끝내며, 이는 구조적 해결이 아닌 완화책으로 기록했습니다([#86](https://github.com/novelKR/CodeSpace/pull/86)). +- **관리형 실행의 descriptor.** 고정한 PTY는 이미 상속 가능한 descriptor만 넘기며, `ChildFds::Attached`는 이후 릴리스가 필요합니다. CodeSpace는 실행용 descriptor를 그 spawn 동안에만 상속 가능하게 하고, 그동안 다른 spawn은 이를 제외합니다([#90](https://github.com/novelKR/CodeSpace/pull/90)). +- **툴체인.** 루트 manifest는 Rust 1.88을 선언하지만 고정한 체크아웃은 1.95.0을 고릅니다. [운영 가이드](operations.md)는 1.88이 검증된 최소 버전이 아니라고 밝힙니다([#56](https://github.com/novelKR/CodeSpace/pull/56)). + + + +### 회수 전 관찰: 첫 패치 + +- **문제.** 고정한 `codex-utils-pty`는 자기 task에서 자식을 회수하며 회수 전 hook이 없습니다. 그래서 관리형 PTY 실행은 루트가 회수되기 전에 관찰할 수 없습니다([DevGuard 연동](devguard-integration.md#devguard-lifecycle)). +- **검토한 것**(2026-10-11, [DevGuard #14](https://github.com/novelKR/DevGuard/issues/14#issuecomment-6110596020)): 문서화된 제약으로 두기, Codex의 공개 `spawn_from_driver` 위에 CodeSpace 자체 PTY를 두기, Codex에 회수 전 hook을 넣기. [업스트림 어댑터 명세](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-cs-dg-upstream-adapter-work-spec-1.md#8-preferred-investigation-b-an-upstream-owned-bounded-pre-reap-observation-contract)는 업스트림이 소유하고 시간 제한이 있는 회수 전 관찰을 우선하며, CodeSpace 자체 PTY는 CodeSpace가 Codex에 맡긴 메커니즘을 CodeSpace 안으로 옮기는 일입니다. +- **결정.** 소유자는 hook을 골랐고, 수명 주기 작업과 병행했습니다. [#92](https://github.com/novelKR/CodeSpace/pull/92)는 PTY를 회수 뒤에 관찰하는 상태로 병합했고, hook은 별도 변경으로 준비했습니다. 관찰 기능은 먼저 Codex 사전 릴리스에서 만들어 시험했고([DevGuard W3 결정 자료](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132) 3.2절), 그 뒤 `rust-v0.154.0`으로 옮겼습니다. +- **형식.** 서브모듈이 가리킬 커밋을 담은 fork, 또는 공식 릴리스 위의 패치 파일. 어느 쪽이든 버전을 옮길 때 다시 옮겨야 하며, `rust-v0.159.x`에서는 이 PTY 코드가 바뀝니다. 소유자는 패치 파일을 골랐습니다. 서브모듈은 공식 릴리스에 남고, 변경과 그 기록, 이후의 이식은 모두 CodeSpace에서 검토하며, 따로 최신으로 유지할 저장소가 생기지 않습니다. 이 문서는 그 선택을 규칙으로 정한 것입니다. + + + +### 사례에서 드러난 원칙 + +- Codex 코드는 릴리스 태그의 서브모듈에서 가져오며, crate를 복사하지 않습니다. +- 어댑터는 독립된 workspace에 두고, Codex workspace의 설정인 `[patch]` 항목과 정확한 버전을 그대로 따르며, 잠금 파일대로 빌드합니다. +- 핵심 crate가 지면 안 되는 Codex 의존성 그래프는 도우미 프로세스와 의존성 검사 뒤에 둡니다. +- 업스트림 기본값이 CodeSpace의 정책을 약하게 하는 곳에서는 CodeSpace의 정책을 따릅니다. +- 고정 버전에 없는 기능은 CodeSpace에서 메우거나, 문서화하고 테스트로 고정합니다. 버전 이동은 언제나 그 자체로 별도의 결정이며 다른 작업의 일부가 아닙니다. +- Codex 코드는 어댑터로도 버전 이동으로도 해결되지 않고 CodeSpace가 그 메커니즘을 넘겨받아서도 안 될 때만 바꾸며, 그때도 공식 릴리스 위의 기록된 패치로만 바꿉니다. + + + +### 남은 점 + +사례를 모으면서 아직 해결되지 않은 세 가지가 드러났습니다. + +- `crates/patch`에는 Rama 버전 고정이 없어, 파일 시스템 어댑터나 Linux 샌드박스 어댑터와 달리 잠금 파일에만 기댑니다. +- 패치 도우미의 이유로 기록된 충돌([#27](https://github.com/novelKR/CodeSpace/pull/27))은 루트가 Codex의 `[patch]` 항목을 가져오기([#51](https://github.com/novelKR/CodeSpace/pull/51)) 전의 일입니다. 지금 루트 workspace가 `codex-apply-patch`를 링크할 수 있는지는 확인되지 않았습니다. +- 기록은 `rust-v0.154.0`을 후보 버전으로만 적고 고른 이유는 적지 않았습니다. [업데이트 절차](upstream-update.md)는 그 이유를 기록하도록 요구합니다. diff --git a/docs/ko/upstream-update.md b/docs/ko/upstream-update.md index 2eaf652..0796c41 100644 --- a/docs/ko/upstream-update.md +++ b/docs/ko/upstream-update.md @@ -16,16 +16,16 @@ 태그나 커밋을 명시적으로 선택하고 이유를 기록합니다. 패치 처리, PTY, 프로세스 보호, 파일 시스템, 샌드박스, 프록시의 관련 변경을 확인하세요. 런타임 의존성과 개발용 의존성은 구분합니다. 개발용 의존성이 있다는 사실만으로 제품 실행 파일에 포함된다고 볼 수는 없습니다. -서브모듈, [버전 기록](upstream-lock.md), 영향받는 어댑터 잠금 파일, 필요한 출처 고지를 함께 갱신합니다. 핵심 타입과 Codex 어댑터 타입의 분리를 유지하세요. 호환되지 않는 의존성을 감추기 위해 업스트림 crate 하나를 제품에 복사하지 않습니다. +서브모듈을 옮기기 전에 `python3 scripts/codex_patch.py revert`로 기록된 Codex 패치를 되돌립니다. 서브모듈, [버전 기록](upstream-lock.md), 영향받는 어댑터 잠금 파일, 필요한 출처 고지를 함께 갱신합니다. 핵심 타입과 Codex 어댑터 타입의 분리를 유지하세요. 호환되지 않는 의존성을 감추기 위해 업스트림 crate 하나를 제품에 복사하지 않습니다. -현재 업스트림 코드의 제한적 adaptation은 없습니다. [adaptation 정책](codex-reuse.md)에 따라 추가한다면 고정 버전을 바꿀 때마다 검토합니다. 기록된 재검토 조건에 따라 후보의 업스트림 소스와 비교하고, 출처와 달라진 동작 기록을 갱신하고, 테스트를 다시 실행하며, 제거 조건을 충족하면 제거하거나 업스트림으로 다시 수렴합니다. 새 업스트림 API를 도입하려면 이 별도의 버전 갱신이 필요하며 다른 변경의 부수 효과로 도입하지 않습니다. +업스트림 코드의 제한적 adaptation은 하나 있습니다. 고정한 릴리스의 파일에 적용하는 `codex-utils-pty`의 [하위 패치](upstream-lock.md#codex-downstream-patch)입니다. 패치는 [패치 정책](upstream-patches.md#patch-pin-changes)에 따라 고정 버전을 바꿀 때마다 검토합니다. 기록된 재검토 조건에 따라 후보의 업스트림 소스와 비교하고, 출처와 달라진 동작 기록을 갱신하고, 테스트를 다시 실행하며, 제거 조건을 충족하면 제거하거나 업스트림으로 다시 수렴합니다. 새 업스트림 API를 도입하려면 이 별도의 버전 갱신이 필요하며 다른 변경의 부수 효과로 도입하지 않습니다. ## 제출 전 검증 -1. `PIN_ONLY=1 ./scripts/check-upstream-pin.sh`로 고정 커밋을 확인합니다. +1. `python3 scripts/codex_patch.py apply`로 기록된 패치를 적용한 뒤 `PIN_ONLY=1 ./scripts/check-upstream-pin.sh`로 고정 커밋과 패치 적용 tree를 확인합니다. 2. 루트 workspace와 분리된 patch, codex-runtime, pty, file-system, linux-sandbox 어댑터에서 `cargo fmt --check`, `cargo clippy --locked --all-targets -- -D warnings`, 테스트를 수행합니다. 3. 패치·런타임·Linux 도우미를 빌드하고 통합 테스트가 의도한 실행 파일을 사용하게 설정합니다. workspace 통합·프로토콜 테스트를 실행합니다. 4. bubblewrap과 네임스페이스를 지원하는 Linux에서 `CODESPACE_REQUIRE_LINUX_SANDBOX=1`로 격리 테스트를 실행합니다. restricted 차단과 enabled 프록시 동작을 포함합니다. diff --git a/docs/operations.md b/docs/operations.md index b4660d0..c5ac1f3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,13 +8,15 @@ This guide sets up a personal, single-user server on macOS or Linux. For the too ## Install -Install Git and a current Rust stable toolchain, matching CI. The root manifest declares Rust 1.88, but that is not a verified minimum for the full Codex adapter graph; the pinned upstream checkout selects Rust 1.95.0. On macOS, install the Xcode command-line tools. Linux command isolation additionally needs the sandbox helper, bubblewrap, and permission to create the required namespaces. Docker is only needed for the separate [container fixture](../deploy/README.md). +Install Git, Python 3 and a current Rust stable toolchain, matching CI. The root manifest declares Rust 1.88, but that is not a verified minimum for the full Codex adapter graph; the pinned upstream checkout selects Rust 1.95.0. On macOS, install the Xcode command-line tools. Linux command isolation additionally needs the sandbox helper, bubblewrap, and permission to create the required namespaces. Docker is only needed for the separate [container fixture](../deploy/README.md). ```bash git clone --recurse-submodules https://github.com/novelKR/CodeSpace.git cd CodeSpace # For an existing clone: git submodule update --init --recursive +# Apply CodeSpace's recorded Codex patch; repeat after every submodule update: +python3 scripts/codex_patch.py apply cargo build --locked -p codespace-server --bin codespace-mcp --release cargo build --locked --manifest-path crates/patch/Cargo.toml --bin codespace-patch --release mkdir -p dist diff --git a/docs/translations.json b/docs/translations.json index 47170e4..4c06e33 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -96,8 +96,8 @@ "다음-단계", "목적에-맞는-문서-찾기" ], - "source_sha256": "2fb1f05b450ed1a948310d4fdafe1781ef14979990f2ecfebfae98f7ebdab7ca", - "translation_sha256": "d4ea3230b9f759e5bca3f59ca45a115341d901eb7d66758af935fe113ccb86b3" + "source_sha256": "a098986ba47c973d99934eb5a101cc224bb4a8379d772938604d82703cf70aa6", + "translation_sha256": "b9aafd2aa8cc5ce51c900c4c62adaace784a29dbc75632970f8292247628afa6" }, { "id": "getting-started", @@ -201,8 +201,8 @@ "작업-공간-등록", "첫-연결-확인" ], - "source_sha256": "004d143763000d45ac44ee5b95cd2f62810d8b89024488bc4aebada37e00b9a5", - "translation_sha256": "cfabfcac93d4f9d8fe68d67c7237fc9c0555543c4ce9459f8d52e71a8a942e1f" + "source_sha256": "de7bbd350892090e5fb352a06e47ce8e95f96b0c2b2dc6128a4edef5f1d9af2b", + "translation_sha256": "aed949328aff2698dbc138d4335ceb97f5c42f0fb1ed8499eb6c0e6469fdc90d" }, { "id": "chatgpt-connector", @@ -609,8 +609,8 @@ "핀-6b9826e의-후보", "핵심-대-어댑터" ], - "source_sha256": "53902d864401da1900b58086d0b92b5d437f843caacfd846f0ac7009a6406983", - "translation_sha256": "ff9eaae8c6b46111c2459a20d2707b01f396e5d15cac1b12ee68688ba123f3af" + "source_sha256": "121d4af8ffeecbe2cf1cf34c23046664637e6d906a38f7cfd2f7a7a0d5ebe656", + "translation_sha256": "fd1b6ffcc4f23f07caad0421546ee59fcae3b0ad6926a0b1900df16e8ac9c911" }, { "id": "upstream-lock", @@ -622,11 +622,13 @@ "anchors": [ "cargo-workspace와-잠금-파일", "check-and-update", + "codex-downstream-patch", "codex-고정-버전", "deployment-pin", "deployment-pin-w06", "devguard-client-pin", "devguard-client-고정-버전", + "downstream-patch", "pinned-codex-revision", "promotion-rule", "upstream-lock", @@ -644,10 +646,11 @@ "업스트림-고정", "재사용하는-것과-거절하는-것", "파일-복사-벤더가-금지인-이유", + "하위-패치", "확인과-갱신" ], - "source_sha256": "bcc3fe556188b33bddf52ffa3964639f34ec68f96d69f87995aad9c92bce04f2", - "translation_sha256": "203d196d391c41b436b9fb561082db7175bb0675c930addea0cd873eefc0530c" + "source_sha256": "f6fe4106df84b502eaaea65d40c666ade982253f658a46e8de769f7911d80b7f", + "translation_sha256": "bcdcb7a4e9b1080f5b5a66159dd1d693b82ed6bf0dccc6e346e8dfc33be0ca16" }, { "id": "upstream-update", @@ -682,8 +685,70 @@ "향후-crate와-백엔드의-경계", "후보-검토" ], - "source_sha256": "b9dc9fdb4f6634b9f149540513dbbb9ae72d490a0ab02adc579d1092ad689f56", - "translation_sha256": "6297a87a070817627e236efd49f7999034f84018d23db198880d2ef8c92e88f7" + "source_sha256": "ddad4af90f67de89b70c93ff143b72386bc0227e66dadeba3cf73491dab1e853", + "translation_sha256": "070d7b0a63d6835df285bd8f8905c4a9c4e4ec0144b25d1e0ec527d9f5ee2985" + }, + { + "id": "upstream-patches", + "section": "implementation", + "order": 3, + "route": "/guide/upstream-patches", + "source": "docs/upstream-patches.md", + "translation": "docs/ko/upstream-patches.md", + "anchors": [ + "approval", + "building-inside-codexs-workspace", + "capabilities-missing-at-the-pin", + "case-defaults", + "case-graph", + "case-missing", + "case-open-points", + "case-patterns", + "case-pre-reap", + "case-taking-code", + "case-workspace", + "changing-codex-at-a-pin", + "codespace가-바꿔-쓰는-업스트림-기본값", + "codex-workspace-안에서-빌드하기", + "codex-의존성-그래프를-핵심-crate-밖에-두기", + "codex-코드를-가져오는-방식", + "earlier-cases", + "form", + "keeping-codexs-graph-out-of-the-core", + "observing-before-the-reap-the-first-patch", + "open-points", + "patch-approval", + "patch-form", + "patch-pin-changes", + "patch-policy", + "patch-record", + "patch-upstream", + "pin-changes", + "policy", + "record", + "taking-codex-code", + "upstream-defaults-that-codespace-overrides", + "upstream-first", + "upstream-patches", + "what-the-cases-show", + "when-a-patch-is-the-answer", + "when-to-patch", + "고정-버전에-없는-기능", + "고정-버전의-codex-수정", + "기록", + "남은-점", + "버전-변경", + "사례에서-드러난-원칙", + "승인", + "업스트림-우선", + "이전-사례", + "정책", + "패치를-쓰는-경우", + "형식", + "회수-전-관찰-첫-패치" + ], + "source_sha256": "f48b33c4789ba32780e63ac790c98c45fa69c21cf8398b7015168c73fcaa3272", + "translation_sha256": "7d84836d7c0e01504955e61ff72029b5650381811c71496cb31ed93622d0e5f1" }, { "id": "documentation", diff --git a/docs/upstream-lock.md b/docs/upstream-lock.md index 81f11cd..2fd5c1c 100644 --- a/docs/upstream-lock.md +++ b/docs/upstream-lock.md @@ -17,11 +17,33 @@ All current Codex execution adapters use the Git submodule in `third_party/codex | Tag | `rust-v0.154.0` | | Commit | `6b9826e3aa83b1a5947db50f4332cb9c65f1b340` | | Path | `third_party/codex` | +| Downstream patch | one, applied to the files of this commit: see [Downstream patch](#codex-downstream-patch) | | Consumers | Patch, runtime worker, PTY, filesystem, Linux sandbox and proxy adapters | | Patch options | `PreserveLineEndings`, `follow_symlinks: false` | See [connected components](codex-reuse.md) for the exact adapter responsibilities. Patch tests cover selected parity cases, not the entire upstream suite or all Codex behavior. + + +## Downstream patch + +CodeSpace carries one patch to `codex-utils-pty`, under the [policy for changing Codex at a pin](upstream-patches.md). The submodule stays on `openai/codex` at the commit above, and the patch is applied to its files; every other Codex crate is identical to the tag. `scripts/codex_patch.py` reads the patch file, its digest and the patched tree from this table. + +| Field | Value | +| --- | --- | +| Patch | `third_party/codex-patches/0001-utils-pty-pre-reap-observation.patch`, SHA-256 `6f61579830f311e3c259c0642864ea9eee3c653f12c7e838e97e9b1dd35b1fba` | +| Patched tree | `1999937194e3a68d982be85679557f0e50fe5408` | +| Files | `observe.rs` (new), `observe_tests.rs` (new, tests only), `pty.rs` and `lib.rs` in `codex-rs/utils/pty/src`: 304 product lines added and 5 removed, and 9 tests | +| Capability | `spawn_pty_process_observed` with an `observe::ExitObserver` registered before the spawn. The child's single waiter sees the exit without reaping it (`waitid` with `WNOWAIT`), gives the observer one opportunity for at most the observer's bound, then reaps it as before and reports `Completed`, `TimedOut`, `Failed` or `Unavailable`. Without an observer every path is unchanged. Platforms other than Unix refuse an observed spawn | +| Reason | observing a governed PTY execution's scope before Codex reaps its root ([CSRG-U5](devguard-integration.md#devguard-lifecycle)), as the owner chose on 2026-10-11; the contract is section 8 of the [upstream adapter work specification](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-cs-dg-upstream-adapter-work-spec-1.md#8-preferred-investigation-b-an-upstream-owned-bounded-pre-reap-observation-contract) | +| Use in CodeSpace | none yet: the PTY adapter still calls `spawn_pty_process`. A separate change connects the observer | +| Provenance | ported from the observer of W3 experiment B ([DevGuard decision packet, section 3.2](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132)) to both Unix PTY paths of this release. Each modified file carries a CodeSpace modification notice (Apache-2.0 section 4(b)) | +| Verification | on the patched tree: Linux x86_64, the crate's 36 tests in each of 30 runs; macOS, its 38 tests in each of 30 runs and W3 experiment B's cases B1-B13 against DevGuard `6e7e065`. Clippy is clean for Linux, macOS and Windows targets | +| Upstream | Codex accepts no external pull requests. A feature request is drafted and not submitted | +| Removal | drop the patch when a Codex release offers an equivalent observation (one waiter, a bound, an outcome) and CodeSpace moves to it, or when CodeSpace no longer observes PTY executions before the reap | +| Pin changes | revert the patch, move the submodule, then port the patch to the candidate or drop it under the removal condition; rerun its tests on Linux and macOS and update this record | +| Rollback | revert the patch file and this record together, after reverting any CodeSpace use of the observer | + ## DevGuard client pin @@ -55,7 +77,8 @@ The patch adapter calls the library inside `codespace-patch`; it does not invoke ## Check and update ```bash +python3 scripts/codex_patch.py apply PIN_ONLY=1 ./scripts/check-upstream-pin.sh ``` -This checks only the submodule revision against this document. Without `PIN_ONLY`, the script also runs patch tests; it does not replace the other adapter gates. Use the [complete update procedure](upstream-update.md) before changing the pin. Do not use `git submodule update --remote` as a deployment step. +Apply the [recorded patch](#codex-downstream-patch) after each clone or submodule update, before building; `scripts/validate-upstream.py` applies it before every validation. The pin check verifies only the submodule revision against this document and that the submodule's files are exactly the recorded patched tree. Without `PIN_ONLY`, the script also runs patch tests; it does not replace the other adapter gates. Run `python3 scripts/codex_patch.py revert` before moving the submodule, and use the [complete update procedure](upstream-update.md) before changing the pin. Do not use `git submodule update --remote` as a deployment step. diff --git a/docs/upstream-patches.md b/docs/upstream-patches.md new file mode 100644 index 0000000..bc2c685 --- /dev/null +++ b/docs/upstream-patches.md @@ -0,0 +1,145 @@ + + +# Changing Codex at a pin + +[English](upstream-patches.md) | [한국어](ko/upstream-patches.md) + +CodeSpace runs Codex code from one [pinned release](upstream-lock.md). This page decides what CodeSpace does when that release cannot do what CodeSpace needs, and, when the answer is a change to Codex's code, how the change is made: as a recorded patch on the official release, never as a fork. The [earlier cases](#earlier-cases) show how CodeSpace handled such gaps before; the policy follows from them so that later decisions take the same path. The patch in force is in the [pin record](upstream-lock.md#codex-downstream-patch). + + + +## Policy + + + +### When a patch is the answer + +Weigh the options in this order, and record why each earlier one does not serve: + +1. the pinned release's public API, with the gap handled on CodeSpace's side of an adapter; +2. a Codex release that already has the capability, taken through a reviewed [pin update](upstream-update.md); +3. a recorded patch to the pinned release, under this policy; +4. a CodeSpace-owned replacement for the Codex mechanism. + +While the work can wait, the gap can also stay as a documented limitation that a test pins. + +A patch is chosen only when the pinned release blocks work that CodeSpace has decided to do now, an adapter cannot close the gap, and moving the pin is not justified for that work alone. It is limited to execution mechanisms such as process, PTY, terminal, descriptor and I/O helpers. It never changes `codex-core` product semantics, session authority or the agent loop, and never hides a dependency from the checks. + +A patch comes before a CodeSpace-owned replacement because CodeSpace stays a thin adapter over the mechanisms it delegates to Codex: the DevGuard integration alone never justifies moving one of them, notably the PTY, into CodeSpace ([boundary review](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-27-cs-rg-boundary-revalidation.md#1-why-this-review-happened), section 1). [Codex reuse](codex-reuse.md#reuse-decisions-for-managed-execution) recorded the same order for managed execution; that section is suspended as an implementation directive, so this page states the order for every change at the pin. + + + +### Form + +- The submodule stays on `openai/codex` at the official release commit. The gitlink never names a modified commit, and CodeSpace keeps no fork of Codex. +- Each patch is one `git format-patch` file in `third_party/codex-patches/`, made against the pinned commit and numbered in the order it applies. +- `scripts/codex_patch.py` applies the patches to the submodule's files, and the result must be the recorded patched tree. The pin check and `scripts/validate-upstream.py` refuse a missing patch or any other change in the checkout. +- No Codex crate is copied into CodeSpace, and no Cargo `[patch]` entry points a Codex crate at a modified copy. The `[patch]` entries copied from Codex's own workspace, which build third-party crates from OpenAI's forks, are not CodeSpace changes and stay as they are. +- A patch keeps upstream behavior unless a caller opts in. It carries a modification notice in each file it changes (Apache-2.0 section 4(b)) and adds tests to the patched crate that run on Linux and macOS. + + + +### Record + +For each patch, the [pin record](upstream-lock.md#codex-downstream-patch) holds the file and its SHA-256, the patched tree, the files changed, the capability, the reason, its use in CodeSpace, provenance, verification on Linux and macOS, upstream status, the removal condition, what a pin change does with it, and rollback. The record and the patch change in the same PR. `scripts/codex_patch.py` reads the record, so the two cannot drift apart. + + + +### Upstream first + +Codex takes code changes only from its own team and asks for feature requests in its issue tracker ([contributing](https://github.com/openai/codex/blob/rust-v0.154.0/docs/contributing.md)). Each patch's capability is proposed there as a feature request, with the patch as a reference. Submitting the request is the owner's decision. Its status stays in the record, and the patch is removed once a Codex release offers an equivalent and CodeSpace moves to it. + + + +### Pin changes + +A pin update reverts the patches (`python3 scripts/codex_patch.py revert`) and moves the submodule. In the same PR it ports each patch to the candidate or drops it under its removal condition, reruns the patch's tests and every affected adapter on Linux and macOS, and updates the record. A patch never moves the pin by itself. + + + +### Approval + +Adding, changing or removing a patch is a reviewed CodeSpace PR, merged at its exact head with the owner's approval, like a pin change. + + + +## Earlier cases + +From the first pin in September 2026 to the PTY observer in October 2026, the pinned release often lacked something an adapter needed. Until the PTY observer, every case was settled without changing Codex source and without moving the pin. The cases fall into six groups. Each link leads to the issue or PR that recorded the decision. + + + +### Taking Codex code + +- **Gap.** `codex-apply-patch` depends on other crates of the Codex workspace, such as `codex-exec-server`, the path utilities and tree-sitter, so it cannot be copied out on its own. +- **Weighed.** Copying a single crate was out of scope from the start ([#1](https://github.com/novelKR/CodeSpace/issues/1)). [#25](https://github.com/novelKR/CodeSpace/pull/25) also ruled out wrapping Codex's standalone `apply_patch` binary, reimplementing the patch format and falling back to `git apply`. +- **Chosen.** The `third_party/codex` submodule at `rust-v0.154.0` ([#25](https://github.com/novelKR/CodeSpace/pull/25)). The pin check rejects any other commit, and `git submodule update --remote` is never a deployment step. +- **Later releases.** `rust-v0.157.1`, `rust-v0.159.0-alpha.11` and stable `rust-v0.159.0` to `rust-v0.159.2` each offered something CodeSpace could use, such as Codex's descriptor policy or `ChildFds`. None was taken as part of other work ([#76](https://github.com/novelKR/CodeSpace/issues/76)). The owner's planning direction of 2026-09-30 keeps `rust-v0.154.0` until a separate, qualified pin PR moves CodeSpace to a reviewed `rust-v0.159.x`, with the PTY adapter's `ChildFds` edit in that PR ([DevGuard #14](https://github.com/novelKR/DevGuard/issues/14#issuecomment-5914489840)). + + + +### Building inside Codex's workspace + +- **Gap.** The pinned crates expect Codex's own Cargo workspace: its shared dependency versions, and `[patch]` entries that build `tungstenite` and `tokio-tungstenite` from OpenAI's forks with features the crates.io releases lack. Cargo applies `[patch]` only from the manifest at the root of a build. +- **Chosen.** Each adapter is its own Cargo workspace with its own lockfile, and the root workspace excludes them ([#25](https://github.com/novelKR/CodeSpace/pull/25)). Each workspace that reaches Codex copies the `[patch]` entries it needs from Codex's manifest unchanged, and they change and revert together with the pin ([#25](https://github.com/novelKR/CodeSpace/pull/25), [#51](https://github.com/novelKR/CodeSpace/pull/51)). +- **Rama.** Codex pins its `rama` crates at `=0.3.0-alpha.4`, but those crates accept their leaf crates at `^0.3.0-alpha.4`, so a fresh resolve mixed in the stable 0.3.0 leaves and broke the build. The file-system and Linux sandbox adapters pin `rama-error`, `rama-macros` and `rama-utils` exactly, and CI builds with `--locked` ([#53](https://github.com/novelKR/CodeSpace/pull/53), [#54](https://github.com/novelKR/CodeSpace/pull/54)). +- **Lockfiles.** A change to the runner's dependencies must reach the worker's lockfile ([#53](https://github.com/novelKR/CodeSpace/pull/53), [#54](https://github.com/novelKR/CodeSpace/pull/54)). CI's cache keys name each lockfile because a glob walked into the submodule ([#67](https://github.com/novelKR/CodeSpace/pull/67)), and a test build no longer rewrites a stale lockfile ([#69](https://github.com/novelKR/CodeSpace/pull/69)). + + + +### Keeping Codex's graph out of the core + +- **Patch engine.** The gateway could not link `codex-apply-patch` in the root workspace because of a `tokio-tungstenite` feature conflict, so the engine runs in the `codespace-patch` helper, which takes JSON on stdin and answers on stdout ([#27](https://github.com/novelKR/CodeSpace/pull/27)). +- **File system.** No-follow file I/O (`LOCAL_FS`) comes from `codex-exec-server`, which brings `codex-protocol` and other Codex crates into the root lockfile. Public types stay CodeSpace's, core crates take no direct Codex dependency, and the dependency check rejects `codex-core`, `codex-exec`, `codex-app-server` and `codex-login` in every product graph ([#51](https://github.com/novelKR/CodeSpace/pull/51), [#57](https://github.com/novelKR/CodeSpace/pull/57)). +- **Linux sandbox.** `codex-linux-sandbox` re-runs its own executable inside bubblewrap. It is wrapped in a binary-only helper, the runner depends only on a small protocol crate, and the dependency check keeps the helper's graph out of the runner ([#53](https://github.com/novelKR/CodeSpace/pull/53), [#54](https://github.com/novelKR/CodeSpace/pull/54), [#57](https://github.com/novelKR/CodeSpace/pull/57)). + + + +### Upstream defaults that CodeSpace overrides + +- Codex's workspace-write profile keeps `.git`, `.agents` and `.codex` read-only, and its default `NO_PROXY` lets traffic to loopback addresses bypass the proxy. CodeSpace's workspace is writable throughout, and loopback traffic goes through CodeSpace's proxy ([#53](https://github.com/novelKR/CodeSpace/pull/53), [#55](https://github.com/novelKR/CodeSpace/pull/55)). +- `codex-uds` changes the mode of an existing socket directory to 0700. CodeSpace's gateway allocates a new 0700 directory instead, and the worker only binds in it ([#46](https://github.com/novelKR/CodeSpace/pull/46)). +- The PTY size is set to 24×80 explicitly rather than taken from upstream's default ([#49](https://github.com/novelKR/CodeSpace/pull/49)). + + + +### Capabilities missing at the pin + +Each was closed in CodeSpace or documented and pinned by a test. None moved the pin. + +- **File modes.** Codex's file-system interface has no chmod, a no-follow `chmodat` failed on Linux, and Codex's walk from `/` fails on macOS, where `/var` is a symbolic link. CodeSpace opens the file without following links and changes the mode through the open descriptor, and it canonicalizes the workspace root. A path-based chmod was rejected because it weakens no-follow ([#25](https://github.com/novelKR/CodeSpace/pull/25), [#51](https://github.com/novelKR/CodeSpace/pull/51), [#52](https://github.com/novelKR/CodeSpace/pull/52)). +- **Signal deaths on PTY.** The pinned PTY reports a process killed by a signal as exit code 1. The pipe path now reports the signal; the PTY behavior is documented and pinned by a test ([#85](https://github.com/novelKR/CodeSpace/issues/85), [#87](https://github.com/novelKR/CodeSpace/pull/87)). +- **Descriptor inheritance on macOS.** Concurrent spawns could pass each other's descriptors, and Codex's own descriptor policy exists only in later releases. CodeSpace marks inherited descriptors close-on-exec in its own spawns; a pin change was among the alternatives and was not chosen ([#79](https://github.com/novelKR/CodeSpace/issues/79), [#81](https://github.com/novelKR/CodeSpace/pull/81)). +- **Fork safety on macOS.** Forked children could abort inside libnotify, and the PTY's fork belongs to the pinned code. CodeSpace completes libnotify's initialization before it forks, recorded as a mitigation rather than a structural fix ([#86](https://github.com/novelKR/CodeSpace/pull/86)). +- **Descriptors for governed launches.** The pinned PTY passes on only descriptors that are already inheritable, and `ChildFds::Attached` needs a later release. CodeSpace makes the launch descriptors inheritable only around that spawn, while its other spawners exclude them ([#90](https://github.com/novelKR/CodeSpace/pull/90)). +- **Toolchain.** The root manifest declares Rust 1.88, while the pinned checkout selects 1.95.0. The [operations guide](operations.md) says 1.88 is not a verified minimum ([#56](https://github.com/novelKR/CodeSpace/pull/56)). + + + +### Observing before the reap: the first patch + +- **Gap.** The pinned `codex-utils-pty` reaps the child in its own task, with no hook before the reap, so a governed PTY execution cannot be observed before its root is reaped ([DevGuard integration](devguard-integration.md#devguard-lifecycle)). +- **Weighed** on 2026-10-11 ([DevGuard #14](https://github.com/novelKR/DevGuard/issues/14#issuecomment-6110596020)): keeping a documented limitation, a CodeSpace-owned PTY on Codex's public `spawn_from_driver`, or a pre-reap hook in Codex. The [upstream adapter specification](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-cs-dg-upstream-adapter-work-spec-1.md#8-preferred-investigation-b-an-upstream-owned-bounded-pre-reap-observation-contract) prefers an upstream-owned, bounded observation before the reap, and a CodeSpace-owned PTY would move a mechanism CodeSpace delegates to Codex into CodeSpace. +- **Chosen.** The owner chose the hook, in parallel with the lifecycle work: [#92](https://github.com/novelKR/CodeSpace/pull/92) merged with PTY observation after the reap, and the hook was prepared as a separate change. The observer had first been built and tested on a Codex prerelease ([DevGuard W3 decision packet](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132), section 3.2) and was ported to `rust-v0.154.0`. +- **Form.** A fork whose commit the submodule would name, or a patch file on the official release. Either has to be ported when the pin moves, and `rust-v0.159.x` changes this PTY code. The owner chose the patch file: the submodule stays official, and the change, its record and each later port are reviewed in CodeSpace, with no second repository to keep current. This page makes that choice the rule. + + + +### What the cases show + +- Codex code comes from a submodule at a release tag, never from a copied crate. +- Adapters live in their own workspaces and take Codex's workspace settings as they are: its `[patch]` entries and exact versions, with locked builds. +- Codex dependency graphs that the core must not carry stay behind helper processes and dependency checks. +- CodeSpace's own policy wins where an upstream default would weaken it. +- A missing capability is closed in CodeSpace, or documented and pinned by a test. A pin move is always its own decision, never part of other work. +- Codex's code changes only when neither an adapter nor a pin move fits and CodeSpace must not take over the mechanism, and then only as a recorded patch on the official release. + + + +### Open points + +Collecting the cases turned up three points that are not yet resolved: + +- `crates/patch` has no Rama resolver guard and relies on its lockfile alone, unlike the file-system and Linux sandbox adapters. +- The conflict recorded for the patch helper ([#27](https://github.com/novelKR/CodeSpace/pull/27)) predates the root's copy of Codex's `[patch]` entries ([#51](https://github.com/novelKR/CodeSpace/pull/51)). Whether the root workspace could now link `codex-apply-patch` is unverified. +- The records name `rust-v0.154.0` as the candidate pin but give no reason for choosing it, although the [update procedure](upstream-update.md) asks for one. diff --git a/docs/upstream-update.md b/docs/upstream-update.md index d35edcf..17d00cc 100644 --- a/docs/upstream-update.md +++ b/docs/upstream-update.md @@ -14,15 +14,15 @@ Update the pinned revision through a reviewed PR. The change can affect every ad Choose an explicit tag or commit and record the reason. Inspect relevant upstream changes in patch handling, PTY, hardening, filesystem, sandbox, and proxy code. Check runtime and development dependencies separately; the presence of a development-only dependency is not proof that it enters the product binary. -Update the submodule, [pin record](upstream-lock.md), affected adapter locks, and attribution when needed. Preserve the separation between core types and Codex adapter types. Never copy a single upstream crate into the product to conceal an incompatible dependency. +Revert the recorded Codex patches with `python3 scripts/codex_patch.py revert` before moving the submodule. Update the submodule, [pin record](upstream-lock.md), affected adapter locks, and attribution when needed. Preserve the separation between core types and Codex adapter types. Never copy a single upstream crate into the product to conceal an incompatible dependency. -No limited adaptation of upstream code exists today. If one is added under the [adaptation policy](codex-reuse.md), review it on every pin change: compare it with the candidate's upstream source against its recorded re-examination condition, update its recorded provenance and divergence, rerun its tests, and remove it or reconverge with upstream when its removal condition holds. Adopting a newer upstream API needs this separate pin update; it is never a side effect of another change. +One limited adaptation of upstream code exists: the [downstream patch](upstream-lock.md#codex-downstream-patch) to `codex-utils-pty`, applied to the files of the pinned release. Review each patch on every pin change, as the [patch policy](upstream-patches.md#patch-pin-changes) requires: compare it with the candidate's upstream source against its recorded re-examination condition, update its recorded provenance and divergence, rerun its tests, and remove it or reconverge with upstream when its removal condition holds. Adopting a newer upstream API needs this separate pin update; it is never a side effect of another change. ## Validate before submission -1. Check the pin with `PIN_ONLY=1 ./scripts/check-upstream-pin.sh`. +1. Apply the recorded patches with `python3 scripts/codex_patch.py apply`, then check the pin and the patched tree with `PIN_ONLY=1 ./scripts/check-upstream-pin.sh`. 2. Run `cargo fmt --check`, `cargo clippy --locked --all-targets -- -D warnings`, and tests for the root workspace and each isolated adapter: patch, codex-runtime, pty, file-system, linux-sandbox. 3. Build the patch, runtime, and Linux helper binaries; point integration tests at the intended binaries. Run workspace integration and protocol tests. 4. On Linux with bubblewrap and namespace support, run the sandbox isolation tests with `CODESPACE_REQUIRE_LINUX_SANDBOX=1`. Include restricted denial and enabled proxy behavior. diff --git a/scripts/check-upstream-pin.sh b/scripts/check-upstream-pin.sh index d8c545d..96cd68b 100755 --- a/scripts/check-upstream-pin.sh +++ b/scripts/check-upstream-pin.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Fail the deploy if the Codex submodule does not match docs/upstream-lock.md -# or if the isolated patch adapter tests fail. Never treat a red parity run -# as success. +# Fail the deploy if the Codex submodule does not match docs/upstream-lock.md, +# if its files are not exactly the recorded patched tree, or if the isolated +# patch adapter tests fail. Never treat a red parity run as success. set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" @@ -39,6 +39,9 @@ if [[ "$actual" != "$expected" ]]; then exit 1 fi +# The recorded downstream patches must be applied, and nothing else changed. +python3 scripts/codex_patch.py check + echo "upstream pin ok $actual" if [[ "${PIN_ONLY:-}" == "1" ]]; then diff --git a/scripts/codex_patch.py b/scripts/codex_patch.py new file mode 100644 index 0000000..cf1406f --- /dev/null +++ b/scripts/codex_patch.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Apply, check or revert the recorded Codex patches on the pinned checkout. + +The submodule stays at the official commit that docs/upstream-lock.md pins. The patches that +the record lists are applied to its files in order, and the result must be the patched tree the +record names. `apply` brings a clean checkout of the pinned commit to that tree, `check` only +verifies it and `revert` returns the files to the pinned commit before a pin change. Any other +state of the checkout is an error, so neither a missing patch nor a stray change passes as the +recorded source. +""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +LOCK = ROOT / 'docs' / 'upstream-lock.md' +CHECKOUT = ROOT / 'third_party' / 'codex' +PIN_SECTION = 'Deployment pin' +PATCH_SECTION = 'Downstream patch' + + +class PatchStateError(RuntimeError): + """The record, a patch file or the checkout is not in a state this script accepts.""" + + +@dataclass(frozen=True) +class Record: + commit: str + # Each patch file and its SHA-256, in the order they apply. + patches: tuple[tuple[Path, str], ...] + # The tree the patches produce from the pinned commit; None when no patch is recorded. + tree: str | None + + +def read_record(lock: Path = LOCK, root: Path = ROOT) -> Record: + """The pinned commit, the patches and the patched tree, as the pin record states them.""" + section, commit, tree, patches = None, None, None, [] + for line in lock.read_text(encoding='utf-8').splitlines(): + if line.startswith('## '): + section = line[3:].strip() + continue + cells = [cell.strip() for cell in line.split('|')] + if len(cells) < 4 or cells[0]: + continue + field, value = cells[1], cells[2] + if section == PIN_SECTION and field == 'Commit' and commit is None: + commit = _one(r'`([0-9a-f]{40})`', value, 'pinned commit') + elif section == PATCH_SECTION and field == 'Patch': + path = _one(r'`(third_party/codex-patches/[^`]+\.patch)`', value, 'patch file') + patches.append((root / path, _one(r'SHA-256 `([0-9a-f]{64})`', value, 'patch SHA-256'))) + elif section == PATCH_SECTION and field == 'Patched tree': + tree = _one(r'`([0-9a-f]{40})`', value, 'patched tree') + if commit is None: + raise PatchStateError(f'no pinned commit in the {PIN_SECTION} table of {lock}') + if bool(patches) != (tree is not None): + raise PatchStateError(f'{lock} must record a patched tree exactly when it records a patch') + return Record(commit, tuple(patches), tree) + + +def _one(pattern, value, what): + match = re.search(pattern, value) + if not match: + raise PatchStateError(f'cannot read the {what} from: {value}') + return match[1] + + +def git(checkout: Path, *args: str, env=None) -> str: + result = subprocess.run(['git', '-C', str(checkout), *args], env=env, capture_output=True, text=True) + if result.returncode: + raise PatchStateError(f'git {" ".join(args)} failed in {checkout}: {result.stderr.strip()}') + return result.stdout.strip() + + +def working_tree(checkout: Path = CHECKOUT) -> str: + """The tree of the checkout's files as they are, untracked files included and ignored ones + left out. It is built in a scratch index, so the checkout's own index is not touched.""" + with tempfile.TemporaryDirectory() as scratch: + env = dict(os.environ, GIT_INDEX_FILE=str(Path(scratch) / 'index')) + git(checkout, 'read-tree', 'HEAD', env=env) + git(checkout, 'add', '--all', env=env) + return git(checkout, 'write-tree', env=env) + + +def verify_patches(record: Record): + for path, expected in record.patches: + try: + actual = hashlib.sha256(path.read_bytes()).hexdigest() + except OSError as error: + raise PatchStateError(f'cannot read the recorded patch {path}: {error}') from error + if actual != expected: + raise PatchStateError(f'{path} has SHA-256 {actual}, not the recorded {expected}') + + +def state(record: Record, checkout: Path) -> str: + """'patched' when the checkout holds exactly the recorded source, 'pinned' when it holds the + pinned commit without the patches. Raises for any other state.""" + head = git(checkout, 'rev-parse', 'HEAD') + if head != record.commit: + raise PatchStateError(f'{checkout} is at {head}, not the pinned {record.commit}') + current = working_tree(checkout) + pinned = git(checkout, 'rev-parse', 'HEAD^{tree}') + if current == (record.tree or pinned): + return 'patched' + if current == pinned: + return 'pinned' + raise PatchStateError( + f'{checkout} has tree {current}, which is neither the pinned commit ({pinned}) nor the ' + f'recorded patched tree ({record.tree}); it has changes the record does not hold') + + +def apply(record: Record, checkout: Path = CHECKOUT) -> str: + verify_patches(record) + if state(record, checkout) == 'patched': + return f'Codex checkout already holds the recorded source: tree {record.tree or "of the pinned commit"}' + for path, _ in record.patches: + git(checkout, 'apply', '--whitespace=nowarn', str(path.resolve())) + if state(record, checkout) != 'patched': + raise PatchStateError(f'the patches did not produce the recorded tree {record.tree}') + return f'Applied {len(record.patches)} recorded patch(es) to {record.commit}: tree {record.tree}' + + +def check(record: Record, checkout: Path = CHECKOUT) -> str: + verify_patches(record) + if state(record, checkout) != 'patched': + raise PatchStateError( + f'{checkout} lacks the recorded patches; run python3 scripts/codex_patch.py apply') + return f'Codex checkout ok: {record.commit} with {len(record.patches)} recorded patch(es)' + + +def revert(record: Record, checkout: Path = CHECKOUT) -> str: + verify_patches(record) + if state(record, checkout) == 'pinned' or not record.patches: + return f'Codex checkout holds the pinned commit {record.commit} unpatched' + for path, _ in reversed(record.patches): + git(checkout, 'apply', '--reverse', '--whitespace=nowarn', str(path.resolve())) + if state(record, checkout) != 'pinned': + raise PatchStateError(f'reverting the patches did not restore the pinned commit in {checkout}') + return f'Reverted the recorded patches: {checkout} holds {record.commit}' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', choices=['apply', 'check', 'revert']) + args = parser.parse_args() + try: + print({'apply': apply, 'check': check, 'revert': revert}[args.command](read_record())) + except (OSError, PatchStateError) as error: + print(f'codex_patch {args.command} failed: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/tests/test_codex_patch.py b/scripts/tests/test_codex_patch.py new file mode 100644 index 0000000..94d5ac7 --- /dev/null +++ b/scripts/tests/test_codex_patch.py @@ -0,0 +1,152 @@ +import hashlib +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import codex_patch +from upstream_dependencies import ROOT + +GIT_ENV = dict(os.environ, GIT_AUTHOR_NAME='t', GIT_AUTHOR_EMAIL='t@example.invalid', + GIT_COMMITTER_NAME='t', GIT_COMMITTER_EMAIL='t@example.invalid', + GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM='1') + + +def git(cwd, *args): + return subprocess.run(['git', *args], cwd=cwd, env=GIT_ENV, check=True, + capture_output=True, text=True).stdout.strip() + + +def lock(commit, patches=(), tree=None): + text = '# Pin\n\n## Deployment pin\n\n| Field | Value |\n| --- | --- |\n' + text += f'| Commit | `{commit}` |\n| Patch options | none |\n\n' + if patches: + text += '## Downstream patch\n\n| Field | Value |\n| --- | --- |\n' + for path, digest in patches: + text += f'| Patch | `{path}`, SHA-256 `{digest}` |\n' + text += f'| Patched tree | `{tree}` |\n' + # A later table with the same field names is not the pin. + return text + '\n## Other pin\n\n| Field | Value |\n| --- | --- |\n| Commit | `' + 'f' * 40 + '` |\n' + + +class CodexPatchTests(unittest.TestCase): + def setUp(self): + self.scratch = tempfile.TemporaryDirectory() + self.root = Path(self.scratch.name) + self.checkout = self.root / 'third_party' / 'codex' + self.checkout.mkdir(parents=True) + git(self.checkout, 'init', '-q') + (self.checkout / 'pty.rs').write_text('fn spawn() {}\n') + (self.checkout / '.gitignore').write_text('target/\n') + git(self.checkout, 'add', '-A') + git(self.checkout, 'commit', '-q', '-m', 'release') + self.commit = git(self.checkout, 'rev-parse', 'HEAD') + self.pinned = git(self.checkout, 'rev-parse', 'HEAD^{tree}') + # The patch, made the way the record's patch was: one commit, exported, then undone. + (self.checkout / 'pty.rs').write_text('fn spawn() {}\nfn spawn_observed() {}\n') + (self.checkout / 'observe.rs').write_text('pub struct ExitObserver;\n') + git(self.checkout, 'add', '-A') + git(self.checkout, 'commit', '-q', '-m', 'observe') + self.patched = git(self.checkout, 'rev-parse', 'HEAD^{tree}') + patches = self.root / 'third_party' / 'codex-patches' + patches.mkdir() + self.patch = patches / '0001-observe.patch' + self.patch.write_text(git(self.checkout, 'format-patch', '-1', '--stdout') + '\n') + git(self.checkout, 'reset', '-q', '--hard', self.commit) + self.digest = hashlib.sha256(self.patch.read_bytes()).hexdigest() + self.lock = self.root / 'upstream-lock.md' + self.lock.write_text(lock(self.commit, [('third_party/codex-patches/0001-observe.patch', self.digest)], + self.patched)) + + def tearDown(self): + self.scratch.cleanup() + + def record(self): + return codex_patch.read_record(self.lock, self.root) + + def test_record_reads_the_pin_tables_only(self): + record = self.record() + self.assertEqual(record.commit, self.commit) + self.assertEqual(record.patches, ((self.patch, self.digest),)) + self.assertEqual(record.tree, self.patched) + + def test_apply_reaches_the_recorded_tree_once(self): + with self.assertRaises(codex_patch.PatchStateError): + codex_patch.check(self.record(), self.checkout) + self.assertIn('Applied', codex_patch.apply(self.record(), self.checkout)) + self.assertEqual(codex_patch.working_tree(self.checkout), self.patched) + self.assertIn('already', codex_patch.apply(self.record(), self.checkout)) + codex_patch.check(self.record(), self.checkout) + # The checkout's own index and commit stay those of the pinned release. + self.assertEqual(git(self.checkout, 'rev-parse', 'HEAD'), self.commit) + self.assertEqual(git(self.checkout, 'diff', '--cached', '--name-only'), '') + + def test_ignored_files_do_not_count(self): + codex_patch.apply(self.record(), self.checkout) + (self.checkout / 'target').mkdir() + (self.checkout / 'target' / 'build.o').write_text('object') + codex_patch.check(self.record(), self.checkout) + + def test_revert_restores_the_pinned_files(self): + codex_patch.apply(self.record(), self.checkout) + codex_patch.revert(self.record(), self.checkout) + self.assertEqual(codex_patch.working_tree(self.checkout), self.pinned) + self.assertFalse((self.checkout / 'observe.rs').exists()) + self.assertIn('unpatched', codex_patch.revert(self.record(), self.checkout)) + + def test_other_changes_are_refused(self): + (self.checkout / 'stray.rs').write_text('fn stray() {}\n') + with self.assertRaisesRegex(codex_patch.PatchStateError, 'changes the record does not hold'): + codex_patch.apply(self.record(), self.checkout) + (self.checkout / 'stray.rs').unlink() + codex_patch.apply(self.record(), self.checkout) + (self.checkout / 'observe.rs').write_text('pub struct Other;\n') + with self.assertRaises(codex_patch.PatchStateError): + codex_patch.check(self.record(), self.checkout) + + def test_a_changed_patch_file_is_refused(self): + self.patch.write_text(self.patch.read_text() + '\n') + with self.assertRaisesRegex(codex_patch.PatchStateError, 'SHA-256'): + codex_patch.apply(self.record(), self.checkout) + self.assertEqual(codex_patch.working_tree(self.checkout), self.pinned) + + def test_another_commit_is_refused(self): + (self.checkout / 'pty.rs').write_text('fn spawn() { /* newer */ }\n') + git(self.checkout, 'commit', '-q', '-am', 'newer release') + with self.assertRaisesRegex(codex_patch.PatchStateError, 'not the pinned'): + codex_patch.apply(self.record(), self.checkout) + + def test_without_a_recorded_patch_the_checkout_must_be_clean(self): + self.lock.write_text(lock(self.commit)) + record = self.record() + self.assertEqual((record.patches, record.tree), ((), None)) + codex_patch.apply(record, self.checkout) + codex_patch.check(record, self.checkout) + (self.checkout / 'pty.rs').write_text('fn changed() {}\n') + with self.assertRaises(codex_patch.PatchStateError): + codex_patch.check(record, self.checkout) + + def test_a_patch_without_a_tree_is_refused(self): + self.lock.write_text(lock(self.commit, [('third_party/codex-patches/0001-observe.patch', self.digest)]) + .replace('| Patched tree | `None` |\n', '')) + with self.assertRaisesRegex(codex_patch.PatchStateError, 'patched tree'): + self.record() + + +class RepositoryRecordTests(unittest.TestCase): + def test_the_record_names_the_gitlink_and_existing_patches(self): + record = codex_patch.read_record() + gitlink = subprocess.run(['git', 'ls-tree', 'HEAD', 'third_party/codex'], cwd=ROOT, check=True, + capture_output=True, text=True).stdout.split() + self.assertEqual(gitlink[:3], ['160000', 'commit', record.commit]) + for path, digest in record.patches: + self.assertTrue(path.is_relative_to(ROOT / 'third_party' / 'codex-patches')) + self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), digest) + self.assertEqual(bool(record.patches), record.tree is not None) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_upstream.py b/scripts/tests/test_upstream.py index 78e9e13..48e335b 100644 --- a/scripts/tests/test_upstream.py +++ b/scripts/tests/test_upstream.py @@ -184,6 +184,7 @@ def test_devguard_worker_is_built_before_and_kept_beside_the_default_one(self): def test_report_and_platform_skip(self): with tempfile.TemporaryDirectory() as tmp, \ patch.object(validation, 'fingerprint', return_value={'head': 'a', 'codex_sha': 'pin'}), \ + patch.object(validation, 'apply_codex_patches', return_value=None), \ patch.object(validation, 'capture', return_value='host: aarch64-apple-darwin'), \ patch.object(validation.platform, 'system', return_value='Darwin'): self.assertEqual(validation.run(['linux-isolation'], Path(tmp)), 0) @@ -194,6 +195,7 @@ def test_report_and_platform_skip(self): def test_changed_inputs_fail(self): with tempfile.TemporaryDirectory() as tmp, \ patch.object(validation, 'fingerprint', side_effect=[{'head': 'a', 'codex_sha': 'pin'}, {'head': 'b', 'codex_sha': 'pin'}]), \ + patch.object(validation, 'apply_codex_patches', return_value=None), \ patch.object(validation, 'capture', return_value='host: x86_64-unknown-linux-gnu'): self.assertEqual(validation.run([], Path(tmp)), 1) report = json.loads((Path(tmp) / 'report.json').read_text()) @@ -202,6 +204,7 @@ def test_changed_inputs_fail(self): def test_failure_report(self): with tempfile.TemporaryDirectory() as tmp, \ patch.object(validation, 'fingerprint', return_value={'head': 'a', 'codex_sha': 'pin'}), \ + patch.object(validation, 'apply_codex_patches', return_value=None), \ patch.object(validation, 'capture', return_value='host: x86_64-unknown-linux-gnu'), \ patch.object(validation, 'execute', side_effect=RuntimeError('failure')): self.assertEqual(validation.run(['pin'], Path(tmp)), 1) diff --git a/scripts/validate-upstream.py b/scripts/validate-upstream.py index b4da914..9126d41 100644 --- a/scripts/validate-upstream.py +++ b/scripts/validate-upstream.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""Shared local/CI upstream gates. No source or lockfile updates.""" +"""Shared local/CI upstream gates. No source or lockfile updates, except the recorded Codex +patches, which are applied to a clean checkout of the pinned commit before anything runs.""" import argparse import hashlib import json @@ -10,6 +11,7 @@ import sys import time +import codex_patch from upstream_dependencies import ADAPTERS, DEVGUARD_SOURCE, ROOT @@ -31,9 +33,19 @@ def fingerprint(): 'codex_sha': capture('git', '-C', 'third_party/codex', 'rev-parse', 'HEAD') if (ROOT / 'third_party/codex/.git').exists() else None, 'codex_status': capture('git', '-C', 'third_party/codex', 'status', '--porcelain') if (ROOT / 'third_party/codex/.git').exists() else 'not initialized', 'codex_diff': hashlib.sha256(subprocess.check_output(['git', '-C', 'third_party/codex', 'diff', '--binary', 'HEAD'], cwd=ROOT)).hexdigest() if (ROOT / 'third_party/codex/.git').exists() else None, + # The checkout's files as built, untracked ones included: the recorded patched tree. + 'codex_tree': codex_patch.working_tree(ROOT / 'third_party/codex') if (ROOT / 'third_party/codex/.git').exists() else None, 'files': hashes} +def apply_codex_patches(): + """Apply the patches docs/upstream-lock.md records to the pinned Codex checkout, or confirm + they are applied. Any other state of the checkout fails the run.""" + if not (ROOT / 'third_party/codex/.git').exists(): + return None + return codex_patch.apply(codex_patch.read_record()) + + def cargo(action, area='root', *args): cmd = ['cargo', action] if action != 'fmt': @@ -156,6 +168,7 @@ def run(selected, output): report = {'schema': 1, 'platform': platform.platform(), 'stages': [], 'status': 'failed'} failed = False try: + report['codex_patches'] = apply_codex_patches() before = fingerprint() report['inputs'] = before report['rust'] = capture('rustc', '-vV') diff --git a/third_party/codex-patches/0001-utils-pty-pre-reap-observation.patch b/third_party/codex-patches/0001-utils-pty-pre-reap-observation.patch new file mode 100644 index 0000000..80e8323 --- /dev/null +++ b/third_party/codex-patches/0001-utils-pty-pre-reap-observation.patch @@ -0,0 +1,914 @@ +From 4b5b05d549c439bdfe04156dcaff04bb255e49e9 Mon Sep 17 00:00:00 2001 +From: Claude +Date: Sun, 11 Oct 2026 16:21:54 +0000 +Subject: [PATCH] utils/pty: let an observer see a PTY child's exit before its + reap + +Downstream patch by the CodeSpace project (novelKR/CodeSpace) on +rust-v0.154.0 (6b9826e3aa83b1a5947db50f4332cb9c65f1b340). Not part of +upstream Codex. It ports the generic observer of CodeSpace's W3 +experiment B (a scratch patch on rust-v0.159.0-alpha.11, see DevGuard's +docs/handoff/2026-09-28-w3-decision-packet.md, section 3.2) to both Unix +PTY paths of this release, Linux included, without the experiment's +trace instrumentation. + +Both Unix PTY spawn paths reap the child in their waiter right after +spawn, so a caller cannot look at what the exited child left behind +(for example descendants still in its process group) while the child's +PID and process group ID are still held. + +spawn_pty_process_observed takes an observe::ExitObserver, registered +before the spawn. The child's single waiter then waits for the exit +without consuming it (waitid with WNOWAIT), publishes one +ExitOpportunity that carries no PID, waits until the observer +acknowledges it or the observer's bound passes, and only then reaps +through its original child object. After the reap it reports how the +observation ended (Completed, TimedOut, Failed, Unavailable); an +acknowledgement after the bound returns OpportunityExpired. A dropped +opportunity, or an observer that stopped listening, ends the wait at +once. Termination and Drop in the window still reach the process group, +which the unreaped child keeps, and the child is reaped once. +spawn_pty_process and every other path are unchanged. Other platforms +refuse an observed spawn before creating a process. + +Tests, on both Unix paths: the observer sees the exited child unreaped +and the exit code arrives after the acknowledgement; the bound reaps +without one and refuses a late one; a dropped opportunity or observer +does not hold the child; a descendant that ignores SIGHUP is still in +the root's process group during the window; termination in the window +kills the group and the child is reaped once; a handle dropped in the +window leaves the reap to the waiter, after the acknowledgement; output +written before the exit drains in full; a runtime shutdown in the +window waits only for the bound. + +Co-Authored-By: Claude Opus 5.5 (1M context) +--- + codex-rs/utils/pty/src/lib.rs | 6 + + codex-rs/utils/pty/src/observe.rs | 210 +++++++++++ + codex-rs/utils/pty/src/observe_tests.rs | 456 ++++++++++++++++++++++++ + codex-rs/utils/pty/src/pty.rs | 93 ++++- + 4 files changed, 760 insertions(+), 5 deletions(-) + create mode 100644 codex-rs/utils/pty/src/observe.rs + create mode 100644 codex-rs/utils/pty/src/observe_tests.rs + +diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs +index 1200c114..9e7308f1 100644 +--- a/codex-rs/utils/pty/src/lib.rs ++++ b/codex-rs/utils/pty/src/lib.rs +@@ -1,3 +1,7 @@ ++// Modified by the CodeSpace project (https://github.com/novelKR/CodeSpace) as a downstream patch ++// to rust-v0.154.0: exports bounded pre-reap exit observation (see `observe.rs`). ++ ++pub mod observe; + pub mod pipe; + mod process; + pub mod process_group; +@@ -39,6 +43,8 @@ pub type SpawnedPty = SpawnedProcess; + pub use pty::conpty_supported; + /// Spawn a process attached to a PTY for interactive use. + pub use pty::spawn_process as spawn_pty_process; ++/// Spawn a PTY process whose exit an observer sees before the reap (Unix only). ++pub use pty::spawn_process_observed as spawn_pty_process_observed; + #[cfg(windows)] + pub use win::JobObject; + #[cfg(windows)] +diff --git a/codex-rs/utils/pty/src/observe.rs b/codex-rs/utils/pty/src/observe.rs +new file mode 100644 +index 00000000..3d19cfde +--- /dev/null ++++ b/codex-rs/utils/pty/src/observe.rs +@@ -0,0 +1,210 @@ ++// Added by the CodeSpace project (https://github.com/novelKR/CodeSpace) as a downstream patch ++// to rust-v0.154.0: bounded observation of an exited child before its reap. Not part of ++// upstream Codex. ++ ++//! Bounded observation of an exited child before it is reaped. ++//! ++//! A PTY spawn that registers an [`ExitObserver`] keeps its single waiter. When the child ++//! exits, the waiter sees the exit without consuming its status (`waitid` with `WNOWAIT`), so ++//! the child stays a zombie that still holds its process ID and process group ID. The waiter ++//! then publishes one [`ExitOpportunity`] and waits until the observer acknowledges it or the ++//! bound fixed at registration passes, whichever comes first. Only then does it reap the child, ++//! through the same child object as without an observer, and report how the observation ended. ++//! The opportunity carries no process ID: an observer that needs the process's identity takes ++//! it from its own records. ++//! ++//! Without a registration the waiter behaves exactly as before. ++ ++use std::fmt; ++use std::sync::Arc; ++use std::sync::Condvar; ++use std::sync::Mutex; ++use std::time::Duration; ++use std::time::Instant; ++ ++use tokio::sync::oneshot; ++ ++/// How an observation ended. Reported once, after the child has been reaped. ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum ObservationOutcome { ++ /// The observer acknowledged the opportunity before the bound. ++ Completed, ++ /// The bound passed first; the child was reaped without an acknowledgement. ++ TimedOut, ++ /// The observer could not take the opportunity, or dropped it without acknowledging. ++ Failed, ++ /// The exit could not be seen without reaping, so no opportunity was published. ++ Unavailable, ++} ++ ++/// Registered with an observed spawn, before the child exists. ++#[derive(Debug)] ++pub struct ExitObserver { ++ /// Gets the one opportunity once the child has exited, before it is reaped. Dropped unsent ++ /// when no opportunity can be published. ++ pub opportunity: oneshot::Sender, ++ /// How long the waiter keeps the exited child unreaped for the observer. ++ pub bound: Duration, ++ /// Gets how the observation ended, once the child has been reaped. ++ pub outcome: oneshot::Sender, ++} ++ ++/// One chance to observe an exited child before it is reaped. Dropping it without ++/// acknowledging ends the observation as [`ObservationOutcome::Failed`]. ++pub struct ExitOpportunity { ++ window: Arc, ++} ++ ++impl fmt::Debug for ExitOpportunity { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.debug_struct("ExitOpportunity").finish() ++ } ++} ++ ++impl ExitOpportunity { ++ /// Let the waiter reap the child now. Refused once the bound has passed. ++ pub fn acknowledge(self) -> Result<(), OpportunityExpired> { ++ self.window.close(WindowState::Acknowledged) ++ } ++} ++ ++impl Drop for ExitOpportunity { ++ fn drop(&mut self) { ++ let _ = self.window.close(WindowState::Abandoned); ++ } ++} ++ ++/// Returned when an opportunity is acknowledged after its bound had passed. ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub struct OpportunityExpired; ++ ++impl fmt::Display for OpportunityExpired { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.write_str("the exit opportunity's bound had already passed") ++ } ++} ++ ++impl std::error::Error for OpportunityExpired {} ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++enum WindowState { ++ Open, ++ Acknowledged, ++ Abandoned, ++ Expired, ++} ++ ++struct Window { ++ state: Mutex, ++ changed: Condvar, ++} ++ ++impl Window { ++ /// Close an open window as `state`; refused when it was already closed. ++ fn close(&self, state: WindowState) -> Result<(), OpportunityExpired> { ++ let Ok(mut current) = self.state.lock() else { ++ return Err(OpportunityExpired); ++ }; ++ if *current != WindowState::Open { ++ return Err(OpportunityExpired); ++ } ++ *current = state; ++ self.changed.notify_all(); ++ Ok(()) ++ } ++ ++ /// Wait until the window is closed or `deadline` passes, which expires it. ++ fn wait(&self, deadline: Instant) -> ObservationOutcome { ++ let Ok(mut state) = self.state.lock() else { ++ return ObservationOutcome::Failed; ++ }; ++ loop { ++ match *state { ++ WindowState::Acknowledged => return ObservationOutcome::Completed, ++ WindowState::Abandoned => return ObservationOutcome::Failed, ++ WindowState::Expired => return ObservationOutcome::TimedOut, ++ WindowState::Open => { ++ let now = Instant::now(); ++ if now >= deadline { ++ *state = WindowState::Expired; ++ return ObservationOutcome::TimedOut; ++ } ++ state = match self.changed.wait_timeout(state, deadline - now) { ++ Ok((state, _)) => state, ++ Err(_) => return ObservationOutcome::Failed, ++ }; ++ } ++ } ++ } ++ } ++} ++ ++/// The observed waiter body: wait until the child `pid` has exited without reaping it, publish ++/// the opportunity, and wait for the acknowledgement or the bound. Whatever happens, then reap ++/// the child once through `reap`, as without an observer, and report the outcome. ++pub(crate) fn observed_wait( ++ pid: Option, ++ observer: ExitObserver, ++ reap: impl FnOnce() -> T, ++) -> T { ++ let ExitObserver { ++ opportunity: publish, ++ bound, ++ outcome: report, ++ } = observer; ++ let outcome = match pid.map(wait_exited_unreaped) { ++ Some(Ok(())) => { ++ let window = Arc::new(Window { ++ state: Mutex::new(WindowState::Open), ++ changed: Condvar::new(), ++ }); ++ let opportunity = ExitOpportunity { ++ window: Arc::clone(&window), ++ }; ++ // A refused opportunity comes back and is dropped, which abandons the window. ++ match publish.send(opportunity) { ++ Ok(()) => window.wait(Instant::now() + bound), ++ Err(_) => ObservationOutcome::Failed, ++ } ++ } ++ Some(Err(_)) | None => ObservationOutcome::Unavailable, ++ }; ++ let status = reap(); ++ let _ = report.send(outcome); ++ status ++} ++ ++/// Block until the child `pid` has exited, leaving it waitable for its own waiter. ++#[cfg(unix)] ++fn wait_exited_unreaped(pid: u32) -> std::io::Result<()> { ++ loop { ++ // SAFETY: an all-zero `siginfo_t` is a valid value for `waitid` to fill. ++ let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; ++ // SAFETY: `info` is valid for writes; `WNOWAIT` leaves the child's status unconsumed. ++ let result = unsafe { ++ libc::waitid( ++ libc::P_PID, ++ pid as libc::id_t, ++ &mut info, ++ libc::WEXITED | libc::WNOWAIT, ++ ) ++ }; ++ if result == 0 { ++ return Ok(()); ++ } ++ let err = std::io::Error::last_os_error(); ++ if err.kind() != std::io::ErrorKind::Interrupted { ++ return Err(err); ++ } ++ } ++} ++ ++/// Exits cannot be seen without reaping here. ++#[cfg(not(unix))] ++fn wait_exited_unreaped(_pid: u32) -> std::io::Result<()> { ++ Err(std::io::ErrorKind::Unsupported.into()) ++} ++ ++#[cfg(all(test, unix))] ++#[path = "observe_tests.rs"] ++mod tests; +diff --git a/codex-rs/utils/pty/src/observe_tests.rs b/codex-rs/utils/pty/src/observe_tests.rs +new file mode 100644 +index 00000000..d9b6a54b +--- /dev/null ++++ b/codex-rs/utils/pty/src/observe_tests.rs +@@ -0,0 +1,456 @@ ++// Added by the CodeSpace project (https://github.com/novelKR/CodeSpace) as a downstream patch ++// to rust-v0.154.0. Not part of upstream Codex. ++ ++use std::collections::HashMap; ++use std::fs::File; ++use std::os::fd::AsRawFd; ++use std::path::Path; ++use std::time::Duration; ++use std::time::Instant; ++ ++use pretty_assertions::assert_eq; ++use tokio::sync::mpsc; ++use tokio::sync::oneshot; ++use tokio::time::timeout; ++ ++use super::ExitObserver; ++use super::ExitOpportunity; ++use super::ObservationOutcome; ++use super::OpportunityExpired; ++use crate::SpawnedProcess; ++use crate::TerminalSize; ++use crate::spawn_pty_process_observed; ++ ++const PATIENCE: Duration = Duration::from_secs(10); ++ ++/// The two Unix spawn paths: without inherited descriptors, and preserving one. ++#[derive(Clone, Copy, Debug)] ++enum SpawnPath { ++ Portable, ++ PreservingFds, ++} ++ ++const PATHS: [SpawnPath; 2] = [SpawnPath::Portable, SpawnPath::PreservingFds]; ++ ++/// The observer's ends of an observed spawn. ++struct Observed { ++ opportunity: oneshot::Receiver, ++ outcome: oneshot::Receiver, ++} ++ ++async fn spawn_observed( ++ script: &str, ++ path: SpawnPath, ++ bound: Duration, ++) -> anyhow::Result<(SpawnedProcess, Observed)> { ++ let env: HashMap = std::env::vars().collect(); ++ let (opportunity_tx, opportunity) = oneshot::channel(); ++ let (outcome_tx, outcome) = oneshot::channel(); ++ let observer = ExitObserver { ++ opportunity: opportunity_tx, ++ bound, ++ outcome: outcome_tx, ++ }; ++ let devnull = File::open("/dev/null")?; ++ let inherited_fds = match path { ++ SpawnPath::Portable => Vec::new(), ++ SpawnPath::PreservingFds => vec![devnull.as_raw_fd()], ++ }; ++ let spawned = spawn_pty_process_observed( ++ "/bin/sh", ++ &["-c".to_string(), script.to_string()], ++ Path::new("."), ++ &env, ++ &None, ++ TerminalSize::default(), ++ &inherited_fds, ++ observer, ++ ) ++ .await?; ++ Ok(( ++ spawned, ++ Observed { ++ opportunity, ++ outcome, ++ }, ++ )) ++} ++ ++/// Read the child's output until it has printed each of `markers` followed by a number and a ++/// line end, and return those numbers in order. ++async fn marker_pids( ++ output: &mut mpsc::Receiver>, ++ markers: [&str; N], ++) -> anyhow::Result<[i32; N]> { ++ let mut collected = String::new(); ++ loop { ++ let found = markers.map(|marker| { ++ let start = collected.find(marker)?; ++ let rest = &collected[start + marker.len()..]; ++ let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); ++ (!digits.is_empty() && rest.len() > digits.len()) ++ .then(|| digits.parse::().ok()) ++ .flatten() ++ }); ++ if found.iter().all(Option::is_some) { ++ return Ok(found.map(Option::unwrap_or_default)); ++ } ++ let chunk = timeout(PATIENCE, output.recv()) ++ .await? ++ .ok_or_else(|| anyhow::anyhow!("output ended before {markers:?}: {collected:?}"))?; ++ collected.push_str(&String::from_utf8_lossy(&chunk)); ++ } ++} ++ ++/// Whether this process's child `pid` has exited and is still unreaped. ++fn exited_unreaped(pid: i32) -> bool { ++ // SAFETY: an all-zero `siginfo_t` is a valid value for `waitid` to fill. ++ let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; ++ // SAFETY: `info` is valid for writes; `WNOWAIT` leaves the child's status unconsumed. ++ let result = unsafe { ++ libc::waitid( ++ libc::P_PID, ++ pid as libc::id_t, ++ &mut info, ++ libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, ++ ) ++ }; ++ // SAFETY: `waitid` filled `info`, or left the zero it was given. ++ result == 0 && unsafe { info.si_pid() } == pid ++} ++ ++/// Whether `pid` is no longer this process's child to wait for: it has been reaped. ++fn reaped(pid: i32) -> bool { ++ // SAFETY: an all-zero `siginfo_t` is a valid value for `waitid` to fill. ++ let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; ++ // SAFETY: `info` is valid for writes; `WNOWAIT` leaves any status unconsumed. ++ let result = unsafe { ++ libc::waitid( ++ libc::P_PID, ++ pid as libc::id_t, ++ &mut info, ++ libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, ++ ) ++ }; ++ result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ECHILD) ++} ++ ++fn alive(pid: i32) -> bool { ++ // SAFETY: signal 0 only checks that `pid` can be signalled. ++ unsafe { libc::kill(pid, 0) == 0 } ++} ++ ++async fn ended(pid: i32) -> bool { ++ let deadline = Instant::now() + PATIENCE; ++ while alive(pid) { ++ if Instant::now() >= deadline { ++ return false; ++ } ++ tokio::time::sleep(Duration::from_millis(20)).await; ++ } ++ true ++} ++ ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn the_observer_sees_the_exit_before_the_reap() -> anyhow::Result<()> { ++ for path in PATHS { ++ let (spawned, observed) = spawn_observed("echo pid:$$; exit 3", path, PATIENCE).await?; ++ let SpawnedProcess { ++ session, ++ mut stdout_rx, ++ exit_rx, ++ .. ++ } = spawned; ++ let Observed { ++ opportunity, ++ outcome, ++ } = observed; ++ let [pid] = marker_pids(&mut stdout_rx, ["pid:"]).await?; ++ ++ let opportunity = timeout(PATIENCE, opportunity).await??; ++ assert!(exited_unreaped(pid), "{path:?}"); ++ assert!(!session.has_exited(), "{path:?}"); ++ ++ opportunity.acknowledge()?; ++ assert_eq!(timeout(PATIENCE, exit_rx).await??, 3, "{path:?}"); ++ assert_eq!( ++ timeout(PATIENCE, outcome).await??, ++ ObservationOutcome::Completed, ++ "{path:?}" ++ ); ++ assert!(reaped(pid), "{path:?}"); ++ } ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn the_bound_reaps_without_an_acknowledgement() -> anyhow::Result<()> { ++ let bound = Duration::from_millis(300); ++ for path in PATHS { ++ let (spawned, observed) = spawn_observed("exit 0", path, bound).await?; ++ let Observed { ++ opportunity, ++ outcome, ++ } = observed; ++ let opportunity = timeout(PATIENCE, opportunity).await??; ++ let published = Instant::now(); ++ ++ assert_eq!(timeout(PATIENCE, spawned.exit_rx).await??, 0, "{path:?}"); ++ assert!( ++ published.elapsed() >= bound - Duration::from_millis(50), ++ "{path:?}" ++ ); ++ assert_eq!( ++ timeout(PATIENCE, outcome).await??, ++ ObservationOutcome::TimedOut, ++ "{path:?}" ++ ); ++ // An acknowledgement after the bound is refused. ++ assert_eq!( ++ opportunity.acknowledge(), ++ Err(OpportunityExpired), ++ "{path:?}" ++ ); ++ } ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn a_dropped_opportunity_reaps_at_once() -> anyhow::Result<()> { ++ for path in PATHS { ++ let (spawned, observed) = spawn_observed("exit 0", path, PATIENCE).await?; ++ let Observed { ++ opportunity, ++ outcome, ++ } = observed; ++ let started = Instant::now(); ++ drop(timeout(PATIENCE, opportunity).await??); ++ ++ assert_eq!(timeout(PATIENCE, spawned.exit_rx).await??, 0, "{path:?}"); ++ assert!(started.elapsed() < PATIENCE / 2, "{path:?}"); ++ assert_eq!( ++ timeout(PATIENCE, outcome).await??, ++ ObservationOutcome::Failed, ++ "{path:?}" ++ ); ++ } ++ Ok(()) ++} ++ ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn a_dropped_observer_does_not_keep_the_child_unreaped() -> anyhow::Result<()> { ++ for path in PATHS { ++ let (spawned, observed) = spawn_observed("sleep 0.2; exit 0", path, PATIENCE).await?; ++ let started = Instant::now(); ++ drop(observed); ++ ++ assert_eq!(timeout(PATIENCE, spawned.exit_rx).await??, 0, "{path:?}"); ++ assert!(started.elapsed() < PATIENCE / 2, "{path:?}"); ++ } ++ Ok(()) ++} ++ ++/// A descendant that ignores the terminal's hangup outlives the root. While the exited root is ++/// unreaped, the descendant's process group is still the root's ID, so an observer can still ++/// attribute it. ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn a_surviving_descendant_still_has_the_roots_group_in_the_window() -> anyhow::Result<()> { ++ for path in PATHS { ++ let (spawned, observed) = spawn_observed( ++ "trap '' HUP; sleep 30 & echo bg:$!; echo pid:$$; exit 0", ++ path, ++ PATIENCE, ++ ) ++ .await?; ++ let SpawnedProcess { ++ mut stdout_rx, ++ exit_rx, ++ .. ++ } = spawned; ++ let Observed { ++ opportunity, ++ outcome, ++ } = observed; ++ let [descendant, pid] = marker_pids(&mut stdout_rx, ["bg:", "pid:"]).await?; ++ ++ let opportunity = timeout(PATIENCE, opportunity).await??; ++ assert!(exited_unreaped(pid), "{path:?}"); ++ assert!(alive(descendant), "{path:?}"); ++ // SAFETY: getpgid only reads the process group of `descendant`. ++ assert_eq!(unsafe { libc::getpgid(descendant) }, pid, "{path:?}"); ++ ++ opportunity.acknowledge()?; ++ assert_eq!(timeout(PATIENCE, exit_rx).await??, 0, "{path:?}"); ++ assert_eq!( ++ timeout(PATIENCE, outcome).await??, ++ ObservationOutcome::Completed, ++ "{path:?}" ++ ); ++ // The reap does not end the descendant. ++ assert!(alive(descendant), "{path:?}"); ++ // SAFETY: SIGKILL to the test's own descendant. ++ unsafe { libc::kill(descendant, libc::SIGKILL) }; ++ assert!(ended(descendant).await, "{path:?}"); ++ } ++ Ok(()) ++} ++ ++/// Termination while the exited root is unreaped reaches the whole group, and the child is ++/// still reaped once, by its waiter. ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn terminate_in_the_window_kills_the_group_and_reaps_once() -> anyhow::Result<()> { ++ for path in PATHS { ++ let (spawned, observed) = spawn_observed( ++ "trap '' HUP; sleep 30 & echo bg:$!; echo pid:$$; exit 0", ++ path, ++ PATIENCE, ++ ) ++ .await?; ++ let SpawnedProcess { ++ session, ++ mut stdout_rx, ++ exit_rx, ++ .. ++ } = spawned; ++ let Observed { ++ opportunity, ++ outcome, ++ } = observed; ++ let [descendant, pid] = marker_pids(&mut stdout_rx, ["bg:", "pid:"]).await?; ++ ++ let opportunity = timeout(PATIENCE, opportunity).await??; ++ session.terminate(); ++ assert!(ended(descendant).await, "{path:?}"); ++ assert!(exited_unreaped(pid), "{path:?}"); ++ ++ opportunity.acknowledge()?; ++ assert_eq!(timeout(PATIENCE, exit_rx).await??, 0, "{path:?}"); ++ assert_eq!( ++ timeout(PATIENCE, outcome).await??, ++ ObservationOutcome::Completed, ++ "{path:?}" ++ ); ++ assert!(reaped(pid), "{path:?}"); ++ } ++ Ok(()) ++} ++ ++/// Dropping the process handle in the window leaves the waiter to it: the child is reaped once, ++/// after the acknowledgement. ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn a_handle_dropped_in_the_window_still_reaps_after_the_acknowledgement() -> anyhow::Result<()> ++{ ++ for path in PATHS { ++ let (spawned, observed) = spawn_observed("echo pid:$$; exit 0", path, PATIENCE).await?; ++ let SpawnedProcess { ++ session, ++ mut stdout_rx, ++ exit_rx, ++ .. ++ } = spawned; ++ let Observed { ++ opportunity, ++ outcome, ++ } = observed; ++ let [pid] = marker_pids(&mut stdout_rx, ["pid:"]).await?; ++ ++ let opportunity = timeout(PATIENCE, opportunity).await??; ++ drop(session); ++ tokio::time::sleep(Duration::from_millis(200)).await; ++ assert!(exited_unreaped(pid), "{path:?}"); ++ ++ opportunity.acknowledge()?; ++ assert_eq!(timeout(PATIENCE, exit_rx).await??, 0, "{path:?}"); ++ assert_eq!( ++ timeout(PATIENCE, outcome).await??, ++ ObservationOutcome::Completed, ++ "{path:?}" ++ ); ++ assert!(reaped(pid), "{path:?}"); ++ } ++ Ok(()) ++} ++ ++/// Output the child wrote before it exited still drains while the child is unreaped. ++#[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++async fn the_output_drains_while_the_child_is_unreaped() -> anyhow::Result<()> { ++ const BYTES: usize = 262_144; ++ for path in PATHS { ++ let (spawned, observed) = spawn_observed( ++ &format!("head -c {BYTES} /dev/zero | tr '\\0' x"), ++ path, ++ PATIENCE, ++ ) ++ .await?; ++ let SpawnedProcess { ++ mut stdout_rx, ++ exit_rx, ++ .. ++ } = spawned; ++ let Observed { ++ opportunity, ++ outcome, ++ } = observed; ++ let drained = tokio::spawn(async move { ++ let mut count = 0; ++ while count < BYTES ++ && let Some(chunk) = stdout_rx.recv().await ++ { ++ count += chunk.iter().filter(|byte| **byte == b'x').count(); ++ } ++ count ++ }); ++ ++ let opportunity = timeout(PATIENCE, opportunity).await??; ++ opportunity.acknowledge()?; ++ assert_eq!(timeout(PATIENCE, exit_rx).await??, 0, "{path:?}"); ++ assert_eq!( ++ timeout(PATIENCE, outcome).await??, ++ ObservationOutcome::Completed, ++ "{path:?}" ++ ); ++ assert_eq!(timeout(PATIENCE, drained).await??, BYTES, "{path:?}"); ++ } ++ Ok(()) ++} ++ ++/// An observer that never acknowledges holds a shutdown of the spawning runtime for the bound, ++/// and no longer. ++#[test] ++fn a_runtime_shutdown_in_the_window_waits_only_for_the_bound() -> anyhow::Result<()> { ++ let bound = Duration::from_millis(500); ++ for path in PATHS { ++ let runtime = tokio::runtime::Builder::new_multi_thread() ++ .worker_threads(1) ++ .enable_all() ++ .build()?; ++ let (spawned, observed) = runtime.block_on(spawn_observed("exit 0", path, bound))?; ++ let Observed { ++ opportunity, ++ outcome, ++ } = observed; ++ let opportunity = runtime.block_on(async { timeout(PATIENCE, opportunity).await })??; ++ ++ let started = Instant::now(); ++ drop(spawned); ++ runtime.shutdown_timeout(PATIENCE); ++ let held = started.elapsed(); ++ assert!( ++ held >= bound - Duration::from_millis(100), ++ "{path:?} {held:?}" ++ ); ++ assert!(held < PATIENCE / 2, "{path:?} {held:?}"); ++ ++ assert_eq!( ++ outcome.blocking_recv()?, ++ ObservationOutcome::TimedOut, ++ "{path:?}" ++ ); ++ assert_eq!( ++ opportunity.acknowledge(), ++ Err(OpportunityExpired), ++ "{path:?}" ++ ); ++ } ++ Ok(()) ++} +diff --git a/codex-rs/utils/pty/src/pty.rs b/codex-rs/utils/pty/src/pty.rs +index 1b848b33..d589366f 100644 +--- a/codex-rs/utils/pty/src/pty.rs ++++ b/codex-rs/utils/pty/src/pty.rs +@@ -1,3 +1,7 @@ ++// Modified by the CodeSpace project (https://github.com/novelKR/CodeSpace) as a downstream patch ++// to rust-v0.154.0: `spawn_process_observed` and both Unix waiters' bounded pre-reap exit ++// observation (see `observe.rs`). ++ + use std::collections::HashMap; + #[cfg(unix)] + use std::fs::File; +@@ -29,6 +33,8 @@ use tokio::sync::mpsc; + use tokio::sync::oneshot; + use tokio::task::JoinHandle; + ++use crate::observe::ExitObserver; ++use crate::observe::observed_wait; + use crate::process::ChildTerminator; + use crate::process::ProcessHandle; + use crate::process::ProcessSignal; +@@ -133,6 +139,61 @@ pub async fn spawn_process( + arg0: &Option, + size: TerminalSize, + inherited_fds: &[i32], ++) -> Result { ++ spawn_process_with_observer( ++ program, ++ args, ++ cwd, ++ env, ++ arg0, ++ size, ++ inherited_fds, ++ /*observer*/ None, ++ ) ++ .await ++} ++ ++/// As [`spawn_process`], with `observer` registered before the spawn: once the child has exited, ++/// its waiter publishes one bounded opportunity while the child is still unreaped, then reaps it ++/// as usual (see [`crate::observe`]). Unix only; elsewhere the spawn is refused before any ++/// process is created. ++#[allow(clippy::too_many_arguments)] ++pub async fn spawn_process_observed( ++ program: &str, ++ args: &[String], ++ cwd: &Path, ++ env: &HashMap, ++ arg0: &Option, ++ size: TerminalSize, ++ inherited_fds: &[i32], ++ observer: ExitObserver, ++) -> Result { ++ if cfg!(not(unix)) { ++ anyhow::bail!("exit observation is not supported on this platform"); ++ } ++ spawn_process_with_observer( ++ program, ++ args, ++ cwd, ++ env, ++ arg0, ++ size, ++ inherited_fds, ++ Some(observer), ++ ) ++ .await ++} ++ ++#[allow(clippy::too_many_arguments)] ++async fn spawn_process_with_observer( ++ program: &str, ++ args: &[String], ++ cwd: &Path, ++ env: &HashMap, ++ arg0: &Option, ++ size: TerminalSize, ++ inherited_fds: &[i32], ++ observer: Option, + ) -> Result { + if program.is_empty() { + anyhow::bail!("missing program for PTY spawn"); +@@ -143,11 +204,20 @@ pub async fn spawn_process( + + #[cfg(unix)] + if !inherited_fds.is_empty() { +- return spawn_process_preserving_fds(program, args, cwd, env, arg0, size, inherited_fds) +- .await; ++ return spawn_process_preserving_fds( ++ program, ++ args, ++ cwd, ++ env, ++ arg0, ++ size, ++ inherited_fds, ++ observer, ++ ) ++ .await; + } + +- spawn_process_portable(program, args, cwd, env, arg0, size).await ++ spawn_process_portable(program, args, cwd, env, arg0, size, observer).await + } + + async fn spawn_process_portable( +@@ -157,6 +227,7 @@ async fn spawn_process_portable( + env: &HashMap, + arg0: &Option, + size: TerminalSize, ++ observer: Option, + ) -> Result { + let pty_system = platform_native_pty_system(); + let pair = pty_system.openpty(size.into())?; +@@ -242,7 +313,12 @@ async fn spawn_process_portable( + let exit_code = Arc::new(StdMutex::new(None)); + let wait_exit_code = Arc::clone(&exit_code); + let wait_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || { +- let code = match child.wait() { ++ let status = match observer { ++ // The same single waiter, which gives the observer its opportunity before it reaps. ++ Some(observer) => observed_wait(child.process_id(), observer, || child.wait()), ++ None => child.wait(), ++ }; ++ let code = match status { + Ok(status) => status.exit_code() as i32, + Err(_) => -1, + }; +@@ -288,6 +364,7 @@ async fn spawn_process_portable( + } + + #[cfg(unix)] ++#[allow(clippy::too_many_arguments)] + async fn spawn_process_preserving_fds( + program: &str, + args: &[String], +@@ -296,6 +373,7 @@ async fn spawn_process_preserving_fds( + arg0: &Option, + size: TerminalSize, + inherited_fds: &[RawFd], ++ observer: Option, + ) -> Result { + let (master, slave) = open_unix_pty(size)?; + let io = crate::unix_io::PtyIo::new(master.as_raw_fd())?; +@@ -376,7 +454,12 @@ async fn spawn_process_preserving_fds( + let exit_code = Arc::new(StdMutex::new(None)); + let wait_exit_code = Arc::clone(&exit_code); + let wait_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || { +- let code = match child.wait() { ++ let status = match observer { ++ // The same single waiter, which gives the observer its opportunity before it reaps. ++ Some(observer) => observed_wait(Some(child.id()), observer, || child.wait()), ++ None => child.wait(), ++ }; ++ let code = match status { + Ok(status) => exit_code_from_status(status), + Err(_) => -1, + }; +-- +2.43.0 +