From 502d67e979d85964a7fbbe6ce8f05ea3eaa50d1d Mon Sep 17 00:00:00 2001 From: Seongjae Date: Sun, 20 Sep 2026 16:06:25 +0900 Subject: [PATCH] P1: expose process lifecycle via process_status and output-loss fields exec_command stays spawn identity; agents need a handle API to judge exit and truncated logs without treating EOF as success. Co-authored-by: Cursor --- README.ko.md | 6 +- README.md | 6 +- crates/codex-runtime/tests/runtime_binary.rs | 2 +- crates/domain/src/lib.rs | 11 +- crates/domain/src/process.rs | 161 ++++++++ crates/domain/src/tools.rs | 2 + crates/runner/src/api.rs | 22 +- crates/runner/src/lib.rs | 26 +- crates/runner/src/process.rs | 376 +++++++++++++++++-- crates/runner/src/uds.rs | 18 +- crates/runner/src/wire.rs | 37 +- crates/runner/tests/uds_runner.rs | 51 +++ crates/server/src/mcp.rs | 69 +++- crates/server/tests/process.rs | 43 ++- crates/server/tests/protocol_compat.rs | 10 +- docs/agent-integration.md | 19 +- docs/codex-reuse.md | 2 +- docs/error-codes.md | 2 +- docs/execution-substrate.md | 6 +- docs/ko/agent-integration.md | 19 +- docs/ko/codex-reuse.md | 2 +- docs/ko/error-codes.md | 2 +- docs/ko/execution-substrate.md | 6 +- docs/ko/operations.md | 2 +- docs/ko/runner-isolation.md | 4 +- docs/operations.md | 2 +- docs/runner-isolation.md | 4 +- docs/translations.json | 28 +- 28 files changed, 845 insertions(+), 93 deletions(-) diff --git a/README.ko.md b/README.ko.md index 1490e83..fc43ef4 100644 --- a/README.ko.md +++ b/README.ko.md @@ -19,7 +19,7 @@ CodeSpace는 외부 코딩 에이전트가 작업 공간의 파일을 읽고 수 | --- | --- | | 실행 환경과 파일 확인 | `workspace_info`, `find`, `read` | | 패치 적용과 기록된 상태 조회 | `apply_patch`, `operation_status` | -| 프로세스 실행과 제어 | `exec_command`, `read_process`, `write_stdin`, `terminate_process` | +| 프로세스 실행과 제어 | `exec_command`, `read_process`, `process_status`, `write_stdin`, `terminate_process` | | 작업과 사용자 추가 지시 관리 | `work_open`, `steer_status`, `steer_claim_next`, `steer_complete`, `work_finish` | 두 전송 방식에서 같은 도구를 사용할 수 있습니다. HTTP `/inbox` API는 사용자용 클라이언트가 지시 초안을 관리하는 JSON API입니다. 브라우저에서 사용하는 받은 편지함 화면은 제공하지 않습니다. @@ -35,8 +35,8 @@ CodeSpace는 외부 코딩 에이전트가 작업 공간의 파일을 읽고 수 에이전트를 연동할 때는 다음 제약을 반영해야 합니다. -- 프로세스 결과에는 출력과 EOF가 있지만 종료 코드는 없습니다. EOF만으로 테스트 성공을 판단할 수 없습니다. -- 프로세스 출력의 보관 크기가 제한되어 있으며, 유실된 출력을 알리는 별도 필드는 없습니다. +- 프로세스 종료는 `process_status`로 판정하세요. `read_process`의 EOF는 성공이 아닙니다. +- 프로세스 출력의 보관 크기가 제한되어 있습니다. `output_lost`가 참이면 보관 창이 전체 로그가 아닙니다. - 명령이 실행 중이면 같은 작업 공간에서 다른 명령이나 패치를 실행할 수 없습니다. 개발 서버를 켜 둔 채 같은 작업 공간을 수정하는 흐름에는 제약이 있습니다. - 서버를 재시작하면 프로세스 핸들이 사라집니다. 패치 작업 기록은 데이터베이스 경로를 설정한 경우에만 유지됩니다. - 컨테이너 실행과 완전한 OAuth 서버는 구현되어 있지 않습니다. 실제 ChatGPT 계정 연결도 아직 검증되지 않았습니다. diff --git a/README.md b/README.md index b1d904d..b2b7ff6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Then use the [Agent Loop integration guide](docs/agent-integration.md) for the r | --- | --- | | Inspect the environment and files | `workspace_info`, `find`, `read` | | Apply a patch and retrieve its recorded state | `apply_patch`, `operation_status` | -| Run and control a process | `exec_command`, `read_process`, `write_stdin`, `terminate_process` | +| Run and control a process | `exec_command`, `read_process`, `process_status`, `write_stdin`, `terminate_process` | | Track a logical job and queued user instructions | `work_open`, `steer_status`, `steer_claim_next`, `steer_complete`, `work_finish` | Both transports expose the same tools. The HTTP `/inbox` API lets a user-facing client manage instruction drafts; it is a JSON API, not a browser inbox application. @@ -33,8 +33,8 @@ CodeSpace reuses pinned Codex execution libraries for patches, terminal sessions For agent integrations, account for these limits: -- Process results expose output and EOF, but no exit code. EOF alone cannot establish that a test passed. -- Process output is bounded; dropped output has no explicit flag in the MCP result. +- Judge process exit with `process_status`. EOF from `read_process` is not success. +- Process output is bounded. `output_lost` means the retained window is not the complete log. - A live command blocks another command or patch in the same workspace. A development server cannot remain running while that workspace is patched. - Process handles do not survive server restart. Patch-operation records persist only when a database path is configured. - Container dispatch and a full OAuth server are not implemented. A live ChatGPT account connection remains unverified. diff --git a/crates/codex-runtime/tests/runtime_binary.rs b/crates/codex-runtime/tests/runtime_binary.rs index 1ea3c4e..be38151 100644 --- a/crates/codex-runtime/tests/runtime_binary.rs +++ b/crates/codex-runtime/tests/runtime_binary.rs @@ -134,7 +134,7 @@ async fn hello_on_live_socket() { match parsed.result { Some(RunnerOpResult::Hello { protocol }) => { assert_eq!(protocol, WIRE_PROTOCOL); - assert_eq!(protocol, 3); + assert_eq!(protocol, 4); } other => panic!("unexpected hello {other:?}"), } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 6fabfa2..5fd5ef3 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -36,17 +36,18 @@ pub use patch::{ OperationEventName, OperationKind, OperationStatusParams, OperationStatusResult, PatchStatus, }; pub use process::{ - ExecCommandParams, ExecCommandResult, ExecDispatchStatus, ReadProcessParams, ReadProcessResult, + ExecCommandParams, ExecCommandResult, ExecDispatchStatus, ProcessState, ProcessStatusParams, + ProcessStatusResult, ProcessTermination, ReadProcessParams, ReadProcessResult, TerminateProcessParams, WriteStdinParams, }; pub use profile::Profile; pub use tools::{ LIVE_TOOLS, SERVER_NAME, SERVER_VERSION, TOOL_APPLY_PATCH, TOOL_APPROVAL_CREATE, TOOL_APPROVAL_RESOLVE, TOOL_EXEC_COMMAND, TOOL_FIND, TOOL_OPERATION_RESUME, - TOOL_OPERATION_STATUS, TOOL_READ, TOOL_READ_PROCESS, TOOL_STEER_CLAIM_NEXT, - TOOL_STEER_COMPLETE, TOOL_STEER_STATUS, TOOL_TERMINATE_PROCESS, TOOL_WORKSPACE_INFO, - TOOL_WORK_FINISH, TOOL_WORK_OPEN, TOOL_WRITE_STDIN, TRANSPORT_STDIO, TRANSPORT_STREAMABLE_HTTP, - W03_EXPOSED_TOOLS, + TOOL_OPERATION_STATUS, TOOL_PROCESS_STATUS, TOOL_READ, TOOL_READ_PROCESS, + TOOL_STEER_CLAIM_NEXT, TOOL_STEER_COMPLETE, TOOL_STEER_STATUS, TOOL_TERMINATE_PROCESS, + TOOL_WORKSPACE_INFO, TOOL_WORK_FINISH, TOOL_WORK_OPEN, TOOL_WRITE_STDIN, TRANSPORT_STDIO, + TRANSPORT_STREAMABLE_HTTP, W03_EXPOSED_TOOLS, }; pub use work::{ ClaimedIntent, CoordinationHint, SteerClaimNextResult, SteerCompleteParams, SteerOutcome, diff --git a/crates/domain/src/process.rs b/crates/domain/src/process.rs index dc6b9b0..3f9d972 100644 --- a/crates/domain/src/process.rs +++ b/crates/domain/src/process.rs @@ -54,10 +54,63 @@ pub struct ReadProcessResult { pub cursor: u64, pub chunk: String, pub eof: bool, + /// True when the retained window does not start at byte 0. + pub output_lost: bool, + /// First retained process-output offset (`dropped` in the runner). + pub retained_from: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub coordination: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProcessState { + Running, + Exited, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProcessTermination { + Exited, + Timeout, + Terminated, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProcessStatusParams { + pub process_id: ProcessId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProcessStatusResult { + pub process_id: ProcessId, + pub state: ProcessState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub termination: Option, + pub output_total: u64, + pub output_retained_from: u64, + pub eof: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coordination: Option, +} + +impl ProcessStatusResult { + pub fn invariants_hold(&self) -> bool { + match self.state { + ProcessState::Running => self.exit_code.is_none() && self.termination.is_none(), + ProcessState::Exited => match self.termination { + Some(ProcessTermination::Exited) => true, + Some(_) => self.exit_code.is_none(), + None => false, + }, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct TerminateProcessParams { pub process_id: ProcessId, @@ -141,4 +194,112 @@ mod tests { "result schema must include dispatch_status: {schema}" ); } + + #[test] + fn running_status_omits_exit_code() { + let json = serde_json::to_value(ProcessStatusResult { + process_id: ProcessId("proc-1".into()), + state: ProcessState::Running, + exit_code: None, + termination: None, + output_total: 0, + output_retained_from: 0, + eof: false, + coordination: None, + }) + .unwrap(); + assert_eq!(json["state"], "running"); + assert!(json.get("exit_code").is_none()); + assert!(json.get("termination").is_none()); + assert!(ProcessStatusResult { + process_id: ProcessId("proc-1".into()), + state: ProcessState::Running, + exit_code: None, + termination: None, + output_total: 0, + output_retained_from: 0, + eof: false, + coordination: None, + } + .invariants_hold()); + assert!(!ProcessStatusResult { + process_id: ProcessId("proc-1".into()), + state: ProcessState::Running, + exit_code: Some(0), + termination: None, + output_total: 0, + output_retained_from: 0, + eof: false, + coordination: None, + } + .invariants_hold()); + let schema = serde_json::to_value(schemars::schema_for!(ProcessStatusResult)).unwrap(); + let dumped = schema.to_string(); + assert!(dumped.contains("output_total"), "{dumped}"); + assert!(dumped.contains("termination"), "{dumped}"); + } + + #[test] + fn exited_success_may_include_exit_code() { + let json = serde_json::to_value(ProcessStatusResult { + process_id: ProcessId("proc-1".into()), + state: ProcessState::Exited, + exit_code: Some(0), + termination: Some(ProcessTermination::Exited), + output_total: 12, + output_retained_from: 0, + eof: true, + coordination: None, + }) + .unwrap(); + assert_eq!(json["state"], "exited"); + assert_eq!(json["exit_code"], 0); + assert_eq!(json["termination"], "exited"); + } + + #[test] + fn timeout_status_has_no_exit_code() { + let json = serde_json::to_value(ProcessStatusResult { + process_id: ProcessId("proc-1".into()), + state: ProcessState::Exited, + exit_code: None, + termination: Some(ProcessTermination::Timeout), + output_total: 0, + output_retained_from: 0, + eof: true, + coordination: None, + }) + .unwrap(); + assert_eq!(json["termination"], "timeout"); + assert!(json.get("exit_code").is_none()); + } + + #[test] + fn read_process_result_exposes_output_loss() { + let json = serde_json::to_value(ReadProcessResult { + process_id: ProcessId("proc-1".into()), + cursor: 10, + chunk: String::new(), + eof: false, + output_lost: true, + retained_from: 8, + coordination: None, + }) + .unwrap(); + assert_eq!(json["output_lost"], true); + assert_eq!(json["retained_from"], 8); + let schema = serde_json::to_value(schemars::schema_for!(ReadProcessResult)).unwrap(); + let dumped = schema.to_string(); + assert!(dumped.contains("output_lost"), "{dumped}"); + assert!(dumped.contains("retained_from"), "{dumped}"); + } + + #[test] + fn process_status_params_are_process_id_only() { + let schema = serde_json::to_value(schemars::schema_for!(ProcessStatusParams)).unwrap(); + let dumped = schema.to_string(); + assert!(dumped.contains("process_id"), "{dumped}"); + assert!(!dumped.contains("tty_size"), "{dumped}"); + assert!(!dumped.contains("signal"), "{dumped}"); + } } diff --git a/crates/domain/src/tools.rs b/crates/domain/src/tools.rs index 80fb4f1..d6945a6 100644 --- a/crates/domain/src/tools.rs +++ b/crates/domain/src/tools.rs @@ -8,6 +8,7 @@ pub const TOOL_APPLY_PATCH: &str = "apply_patch"; pub const TOOL_EXEC_COMMAND: &str = "exec_command"; pub const TOOL_WRITE_STDIN: &str = "write_stdin"; pub const TOOL_READ_PROCESS: &str = "read_process"; +pub const TOOL_PROCESS_STATUS: &str = "process_status"; pub const TOOL_TERMINATE_PROCESS: &str = "terminate_process"; pub const TOOL_OPERATION_STATUS: &str = "operation_status"; pub const TOOL_WORK_OPEN: &str = "work_open"; @@ -33,6 +34,7 @@ pub const LIVE_TOOLS: &[&str] = &[ TOOL_EXEC_COMMAND, TOOL_WRITE_STDIN, TOOL_READ_PROCESS, + TOOL_PROCESS_STATUS, TOOL_TERMINATE_PROCESS, TOOL_WORK_OPEN, TOOL_STEER_STATUS, diff --git a/crates/runner/src/api.rs b/crates/runner/src/api.rs index 4eee403..c06a30f 100644 --- a/crates/runner/src/api.rs +++ b/crates/runner/src/api.rs @@ -4,7 +4,10 @@ use std::collections::BTreeMap; use std::path::Path; -use codespace_domain::{ErrorBody, ErrorCode, FileChange, PatchStatus, ProcessId, Profile}; +use codespace_domain::{ + ErrorBody, ErrorCode, FileChange, PatchStatus, ProcessId, ProcessState, ProcessTermination, + Profile, +}; use codespace_policy::NetworkAxis; use serde::{Deserialize, Serialize}; @@ -113,6 +116,23 @@ pub struct RunnerReadResult { pub cursor: u64, pub chunk: String, pub eof: bool, + #[serde(default)] + pub output_lost: bool, + #[serde(default)] + pub retained_from: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunnerProcessStatus { + pub process_id: ProcessId, + pub state: ProcessState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub termination: Option, + pub output_total: u64, + pub output_retained_from: u64, + pub eof: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index 03ee4e9..4480349 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -115,8 +115,8 @@ mod wire; pub use api::{ default_exec_timeout_ms, runner_local_exec_env, RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerCwd, RunnerError, RunnerExecEnv, RunnerExecPolicy, - RunnerExecRequest, RunnerExecResult, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, - DEFAULT_TIMEOUT_MS, MAX_OUTPUT_BYTES, + RunnerExecRequest, RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult, + RunnerWriteStdin, DEFAULT_TIMEOUT_MS, MAX_OUTPUT_BYTES, }; pub use files::{DEFAULT_FIND_LIMIT, DEFAULT_READ_LIMIT, VERSION_ABSENT}; pub use patch_helper::ensure_helper_for_tests; @@ -178,6 +178,10 @@ pub trait Runner: Send + Sync { &self, req: RunnerReadProcess, ) -> impl std::future::Future> + Send; + fn process_status( + &self, + process_id: &ProcessId, + ) -> impl std::future::Future> + Send; fn terminate( &self, process_id: &ProcessId, @@ -231,6 +235,14 @@ impl Runner for InProcessRunner { self.read_host_process(req).map_err(RunnerError::from) } + async fn process_status( + &self, + process_id: &ProcessId, + ) -> Result { + self.host_process_status(process_id) + .map_err(RunnerError::from) + } + async fn terminate(&self, process_id: &ProcessId) -> Result<(), RunnerError> { self.kill_host(process_id).map_err(RunnerError::from) } @@ -315,6 +327,16 @@ impl Runner for RuntimeBackend { } } + async fn process_status( + &self, + process_id: &ProcessId, + ) -> Result { + match self { + Self::InProcess(runner) => runner.process_status(process_id).await, + Self::Uds(runner) => runner.process_status(process_id).await, + } + } + async fn terminate(&self, process_id: &ProcessId) -> Result<(), RunnerError> { match self { Self::InProcess(runner) => runner.terminate(process_id).await, diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index 2378a6a..605c6e1 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -10,7 +10,9 @@ use std::process::Stdio; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use codespace_domain::{ErrorBody, ErrorCode, ProcessId, Profile}; +use codespace_domain::{ + ErrorBody, ErrorCode, ProcessId, ProcessState, ProcessTermination, Profile, +}; use codespace_linux_sandbox_protocol::SandboxNetwork; use codespace_policy::{NetworkAxis, Workspace}; @@ -22,8 +24,8 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use crate::{ - runner_local_exec_env, RunnerCwd, RunnerExecRequest, RunnerExecResult, RunnerReadProcess, - RunnerReadResult, RunnerWriteStdin, + runner_local_exec_env, RunnerCwd, RunnerExecRequest, RunnerExecResult, RunnerProcessStatus, + RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, }; pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); @@ -70,6 +72,7 @@ struct Slot { io: SessionIo, output: Arc>, completed_at: Arc>>, + lifecycle: Arc>, } enum SessionIo { @@ -89,6 +92,7 @@ struct SpawnCtx { timeout: Duration, output: Arc>, completed_at: Arc>>, + lifecycle: Arc>, process_id: String, } @@ -101,6 +105,92 @@ struct OutputBuf { timed_out: bool, } +#[derive(Clone, Copy)] +struct LifecycleSnapshot { + state: ProcessState, + exit_code: Option, + termination: Option, +} + +#[derive(Clone, Copy)] +enum KillIntent { + Timeout, + Terminated, +} + +struct Lifecycle { + finished: bool, + kill_intent: Option, + snapshot: LifecycleSnapshot, +} + +impl Lifecycle { + fn new() -> Self { + Self { + finished: false, + kill_intent: None, + snapshot: LifecycleSnapshot { + state: ProcessState::Running, + exit_code: None, + termination: None, + }, + } + } + + fn note_kill(&mut self, intent: KillIntent) { + if self.kill_intent.is_none() { + self.kill_intent = Some(intent); + } + } + + fn finish_wait(&mut self, code: Option) { + if self.finished { + return; + } + self.finished = true; + match self.kill_intent { + Some(KillIntent::Timeout) => { + self.snapshot = LifecycleSnapshot { + state: ProcessState::Exited, + exit_code: None, + termination: Some(ProcessTermination::Timeout), + }; + } + Some(KillIntent::Terminated) => { + self.snapshot = LifecycleSnapshot { + state: ProcessState::Exited, + exit_code: None, + termination: Some(ProcessTermination::Terminated), + }; + } + None => { + self.snapshot = LifecycleSnapshot { + state: ProcessState::Exited, + exit_code: code, + termination: Some(ProcessTermination::Exited), + }; + } + } + } + + fn finish_lost(&mut self) { + if self.finished { + return; + } + self.finished = true; + let termination = match self.kill_intent { + Some(KillIntent::Timeout) => ProcessTermination::Timeout, + Some(KillIntent::Terminated) => ProcessTermination::Terminated, + None => ProcessTermination::Unknown, + }; + self.snapshot = LifecycleSnapshot { + state: ProcessState::Exited, + exit_code: None, + termination: Some(termination), + }; + } +} + impl InProcessRunner { pub fn new(on_release: ShellRelease) -> Self { Self::with_retention(on_release, RetentionPolicy::default()) @@ -164,6 +254,7 @@ impl InProcessRunner { timeout, output: Arc::new(Mutex::new(OutputBuf::default())), completed_at: Arc::new(Mutex::new(None)), + lifecycle: Arc::new(Mutex::new(Lifecycle::new())), process_id: req.process_id.0.clone(), }; if req.tty { @@ -185,6 +276,7 @@ impl InProcessRunner { timeout, output, completed_at, + lifecycle, process_id, } = ctx; let launch = exec_launch(ws, &req)?; @@ -219,6 +311,7 @@ impl InProcessRunner { }, output: output.clone(), completed_at: completed_at.clone(), + lifecycle: lifecycle.clone(), }; { let mut map = self.inner.lock().expect("runner"); @@ -244,10 +337,11 @@ impl InProcessRunner { let wait_child = child.clone(); let wait_out = output.clone(); + let wait_life = lifecycle.clone(); let wait_release = self.on_release.clone(); let wait_process = process_id; tokio::spawn(async move { - reap_child(wait_child).await; + reap_child(wait_child, wait_life).await; join_pump(out_handle).await; join_pump(err_handle).await; if let Ok(mut buf) = wait_out.lock() { @@ -261,13 +355,31 @@ impl InProcessRunner { let timeout_child = child; let timeout_out = output; + let timeout_life = lifecycle; tokio::spawn(async move { tokio::time::sleep(timeout).await; let mut ch = timeout_child.lock().expect("child"); - if ch.try_wait().ok().flatten().is_none() { - let _ = ch.start_kill(); - if let Ok(mut buf) = timeout_out.lock() { - buf.timed_out = true; + let mut life = timeout_life.lock().expect("lifecycle"); + if life.finished { + return; + } + match ch.try_wait() { + Ok(Some(status)) => life.finish_wait(status.code()), + Ok(None) => { + life.note_kill(KillIntent::Timeout); + let _ = ch.start_kill(); + drop(life); + if let Ok(mut buf) = timeout_out.lock() { + buf.timed_out = true; + } + } + Err(_) => { + life.note_kill(KillIntent::Timeout); + life.finish_lost(); + drop(life); + if let Ok(mut buf) = timeout_out.lock() { + buf.timed_out = true; + } } } }); @@ -289,6 +401,7 @@ impl InProcessRunner { timeout, output, completed_at, + lifecycle, process_id, } = ctx; let launch = exec_launch(ws, &req)?; @@ -328,6 +441,7 @@ impl InProcessRunner { }, output: output.clone(), completed_at: completed_at.clone(), + lifecycle: lifecycle.clone(), }; { let mut map = self.inner.lock().expect("runner"); @@ -349,10 +463,14 @@ impl InProcessRunner { )) }; let wait_out = output.clone(); + let wait_life = lifecycle.clone(); let wait_release = self.on_release.clone(); let wait_process = process_id; tokio::spawn(async move { - let _ = exit.await; + match exit.await { + Ok(code) => wait_life.lock().expect("lifecycle").finish_wait(Some(code)), + Err(_) => wait_life.lock().expect("lifecycle").finish_lost(), + } join_pump(out_handle).await; if let Ok(mut buf) = wait_out.lock() { buf.eof = true; @@ -365,13 +483,18 @@ impl InProcessRunner { let timeout_session = session; let timeout_out = output; + let timeout_life = lifecycle; tokio::spawn(async move { tokio::time::sleep(timeout).await; - if !timeout_session.has_exited() { - timeout_session.kill(); - if let Ok(mut buf) = timeout_out.lock() { - buf.timed_out = true; - } + let mut life = timeout_life.lock().expect("lifecycle"); + if life.finished || timeout_session.has_exited() { + return; + } + life.note_kill(KillIntent::Timeout); + drop(life); + timeout_session.kill(); + if let Ok(mut buf) = timeout_out.lock() { + buf.timed_out = true; } }); @@ -444,6 +567,30 @@ impl InProcessRunner { cursor: next, chunk: String::from_utf8_lossy(&chunk).into_owned(), eof: buf.eof && next >= buf.total, + output_lost: buf.dropped > 0, + retained_from: buf.dropped, + }) + } + + pub fn host_process_status( + &self, + process_id: &ProcessId, + ) -> Result { + let mut map = self.inner.lock().expect("runner"); + self.evict_completed(&mut map); + let slot = map + .get(&process_id.0) + .ok_or_else(|| missing(&process_id.0))?; + let snap = slot.lifecycle.lock().expect("lifecycle").snapshot; + let buf = slot.output.lock().expect("output"); + Ok(RunnerProcessStatus { + process_id: process_id.clone(), + state: snap.state, + exit_code: snap.exit_code, + termination: snap.termination, + output_total: buf.total, + output_retained_from: buf.dropped, + eof: buf.eof, }) } @@ -479,13 +626,25 @@ impl InProcessRunner { } } -async fn reap_child(child: Arc>) { +async fn reap_child(child: Arc>, lifecycle: Arc>) { loop { { let mut ch = child.lock().expect("child"); - if ch.try_wait().ok().flatten().is_some() { + let mut life = lifecycle.lock().expect("lifecycle"); + if life.finished { return; } + match ch.try_wait() { + Ok(Some(status)) => { + life.finish_wait(status.code()); + return; + } + Ok(None) => {} + Err(_) => { + life.finish_lost(); + return; + } + } } tokio::time::sleep(Duration::from_millis(20)).await; } @@ -501,18 +660,34 @@ fn request_kill(slot: &Slot) -> Result { match &slot.io { SessionIo::Pipe { child, .. } => { let mut child = child.lock().expect("child"); - if child.try_wait().ok().flatten().is_some() { + let mut life = slot.lifecycle.lock().expect("lifecycle"); + if life.finished { return Ok(false); } - child - .start_kill() - .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; - Ok(true) + match child.try_wait() { + Ok(Some(status)) => { + life.finish_wait(status.code()); + Ok(false) + } + Ok(None) => { + life.note_kill(KillIntent::Terminated); + child + .start_kill() + .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; + Ok(true) + } + Err(_) => { + life.finish_lost(); + Ok(false) + } + } } SessionIo::Pty { session, .. } => { - if session.has_exited() { + let mut life = slot.lifecycle.lock().expect("lifecycle"); + if life.finished || session.has_exited() { return Ok(false); } + life.note_kill(KillIntent::Terminated); session.kill(); Ok(true) } @@ -695,6 +870,17 @@ mod tests { chunk } + async fn wait_exited(runner: &InProcessRunner, process_id: &ProcessId) -> RunnerProcessStatus { + for _ in 0..250 { + let status = runner.process_status(process_id).await.unwrap(); + if status.state == ProcessState::Exited { + return status; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("process did not exit: {}", process_id.0) + } + fn workspace(root: &std::path::Path) -> Workspace { Workspace::new( WorkspaceId("demo".into()), @@ -1135,4 +1321,150 @@ mod tests { } assert!(eof, "SIGTERM on the helper must reap the sandbox tree"); } + + #[tokio::test] + async fn echo_status_is_exited_zero() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-echo-status".into()); + runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "hi".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap(); + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.state, ProcessState::Exited); + assert_eq!(status.termination, Some(ProcessTermination::Exited)); + assert_eq!(status.exit_code, Some(0)); + assert!(status.eof); + let read = runner + .read_process(RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + assert!(!read.output_lost); + assert_eq!(read.retained_from, 0); + } + + #[tokio::test] + async fn false_status_is_exited_one() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-false-status".into()); + runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/usr/bin/false".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap(); + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.state, ProcessState::Exited); + assert_eq!(status.termination, Some(ProcessTermination::Exited)); + assert_eq!(status.exit_code, Some(1)); + } + + #[tokio::test] + async fn timeout_status_has_no_exit_code() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-timeout-status".into()); + let mut req = RunnerExecRequest::for_host( + vec!["/bin/sleep".into(), "30".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ); + req.timeout_ms = 50; + runner.exec(&ws, req).await.unwrap(); + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.state, ProcessState::Exited); + assert_eq!(status.termination, Some(ProcessTermination::Timeout)); + assert!(status.exit_code.is_none()); + let err = runner + .read_process(RunnerReadProcess { + process_id, + cursor: status.output_total, + }) + .await + .unwrap_err(); + assert_eq!( + err.as_execution().map(|body| body.code), + Some(ErrorCode::Timeout) + ); + } + + #[tokio::test] + async fn terminate_status_has_no_exit_code() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-term-status".into()); + runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/sleep".into(), "30".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap(); + runner.kill_host(&process_id).unwrap(); + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.state, ProcessState::Exited); + assert_eq!(status.termination, Some(ProcessTermination::Terminated)); + assert!(status.exit_code.is_none()); + } + + #[tokio::test] + async fn overflow_read_exposes_output_loss() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-overflow".into()); + runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec![ + "/bin/sh".into(), + "-c".into(), + "dd if=/dev/zero bs=1024 count=300 2>/dev/null".into(), + ], + process_id.clone(), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap(); + let status = wait_exited(&runner, &process_id).await; + assert!(status.output_total > crate::MAX_OUTPUT_BYTES as u64); + assert!(status.output_retained_from > 0); + let read = runner + .read_process(RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + assert!(read.output_lost); + assert_eq!(read.retained_from, status.output_retained_from); + assert!(read.cursor >= read.retained_from); + } } diff --git a/crates/runner/src/uds.rs b/crates/runner/src/uds.rs index 2e209ed..ea5df28 100644 --- a/crates/runner/src/uds.rs +++ b/crates/runner/src/uds.rs @@ -26,7 +26,8 @@ use crate::wire::{ }; use crate::{ Runner, RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerError, RunnerExecRequest, - RunnerExecResult, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, ShellRelease, + RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, + ShellRelease, }; /// Transport deadline for one RPC. Longer than the default exec timeout @@ -382,6 +383,21 @@ impl Runner for UdsRunner { } } + async fn process_status( + &self, + process_id: &ProcessId, + ) -> Result { + match self + .call(RunnerOp::ProcessStatus { + process_id: process_id.clone(), + }) + .await? + { + RunnerOpResult::ProcessStatus(result) => Ok(result), + other => Err(unexpected(other)), + } + } + async fn terminate(&self, process_id: &ProcessId) -> Result<(), RunnerError> { match self .call(RunnerOp::Terminate { diff --git a/crates/runner/src/wire.rs b/crates/runner/src/wire.rs index 04c1f0a..ada7db7 100644 --- a/crates/runner/src/wire.rs +++ b/crates/runner/src/wire.rs @@ -15,11 +15,11 @@ use tokio::sync::{mpsc, Mutex}; use crate::{ InProcessRunner, Runner, RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerError, - RunnerExecRequest, RunnerExecResult, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, - ShellRelease, + RunnerExecRequest, RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult, + RunnerWriteStdin, ShellRelease, }; -pub const WIRE_PROTOCOL: u32 = 3; +pub const WIRE_PROTOCOL: u32 = 4; const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; const MAX_REPLAY: usize = 32; @@ -137,6 +137,9 @@ pub enum RunnerOp { ReadProcess { request: RunnerReadProcess, }, + ProcessStatus { + process_id: ProcessId, + }, Terminate { process_id: ProcessId, }, @@ -162,6 +165,7 @@ pub enum RunnerOpResult { Exec(RunnerExecResult), WriteStdin, ReadProcess(RunnerReadResult), + ProcessStatus(RunnerProcessStatus), Terminate, WorkspaceOf(Option), TerminateWorkspace(u32), @@ -341,6 +345,10 @@ async fn dispatch(runner: &InProcessRunner, op: RunnerOp) -> Result runner + .process_status(&process_id) + .await + .map(RunnerOpResult::ProcessStatus), RunnerOp::Terminate { process_id } => runner .terminate(&process_id) .await @@ -371,8 +379,8 @@ mod tests { } #[test] - fn wire_protocol_is_v3() { - assert_eq!(WIRE_PROTOCOL, 3); + fn wire_protocol_is_v4() { + assert_eq!(WIRE_PROTOCOL, 4); } #[tokio::test] @@ -477,6 +485,25 @@ mod tests { assert!(eof.is_none()); } + #[tokio::test] + async fn protocol_3_hello_is_rejected() { + let (client, server) = tokio::net::UnixStream::pair().unwrap(); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve"); + }); + let (mut read, mut write) = client.into_split(); + let mut envelope = WireEnvelope::request("rrpc-old".into(), RunnerOp::Hello); + envelope.protocol = 3; + write_frame(&mut write, &envelope).await.unwrap(); + let reply = read_frame(&mut read).await.unwrap().unwrap(); + let parsed: WireEnvelope = serde_json::from_slice(&reply).unwrap(); + assert_eq!(parsed.ok, Some(false)); + assert!(parsed.error.is_some()); + } + #[tokio::test] async fn hello_round_trip() { let (client, server) = tokio::net::UnixStream::pair().unwrap(); diff --git a/crates/runner/tests/uds_runner.rs b/crates/runner/tests/uds_runner.rs index b3545cd..06032ae 100644 --- a/crates/runner/tests/uds_runner.rs +++ b/crates/runner/tests/uds_runner.rs @@ -111,6 +111,57 @@ async fn uds_runner_read_and_exec_over_length_prefix() { ); } +#[tokio::test] +async fn uds_process_status_echo_and_protocol_4_hello() { + let (client, server) = UnixStream::pair().expect("unix pair"); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve runner"); + }); + let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); + runner.handshake().await.expect("hello protocol 4"); + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let process_id = ProcessId("proc-uds-status".into()); + runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "ok".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap(); + let mut status = None; + for _ in 0..50 { + let got = runner.process_status(&process_id).await.unwrap(); + if got.state == codespace_domain::ProcessState::Exited { + status = Some(got); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let status = status.expect("process_status exited"); + assert_eq!( + status.termination, + Some(codespace_domain::ProcessTermination::Exited) + ); + assert_eq!(status.exit_code, Some(0)); + let read = runner + .read_process(codespace_runner::RunnerReadProcess { + process_id, + cursor: 0, + }) + .await + .unwrap(); + assert!(!read.output_lost); + assert_eq!(read.retained_from, 0); +} + #[tokio::test] async fn uds_apply_patch_lost_response_is_ambiguous() { let (client, server) = UnixStream::pair().expect("unix pair"); diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 0d39276..9fec30c 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -7,11 +7,11 @@ use codespace_domain::{ ClientEnvironmentKind, CoordinationHint, EffectivePermissionInfo, EnvironmentExecutionInfo, ErrorBody, ErrorCode, ExecCommandParams, ExecCommandResult, ExecDispatchStatus, FindParams, FindResult, NetworkPolicyState, OperationResumeParams, OperationResumeResult, - OperationStatusParams, OperationStatusResult, PatchStatus, ProcessId, ReadParams, - ReadProcessParams, ReadProcessResult, ReadResult, SteerClaimNextResult, SteerCompleteParams, - SteerStatusResult, TerminateProcessParams, WorkFinishResult, WorkId, WorkIdParams, - WorkOpenParams, WorkOpenResult, WorkspaceExecutionInfo, WorkspaceInfo, WorkspaceInfoParams, - WriteStdinParams, + OperationStatusParams, OperationStatusResult, PatchStatus, ProcessId, ProcessStatusParams, + ProcessStatusResult, ReadParams, ReadProcessParams, ReadProcessResult, ReadResult, + SteerClaimNextResult, SteerCompleteParams, SteerStatusResult, TerminateProcessParams, + WorkFinishResult, WorkId, WorkIdParams, WorkOpenParams, WorkOpenResult, WorkspaceExecutionInfo, + WorkspaceInfo, WorkspaceInfoParams, WriteStdinParams, }; use codespace_policy::{ allow, Action, ClientClaims, EnvironmentKind, NetworkAxis, PermissionProfile, Registry, @@ -88,8 +88,22 @@ operation_resume; resume re-checks policy. If exec_command reports dispatch_status=unknown, the spawn may have occurred. \ Do not blindly start a duplicate process. The returned process_id identifies \ -the uncertain attempt. Use read_process or terminate_process when the backend \ -remains reachable; do not assume that unknown means the process did not start. +the uncertain attempt. Use read_process, process_status, or terminate_process \ +when the backend remains reachable; do not assume that unknown means the \ +process did not start. + +process_id is a lifecycle handle after spawn. exec_command returns dispatch \ +identity only. Use process_status to observe state running or exited. \ +termination is present only after exit and is one of exited, timeout, \ +terminated, or unknown. exit_code is present only when termination is exited. \ +EOF from read_process is not a successful exit. timeout, terminated, and \ +unknown are not success even when eof is true. + +read_process results include output_lost and retained_from. If output_lost is \ +true, the retained window is not the complete log. + +tty_size is not an exec_command argument. PTY resize is not currently \ +supported. Claim user intents only at major checkpoints and before work_finish."; @@ -226,7 +240,7 @@ impl CodeSpace { #[tool( name = "exec_command", - description = "Start a managed argv in the workspace cwd. There is no implicit shell. Returns a server-minted process_id and a dispatch_status. Request end does not terminate the process. Omitted or false tty uses pipes. tty=true attaches a fixed 24x80 PTY; resize is not supported. Use tty only for commands requiring terminal semantics or an interactive TUI. A live process holds the workspace mutation lease, so another exec_command or apply_patch may return WORKSPACE_BUSY until it exits or is terminated. Use write_stdin, read_process, and terminate_process with the returned process_id. dispatch_status=unknown means the spawn may have occurred. Do not blindly start a duplicate process. The returned process_id identifies the uncertain attempt. Use read_process or terminate_process when the backend remains reachable; do not assume that unknown means the process did not start. PROCESS_SPAWN_FAILED means the backend confirmed that no managed process was started; it is distinct from dispatch_status=unknown. When the workspace approvals mode is confirm, a policy-allowed request returns APPROVAL_REQUIRED before spawn." + description = "Start a managed argv in the workspace cwd. There is no implicit shell. Returns a server-minted process_id and a dispatch_status. Request end does not terminate the process. Omitted or false tty uses pipes. tty=true attaches a fixed 24x80 PTY; resize is not supported. Use tty only for commands requiring terminal semantics or an interactive TUI. A live process holds the workspace mutation lease, so another exec_command or apply_patch may return WORKSPACE_BUSY until it exits or is terminated. Use write_stdin, read_process, process_status, and terminate_process with the returned process_id. dispatch_status=unknown means the spawn may have occurred. Do not blindly start a duplicate process. The returned process_id identifies the uncertain attempt. Use read_process, process_status, or terminate_process when the backend remains reachable; do not assume that unknown means the process did not start. PROCESS_SPAWN_FAILED means the backend confirmed that no managed process was started; it is distinct from dispatch_status=unknown. When the workspace approvals mode is confirm, a policy-allowed request returns APPROVAL_REQUIRED before spawn." )] async fn exec_command( &self, @@ -273,7 +287,7 @@ impl CodeSpace { #[tool( name = "read_process", - description = "Read output from a managed process starting at cursor. Output is bounded; process_id cannot be invented." + description = "Read output from a managed process starting at cursor. Output is bounded; output_lost means the retained window is not the complete log. process_id cannot be invented." )] async fn read_process( &self, @@ -292,6 +306,33 @@ impl CodeSpace { cursor: result.cursor, chunk: result.chunk, eof: result.eof, + output_lost: result.output_lost, + retained_from: result.retained_from, + coordination: self.process_hint(¶ms.process_id.0).await, + })) + } + + #[tool( + name = "process_status", + description = "Observe a managed process lifecycle. state is running or exited. termination is present only after exit (exited, timeout, terminated, or unknown). exit_code is present only when termination is exited. EOF is not success. Unknown process_id is rejected." + )] + async fn process_status( + &self, + Parameters(params): Parameters, + ) -> Result, String> { + let result = self + .runner + .process_status(¶ms.process_id) + .await + .map_err(runner_err_json)?; + Ok(Json(ProcessStatusResult { + process_id: result.process_id, + state: result.state, + exit_code: result.exit_code, + termination: result.termination, + output_total: result.output_total, + output_retained_from: result.output_retained_from, + eof: result.eof, coordination: self.process_hint(¶ms.process_id.0).await, })) } @@ -1022,6 +1063,16 @@ mod tests { assert!(text.contains("dispatch_status=unknown"), "{text}"); assert!(text.contains("uncertain attempt"), "{text}"); assert!(text.contains("backend remains reachable"), "{text}"); + assert!(text.contains("process_status"), "{text}"); + assert!(text.contains("output_lost"), "{text}"); + assert!( + text.contains("EOF from read_process is not a successful exit"), + "{text}" + ); + assert!( + text.contains("tty_size is not an exec_command argument"), + "{text}" + ); assert!( !text.contains("inspect or terminate that handle rather than"), "{text}" diff --git a/crates/server/tests/process.rs b/crates/server/tests/process.rs index ed4c311..9e8da09 100644 --- a/crates/server/tests/process.rs +++ b/crates/server/tests/process.rs @@ -1,6 +1,6 @@ use codespace_domain::{ - LIVE_TOOLS, TOOL_APPLY_PATCH, TOOL_EXEC_COMMAND, TOOL_FIND, TOOL_READ, TOOL_READ_PROCESS, - TOOL_TERMINATE_PROCESS, TOOL_WORKSPACE_INFO, TOOL_WRITE_STDIN, + LIVE_TOOLS, TOOL_APPLY_PATCH, TOOL_EXEC_COMMAND, TOOL_FIND, TOOL_PROCESS_STATUS, TOOL_READ, + TOOL_READ_PROCESS, TOOL_TERMINATE_PROCESS, TOOL_WORKSPACE_INFO, TOOL_WRITE_STDIN, }; use codespace_server::config::{HttpConfig, MCP_PATH}; use codespace_server::http::router_with_registry; @@ -115,6 +115,26 @@ async fn exec_echo_is_readable_and_unknown_id_is_rejected() { "missing process output: {chunk:?}" ); + let mut status_body = serde_json::Value::Null; + for _ in 0..50 { + let status = client + .call_tool( + CallToolRequestParams::new(TOOL_PROCESS_STATUS) + .with_arguments(object!({ "process_id": pid })), + ) + .await + .expect("process_status"); + status_body = payload(&status); + if status_body["state"] == "exited" { + break; + } + sleep(Duration::from_millis(40)).await; + } + assert_eq!(status_body["state"], "exited"); + assert_eq!(status_body["termination"], "exited"); + assert_eq!(status_body["exit_code"], 0); + assert_eq!(status_body["eof"], true); + let missing = client .call_tool( CallToolRequestParams::new(TOOL_READ_PROCESS) @@ -643,6 +663,25 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { let mut expected = LIVE_TOOLS.to_vec(); expected.sort(); assert_eq!(names, expected, "tools/list must match LIVE_TOOLS"); + assert!( + names.contains(&TOOL_PROCESS_STATUS), + "LIVE_TOOLS must include process_status, got {names:?}" + ); + + let status_tool = tools + .iter() + .find(|tool| tool.name.as_ref() == TOOL_PROCESS_STATUS) + .expect("process_status"); + let status_out = serde_json::to_value(status_tool.output_schema.as_ref()).unwrap(); + let status_dumped = status_out.to_string(); + assert!( + status_dumped.contains("termination"), + "process_status result schema must include termination: {status_dumped}" + ); + assert!( + status_dumped.contains("output_total"), + "process_status result schema must include output_total: {status_dumped}" + ); let exec = tools .iter() diff --git a/crates/server/tests/protocol_compat.rs b/crates/server/tests/protocol_compat.rs index caa87d7..5eb1c34 100644 --- a/crates/server/tests/protocol_compat.rs +++ b/crates/server/tests/protocol_compat.rs @@ -8,9 +8,9 @@ use std::sync::Arc; use codespace_domain::{ Profile, WorkspaceId, LIVE_TOOLS, SERVER_NAME, TOOL_APPLY_PATCH, TOOL_EXEC_COMMAND, - TOOL_OPERATION_STATUS, TOOL_READ, TOOL_STEER_CLAIM_NEXT, TOOL_STEER_COMPLETE, - TOOL_STEER_STATUS, TOOL_WORKSPACE_INFO, TOOL_WORK_FINISH, TOOL_WORK_OPEN, TRANSPORT_STDIO, - TRANSPORT_STREAMABLE_HTTP, + TOOL_OPERATION_STATUS, TOOL_PROCESS_STATUS, TOOL_READ, TOOL_STEER_CLAIM_NEXT, + TOOL_STEER_COMPLETE, TOOL_STEER_STATUS, TOOL_WORKSPACE_INFO, TOOL_WORK_FINISH, TOOL_WORK_OPEN, + TRANSPORT_STDIO, TRANSPORT_STREAMABLE_HTTP, }; use codespace_policy::{Registry, Workspace}; use codespace_server::config::{HttpConfig, INBOX_PATH, MCP_PATH}; @@ -57,6 +57,10 @@ fn assert_live_tools(names: impl IntoIterator>) { names.iter().any(|n| n == TOOL_WORKSPACE_INFO), "tools/list must include workspace_info, got {names:?}" ); + assert!( + names.iter().any(|n| n == TOOL_PROCESS_STATUS), + "tools/list must include process_status, got {names:?}" + ); assert_eq!(names, expected); } diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 7e819e8..063afc2 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -71,9 +71,9 @@ A preview returns `status: "checked"` without writing. To apply, send the same p } ``` -The command is an argument array. Shell quoting, pipes, and `&&` are not interpreted unless you explicitly launch a shell. The working directory is the workspace root; environment and timeout are operator-controlled. Add `"tty": true` to allocate a pseudo-terminal (PTY) when a program requires a terminal (fixed 24×80; no resize API). +The command is an argument array. Shell quoting, pipes, and `&&` are not interpreted unless you explicitly launch a shell. The working directory is the workspace root; environment and timeout are operator-controlled. Add `"tty": true` to allocate a pseudo-terminal (PTY) when a program requires a terminal. The size is fixed at 24×80. `tty_size` is not an `exec_command` argument, and there is no resize tool. -The response contains a server-issued `process_id` and `dispatch_status`. `confirmed` means dispatch was acknowledged, **not that the command succeeded**. Save the ID, then poll with a modest delay and pass the returned cursor into the next read: +The response contains a server-issued `process_id` and `dispatch_status`. `confirmed` means dispatch was acknowledged, **not that the command succeeded**. Save the ID. Poll output with `read_process` using the returned cursor, and judge exit with `process_status`. ```json { @@ -85,7 +85,16 @@ The response contains a server-issued `process_id` and `dispatch_status`. `confi } ``` -Each result has `chunk`, `cursor`, and `eof`. Output combines stdout/stderr without preserving their identity. EOF means output collection is complete; it is not a successful exit status. MCP currently exposes no exit code. The last 256 KiB are retained and older bytes can be dropped without an explicit loss flag. Do not claim a build or test passed solely from EOF or an incomplete log. If success cannot be established from a reliable task-specific result, report it as unverified. +Each result has `chunk`, `cursor`, `eof`, `output_lost`, and `retained_from`. Output combines stdout/stderr without preserving their identity. EOF means output collection is complete; it is not a successful exit status. Judge termination with `process_status`: `state` is `running` or `exited`. After exit, `termination` is `exited`, `timeout`, `terminated`, or `unknown`. `exit_code` is present only when `termination` is `exited`. `timeout`, `terminated`, and `unknown` are not success, even when `eof` is true. The last 256 KiB are retained. If `output_lost` is true, the retained window is not the complete log. Do not claim a build or test passed solely from EOF or an incomplete log. If success cannot be established from a reliable task-specific result, report it as unverified. + +```json +{ + "name": "process_status", + "arguments": { + "process_id": "PROCESS_ID_FROM_EXEC" + } +} +``` @@ -103,7 +112,9 @@ These example result bodies illustrate the command above. IDs are placeholders: "process_id": "SERVER_ISSUED_PROCESS_ID", "cursor": 12, "chunk": "agent-smoke\n", - "eof": true + "eof": true, + "output_lost": false, + "retained_from": 0 } ``` diff --git a/docs/codex-reuse.md b/docs/codex-reuse.md index 6a2b148..925431e 100644 --- a/docs/codex-reuse.md +++ b/docs/codex-reuse.md @@ -35,7 +35,7 @@ Agent → CodeSpace MCP/policy/store → Runner contract Core crates have no direct Codex dependencies. Adapter crates are separate Cargo workspaces to accommodate the pinned upstream workspace dependencies. The filesystem and PTY adapters are library dependencies of the Runner; patch and Linux sandbox operations use helper processes. An isolated Cargo workspace alone does not create a process or security boundary. -The Linux sandbox helper is binary-only. `codespace-linux-sandbox-protocol` contains its CodeSpace-owned handshake data and no Codex types. Worker UDS protocol version 3 and sandbox-helper protocol version 1 are separate contracts. +The Linux sandbox helper is binary-only. `codespace-linux-sandbox-protocol` contains its CodeSpace-owned handshake data and no Codex types. Worker UDS protocol version 4 and sandbox-helper protocol version 1 are separate contracts. diff --git a/docs/error-codes.md b/docs/error-codes.md index c7e087b..e061618 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -56,6 +56,6 @@ Errors use uppercase identifiers and a message. An `operation_id` may be present `dispatch_status: unknown` is a successful exec result describing an uncertain dispatch, not proof that no process started. Use the returned process handle if reachable and avoid duplicate launches. `confirmed` acknowledges dispatch; it does not mean command success. -Linux helper preparation/protocol/start errors occur before a managed process exists and use `PROCESS_SPAWN_FAILED`. Once the helper is running, plan load, inner sandbox, or proxy-start failure becomes process termination. There is currently no exit-code field in the public result, so a completed output stream is insufficient success evidence. +Linux helper preparation/protocol/start errors occur before a managed process exists and use `PROCESS_SPAWN_FAILED`. Once the helper is running, plan load, inner sandbox, or proxy-start failure becomes process termination. Use `process_status` to observe that outcome; EOF is not success. On Linux sandbox the wait status belongs to the managed child (helper argv). For patches, see the [status table and rollback limits](behavior-differences.md). For retries, timeouts, missing handles, and user-instruction completion, follow [Agent Loop integration](agent-integration.md). A `work_finish` response with `closed: false` and `reason: "pending_user_input"` is an application result, not a transport failure. diff --git a/docs/execution-substrate.md b/docs/execution-substrate.md index 5698b30..7fa5040 100644 --- a/docs/execution-substrate.md +++ b/docs/execution-substrate.md @@ -37,7 +37,9 @@ The operator registry maps `read-only` and `workspace-write` to effective permis ## Execution and observation -Gateway fills workspace-root cwd, runner-local environment defaults, time/output limits, PTY choice, and policy into the internal Runner request. Only `tty` is exposed as a terminal option today. Public calls do not accept arbitrary cwd/env/timeout overrides. See [operations](operations.md) for defaults and [Agent Loop integration](agent-integration.md) for result handling. +Gateway fills workspace-root cwd, runner-local environment defaults, time/output limits, PTY choice, and policy into the internal Runner request. Only `tty` is exposed as a terminal option today. `tty_size` is not a spawn argument. Public calls do not accept arbitrary cwd/env/timeout overrides. See [operations](operations.md) for defaults and [Agent Loop integration](agent-integration.md) for result handling. + +`exec_command` returns dispatch identity (`process_id`, `dispatch_status`). `process_status` reports `running` or `exited` plus termination metadata. `read_process` reports output including `output_lost` and `retained_from`. EOF is not success. After handle eviction the next lookup is `PROCESS_NOT_FOUND`, not a new state. On Linux sandbox, the wait status is that of the managed child (the helper argv); it is not documented as identical to the user argv. A workspace mutation lease prevents simultaneous patch/exec mutations. Read and find remain available while a command runs, so filesystem I/O must reject symlink races at open time rather than rely on a prior path check. Runner file operations use `codespace-fs`; patch execution uses the separate patch helper. `operation_status` exposes the recorded patch ledger (`kind` is `patch`); live commands stay on `process_id` and are not recovered through that lookup. @@ -67,7 +69,7 @@ When the operator sets workspace `approvals` to `confirm`, a policy-allowed `app ## What remains unimplemented -Process exit codes and explicit output-loss metadata are not exposed to MCP. PTY resize, file range/pagination arguments, durable process recovery, container/remote dispatch, and a resource queue scheduler remain absent. MCP `fs/watch`, UDS watch events, and write-cause classification are not provided. Richer internal types and negotiated protocol flags do not imply those features are callable. +PTY resize (`process_resize` is not provided), file range/pagination arguments, durable process recovery, container/remote dispatch, and a resource queue scheduler remain absent. MCP `fs/watch`, UDS watch events, and write-cause classification are not provided. Richer internal types and negotiated protocol flags do not imply those features are callable. ## Maintaining the boundary diff --git a/docs/ko/agent-integration.md b/docs/ko/agent-integration.md index ac182fa..4d6bf2f 100644 --- a/docs/ko/agent-integration.md +++ b/docs/ko/agent-integration.md @@ -71,9 +71,9 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 } ``` -명령은 인자 배열입니다. 셸을 명시적으로 실행하지 않는 한 셸 따옴표, 파이프, `&&`는 해석되지 않습니다. 작업 디렉터리는 작업 공간 루트이며 환경변수와 제한 시간은 운영자 설정을 따릅니다. 터미널이 필요한 프로그램은 `"tty": true`로 가상 터미널(PTY)을 할당합니다. 크기는 24×80으로 고정되며 크기 변경 API는 없습니다. +명령은 인자 배열입니다. 셸을 명시적으로 실행하지 않는 한 셸 따옴표, 파이프, `&&`는 해석되지 않습니다. 작업 디렉터리는 작업 공간 루트이며 환경변수와 제한 시간은 운영자 설정을 따릅니다. 터미널이 필요한 프로그램은 `"tty": true`로 가상 터미널(PTY)을 할당합니다. 크기는 24×80으로 고정됩니다. `tty_size`는 `exec_command` 인자가 아니며, 크기 변경 도구는 없습니다. -응답에는 서버가 발급한 `process_id`와 `dispatch_status`가 있습니다. `confirmed`는 실행 요청이 확인되었다는 뜻이며 **명령의 성공을 뜻하지 않습니다**. ID를 저장한 뒤 적절한 간격으로 출력을 조회하고, 매번 반환된 커서를 다음 조회에 사용합니다. +응답에는 서버가 발급한 `process_id`와 `dispatch_status`가 있습니다. `confirmed`는 실행 요청이 확인되었다는 뜻이며 **명령의 성공을 뜻하지 않습니다**. ID를 저장한 뒤 출력을 `read_process`로 조회하고, 종료는 `process_status`로 판정하세요. 출력 조회 시 매번 반환된 커서를 다음 조회에 사용합니다. ```json { @@ -85,7 +85,16 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 } ``` -결과에는 `chunk`, `cursor`, `eof`가 있습니다. stdout/stderr는 구분 없이 합쳐집니다. EOF는 출력 수집이 끝났다는 뜻이며 성공 종료를 나타내지 않습니다. 현재 MCP에는 종료 코드가 없고, 마지막 256 KiB만 보관하므로 앞부분이 별도 표시 없이 사라질 수 있습니다. EOF나 불완전한 로그만으로 빌드·테스트 성공을 선언하지 마세요. 신뢰할 수 있는 작업별 결과로 성공을 확인할 수 없다면 미검증으로 보고해야 합니다. +결과에는 `chunk`, `cursor`, `eof`, `output_lost`, `retained_from`이 있습니다. stdout/stderr는 구분 없이 합쳐집니다. EOF는 출력 수집이 끝났다는 뜻이며 성공 종료를 나타내지 않습니다. 종료는 `process_status`로 판정합니다. `state`는 `running` 또는 `exited`입니다. 종료 후 `termination`은 `exited`·`timeout`·`terminated`·`unknown` 중 하나입니다. `exit_code`는 `termination`이 `exited`일 때만 있을 수 있습니다. `timeout`·`terminated`·`unknown`은 `eof`가 참이어도 성공이 아닙니다. 마지막 256 KiB만 보관합니다. `output_lost`가 참이면 보관 창이 전체 로그가 아닙니다. EOF나 불완전한 로그만으로 빌드·테스트 성공을 선언하지 마세요. 신뢰할 수 있는 작업별 결과로 성공을 확인할 수 없다면 미검증으로 보고해야 합니다. + +```json +{ + "name": "process_status", + "arguments": { + "process_id": "PROCESS_ID_FROM_EXEC" + } +} +``` @@ -103,7 +112,9 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 "process_id": "SERVER_ISSUED_PROCESS_ID", "cursor": 12, "chunk": "agent-smoke\n", - "eof": true + "eof": true, + "output_lost": false, + "retained_from": 0 } ``` diff --git a/docs/ko/codex-reuse.md b/docs/ko/codex-reuse.md index 8fe38ee..7e4d9ff 100644 --- a/docs/ko/codex-reuse.md +++ b/docs/ko/codex-reuse.md @@ -40,7 +40,7 @@ CodeSpace는 특정 버전에 고정한 Codex 소스의 실행 라이브러리 핵심 crate에는 직접적인 Codex 의존성이 없습니다. 어댑터는 고정된 업스트림의 workspace 의존성을 수용하기 위해 별도의 Cargo workspace로 구성합니다. 파일 시스템·PTY 어댑터는 Runner의 라이브러리 의존성이며, 패치와 Linux 샌드박스는 도우미 프로세스를 사용합니다. Cargo workspace를 분리하는 것만으로 프로세스나 보안 경계가 생기지는 않습니다. -Linux 샌드박스 도우미는 실행 파일만 제공합니다. `codespace-linux-sandbox-protocol`에는 CodeSpace가 정의한 핸드셰이크 데이터만 있고 Codex 타입은 없습니다. worker의 UDS 프로토콜 버전 3과 샌드박스 도우미 프로토콜 버전 1은 별개의 계약입니다. +Linux 샌드박스 도우미는 실행 파일만 제공합니다. `codespace-linux-sandbox-protocol`에는 CodeSpace가 정의한 핸드셰이크 데이터만 있고 Codex 타입은 없습니다. worker의 UDS 프로토콜 버전 4와 샌드박스 도우미 프로토콜 버전 1은 별개의 계약입니다. diff --git a/docs/ko/error-codes.md b/docs/ko/error-codes.md index 7aee5ae..a99b456 100644 --- a/docs/ko/error-codes.md +++ b/docs/ko/error-codes.md @@ -59,6 +59,6 @@ `dispatch_status: unknown`은 실행 여부가 불확실하다는 정상 형식의 응답이며 프로세스가 시작되지 않았다는 뜻이 아닙니다. 연결 가능하면 반환된 핸들을 사용하고 중복 실행을 피하세요. `confirmed`도 요청 확인을 뜻하며 명령 성공을 뜻하지 않습니다. -Linux 도우미 준비·프로토콜·시작 오류는 관리 프로세스 생성 전에 발생하므로 `PROCESS_SPAWN_FAILED`를 사용합니다. 도우미가 이미 시작된 뒤 계획 읽기, 내부 샌드박스, 프록시 시작에서 실패하면 프로세스 종료로 처리됩니다. 현재 공개 결과에 종료 코드가 없으므로 출력 스트림 완료만으로 성공을 판단할 수 없습니다. +Linux 도우미 준비·프로토콜·시작 오류는 관리 프로세스 생성 전에 발생하므로 `PROCESS_SPAWN_FAILED`를 사용합니다. 도우미가 이미 시작된 뒤 계획 읽기, 내부 샌드박스, 프록시 시작에서 실패하면 프로세스 종료로 처리됩니다. 그 결과는 `process_status`로 확인하세요. EOF는 성공이 아닙니다. Linux 샌드박스에서 wait 상태는 관리 자식(헬퍼 argv)의 코드입니다. 패치는 [상태 표와 복구 한계](behavior-differences.md)를 참고하세요. 재시도, 시간 초과, 사라진 핸들, 사용자 지시 완료는 [Agent Loop 연동](agent-integration.md)을 따릅니다. `work_finish`의 `closed: false`, `reason: "pending_user_input"`는 애플리케이션 결과이며 전송 실패가 아닙니다. diff --git a/docs/ko/execution-substrate.md b/docs/ko/execution-substrate.md index 8a45ea4..bf63187 100644 --- a/docs/ko/execution-substrate.md +++ b/docs/ko/execution-substrate.md @@ -42,7 +42,9 @@ ## 실행과 결과 관측 -게이트웨이는 내부 Runner 요청에 작업 공간 루트 cwd, 러너 환경 기본값, 시간·출력 제한, PTY 선택, 정책을 채웁니다. 현재 터미널 옵션으로 공개된 것은 `tty`뿐입니다. 공개 호출은 임의의 cwd·환경변수·제한 시간 변경을 받지 않습니다. 기본값은 [운영](operations.md), 결과 처리는 [Agent Loop 연동](agent-integration.md)을 참고하세요. +게이트웨이는 내부 Runner 요청에 작업 공간 루트 cwd, 러너 환경 기본값, 시간·출력 제한, PTY 선택, 정책을 채웁니다. 현재 터미널 옵션으로 공개된 것은 `tty`뿐입니다. `tty_size`는 spawn 인자가 아닙니다. 공개 호출은 임의의 cwd·환경변수·제한 시간 변경을 받지 않습니다. 기본값은 [운영](operations.md), 결과 처리는 [Agent Loop 연동](agent-integration.md)을 참고하세요. + +`exec_command`는 디스패치 식별(`process_id`, `dispatch_status`)만 반환합니다. 종료 판정은 `process_status`의 `running`/`exited`와 termination 메타데이터를 사용합니다. `read_process`는 `output_lost`와 `retained_from`을 포함한 출력을 반환합니다. EOF는 성공이 아닙니다. 핸들이 만료된 뒤의 조회는 새 상태가 아니라 `PROCESS_NOT_FOUND`입니다. Linux 샌드박스에서 wait 상태는 관리 자식(헬퍼 argv)의 코드이며, 사용자 argv와 동일하다고 문서화하지 않습니다. 작업 공간 잠금은 패치와 명령이 동시에 파일을 변경하지 못하게 합니다. 명령 실행 중에도 읽기와 검색은 가능하므로 파일 I/O는 사전 경로 검사에만 의존하지 않고 파일을 여는 시점의 심볼릭 링크 변경도 거부해야 합니다. Runner 파일 작업은 `codespace-fs`, 패치 적용은 별도 패치 도우미를 사용합니다. `operation_status`는 기록된 패치 원장(`kind`는 `patch`)을 조회하며, 실행 중인 명령은 `process_id`로만 다루고 이 조회로 복구하지 않습니다. @@ -76,7 +78,7 @@ Runner는 해당 작업 공간에서 처음 `read`·`find`·`version`·`apply_pa ## 아직 제공하지 않는 기능 -MCP에는 프로세스 종료 코드와 명시적 출력 유실 정보가 없습니다. PTY 크기 변경, 파일 범위·페이지 인자, 영속적인 프로세스 복구, 컨테이너·원격 실행, 자원 큐 스케줄러도 제공하지 않습니다. MCP `fs/watch`, UDS watch 이벤트, 쓰기 원인 분류도 제공하지 않습니다. 내부 타입이나 협상된 프로토콜 플래그가 존재한다고 해당 기능을 호출할 수 있는 것은 아닙니다. +PTY 크기 변경(`process_resize`는 제공하지 않음), 파일 범위·페이지 인자, 영속적인 프로세스 복구, 컨테이너·원격 실행, 자원 큐 스케줄러도 제공하지 않습니다. MCP `fs/watch`, UDS watch 이벤트, 쓰기 원인 분류도 제공하지 않습니다. 내부 타입이나 협상된 프로토콜 플래그가 존재한다고 해당 기능을 호출할 수 있는 것은 아닙니다. ## 구현 경계 유지 diff --git a/docs/ko/operations.md b/docs/ko/operations.md index 3ea82e7..0197cb5 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -128,7 +128,7 @@ worker는 같은 호스트에서 실행하는 별도 프로세스이며 컨테 | `CODESPACE_OPERATIONS_DB` 미설정 | 패치 작업과 지시 큐를 메모리에 보관하며 재시작 시 사라짐 | | `CODESPACE_PROCESS_TIMEOUT_SECS` | 양의 정수. 기본 30초. 러너 환경에 설정 | | `CODESPACE_MAX_PROCESSES` | 러너 전체의 실행 중 프로세스 기본 상한 8개. 작업 공간별 점유 규칙도 적용 | -| 프로세스 출력 | 마지막 256 KiB 보관. stdout/stderr를 합치며 MCP 결과에 유실 표시와 종료 코드가 없음 | +| 프로세스 출력 | 마지막 256 KiB 보관. stdout/stderr를 합침. `read_process`는 `output_lost`와 `retained_from`을 보고하고, 종료는 `process_status`로 조회 | | 종료된 핸들 | 기본 최대 15분, 최대 64개 보관. 영구 저장하지 않음 | 로그와 데이터베이스는 토큰·게이트웨이 설정과 같이 관리 대상 작업 공간 밖에 두세요. stderr 로그의 보관·순환은 운영자가 관리합니다. Bearer 토큰을 로그나 커밋에 넣지 마세요. 데이터베이스를 삭제하면 패치 중복 실행 방지 기록과 확인 홀드 행도 사라집니다. diff --git a/docs/ko/runner-isolation.md b/docs/ko/runner-isolation.md index 7aa5afc..6d2b058 100644 --- a/docs/ko/runner-isolation.md +++ b/docs/ko/runner-isolation.md @@ -13,7 +13,7 @@ `in-process`는 `codespace-mcp` 안에서 프로세스를 관리합니다. `uds`(Unix domain socket, Unix 도메인 소켓)는 같은 호스트의 `codespace-codex-runtime` 안에서 같은 관리 코드를 실행합니다. worker는 시작 시 Codex의 프로세스 보호 설정을 적용하고 전용 Unix 소켓을 엽니다. worker 자체를 보호하는 것과 실행할 명령에 샌드박스를 적용하는 것은 별개입니다. -게이트웨이는 임시 디렉터리 또는 `CODESPACE_RUNNER_DIR` 아래에 권한 0700의 고유 디렉터리를 만듭니다. 내부 통신은 u32 길이 접두부, 버전 3 핸드셰이크, 요청 ID, 프로세스 종료 이벤트를 사용하는 CodeSpace JSON입니다. Codex App Server RPC가 아닙니다. 같은 연결에서의 요청 재처리는 재접속 복구를 뜻하지 않습니다. 게이트웨이와 worker 연결이 끊기면 관리 중인 worker와 자식 프로세스가 종료되고 핸들이 사라집니다. +게이트웨이는 임시 디렉터리 또는 `CODESPACE_RUNNER_DIR` 아래에 권한 0700의 고유 디렉터리를 만듭니다. 내부 통신은 u32 길이 접두부, 버전 4 핸드셰이크, 요청 ID, 프로세스 종료 이벤트를 사용하는 CodeSpace JSON입니다. Codex App Server RPC가 아닙니다. 같은 연결에서의 요청 재처리는 재접속 복구를 뜻하지 않습니다. 게이트웨이와 worker 연결이 끊기면 관리 중인 worker와 자식 프로세스가 종료되고 핸들이 사라집니다. @@ -31,7 +31,7 @@ Runner → 관리 프로세스로 도우미 run --plan 실행 Codex 권한 변환과 샌드박스 인자는 실행 파일 전용 도우미 안에서 처리합니다. 실행 계획은 권한 0600의 비공개 파일이며 도우미가 읽어 실행에 사용합니다. Runner는 작은 프로토콜 crate에만 의존하며 샌드박스 구현 라이브러리를 직접 가져오지 않습니다. restricted 실행은 도우미 프로세스가 자신을 실행 명령으로 교체하며, enabled 실행은 도우미가 프록시를 유지하면서 샌드박스 자식 프로세스를 기다립니다. -최초 검사가 실패하면 `restricted`는 비격리 호스트 실행을 허용하며, 실행 정보에 `command_sandbox: none`과 OS 네트워크 집행 없음이 표시됩니다. `enabled`는 도우미가 없으면 실패합니다. 최초 검사가 성공한 뒤 준비·프로토콜·시작 오류가 발생하면 비격리 실행으로 대체하지 않고 `PROCESS_SPAWN_FAILED`를 반환합니다. 이미 시작된 도우미 내부의 실패는 프로세스 종료로 관측되며 현재 공개 MCP 결과에는 종료 코드가 없습니다. +최초 검사가 실패하면 `restricted`는 비격리 호스트 실행을 허용하며, 실행 정보에 `command_sandbox: none`과 OS 네트워크 집행 없음이 표시됩니다. `enabled`는 도우미가 없으면 실패합니다. 최초 검사가 성공한 뒤 준비·프로토콜·시작 오류가 발생하면 비격리 실행으로 대체하지 않고 `PROCESS_SPAWN_FAILED`를 반환합니다. 이미 시작된 도우미 내부의 실패는 `process_status`로 관측하는 프로세스 종료입니다. 그 wait 상태는 관리 자식(헬퍼 argv)의 코드이며 사용자 argv와 동일하다고 문서화하지 않습니다. ## 네트워크와 파일 접근 범위 diff --git a/docs/operations.md b/docs/operations.md index e033ead..486ce12 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -120,7 +120,7 @@ The worker runs on the same host and is not a container. The gateway creates a p | `CODESPACE_OPERATIONS_DB` unset | In-memory patch operations and instruction queue; lost on restart | | `CODESPACE_PROCESS_TIMEOUT_SECS` | Positive integer; default 30 seconds; set in the runner environment | | `CODESPACE_MAX_PROCESSES` | Default 8 live processes across the runner; workspace occupancy still applies | -| Process output | Last 256 KiB retained; stdout/stderr combined; no explicit loss flag or exit code in MCP results | +| Process output | Last 256 KiB retained; stdout/stderr combined; `read_process` reports `output_lost` and `retained_from`; `process_status` reports termination | | Completed handles | Default retention up to 15 minutes and 64 completed entries; not durable | Store logs and the database outside the managed workspace, with tokens and gateway configuration. Rotate stderr capture yourself. Do not log Bearer tokens or commit real credentials. Deleting the database also deletes patch idempotency records and confirmation-hold rows. diff --git a/docs/runner-isolation.md b/docs/runner-isolation.md index 2e2d91f..4d35696 100644 --- a/docs/runner-isolation.md +++ b/docs/runner-isolation.md @@ -10,7 +10,7 @@ There are three separate questions: which process runs the work, whether command `in-process` runs the supervisor inside `codespace-mcp`. `uds` (Unix domain socket) runs that supervisor inside `codespace-codex-runtime` on the same host. The worker starts with Codex process hardening and binds a private Unix socket. Hardening the worker does not sandbox its commands. -The gateway creates a unique 0700 directory beneath its temporary directory or `CODESPACE_RUNNER_DIR`. The internal protocol is CodeSpace JSON with a u32 length prefix, handshake version 3, request IDs, and process-exit events. It is not Codex App Server RPC. Same-connection replay is not reconnect recovery. Gateway/worker disconnect ends the owned worker and its children; process handles are lost. +The gateway creates a unique 0700 directory beneath its temporary directory or `CODESPACE_RUNNER_DIR`. The internal protocol is CodeSpace JSON with a u32 length prefix, handshake version 4, request IDs, and process-exit events. It is not Codex App Server RPC. Same-connection replay is not reconnect recovery. Gateway/worker disconnect ends the owned worker and its children; process handles are lost. @@ -27,7 +27,7 @@ Runner → managed helper run --plan Codex permission translation and sandbox arguments stay inside the binary-only helper. The plan is a private 0600 file consumed by the helper. The runner depends on the small protocol crate, not the sandbox implementation library. Restricted execution uses self-exec; enabled execution keeps a helper-owned proxy while waiting for its sandbox child. -A failed initial probe permits unsandboxed host execution for `restricted`; the contract reports `command_sandbox: none` and no OS network enforcement. `enabled` requires the helper and fails without it. Once a probe succeeds, later prepare/protocol/spawn errors never fall back to unsandboxed execution. They report `PROCESS_SPAWN_FAILED`. Failure inside an already-started helper is observed as process termination; the public MCP result currently has no exit code. +A failed initial probe permits unsandboxed host execution for `restricted`; the contract reports `command_sandbox: none` and no OS network enforcement. `enabled` requires the helper and fails without it. Once a probe succeeds, later prepare/protocol/spawn errors never fall back to unsandboxed execution. They report `PROCESS_SPAWN_FAILED`. Failure inside an already-started helper is observed as process termination through `process_status`. That wait status belongs to the managed child (helper argv) and is not documented as identical to the user argv. ## Network and filesystem scope diff --git a/docs/translations.json b/docs/translations.json index a48e944..98410b4 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -74,8 +74,8 @@ "작업-공간을-등록하고-시작하기", "제공하는-도구" ], - "source_sha256": "fbeb688c943ac78e3ae3a0441dedce9c553dd25470eadabcc037429990263c32", - "translation_sha256": "32c41ce394799ac7603f463e9ef7d5a8afab3dc61845c45eef05d03eab11e319" + "source_sha256": "55ba1a154bb3023a458c9dcbb395e465004c45073a9b623be50e42795590acc6", + "translation_sha256": "2e13cae976e4d4baa4a997c223714cf5cd6bdd4dde59ee2ce673e10cbf11cd70" }, { "id": "agent-integration", @@ -100,8 +100,8 @@ "재시도와-복구", "파일-읽기와-패치" ], - "source_sha256": "f00270e95b2bc084a9bacf7b9deeb2d9027c74f3debbd09e7f0d5fe629a157c8", - "translation_sha256": "0303b0f054ece5e2a7c4ea2c2a78e18247c578b53420ab53352a7f6e682066f5" + "source_sha256": "1686f596d2c7b19c9f557b791a0b1ff242950efca0d4cd6ac3c34730f9eb4609", + "translation_sha256": "8a4d988446b81e1fc139596ff6bd858bb26b8a48b06465d2035efda6cdbe2afe" }, { "id": "operations", @@ -152,8 +152,8 @@ "작업-공간-등록", "첫-연결-확인" ], - "source_sha256": "2b4070638407f6cf73a0c18d65edb8e8bc76d7cb0e30708ac8dab2d04fbc50ab", - "translation_sha256": "47adcf45d36bd8e054e5351b564e60d5962cec35f3a853e03450e7bed6b693de" + "source_sha256": "aefe37ed26f44ca4cc620b085df753ea5c24d03f74538b83e791a6a4d67504d2", + "translation_sha256": "a39fb9446b2270c41ff739a14acff192d864c996758dbea97cc05230b5636e5e" }, { "id": "chatgpt-connector", @@ -303,8 +303,8 @@ "확인-홀드", "훅과-스킬" ], - "source_sha256": "07174574aa201e3d86e301916a09348a0fd6a2ff47d70f45c7473cb3cd956bf4", - "translation_sha256": "24e6a8d39012ddb4e863bb243099f83c5a303335180d46262e08166d460d0f1d" + "source_sha256": "c84e0751e3fb3c7d1cd0b418caa66ff6778777437d4c29a83e93b1ff2a5a6910", + "translation_sha256": "eb6b798eea0cc1ca1ca8664878c20b364c9b3d32939a09f8bfd4b104361b65cf" }, { "id": "protocol-compatibility", @@ -445,8 +445,8 @@ "컨테이너-실험-구성과-검증", "호스트와-worker-실행" ], - "source_sha256": "c9343ac59489e008a55f90d0fbfa7a66890b03abc08c4a65729685db602c72c1", - "translation_sha256": "c7c2298fedf0ebec6d527663d03575460cc3ae83f7f1277d30ddd6a25942c530" + "source_sha256": "174e4e9586869fe059cc9fdd2560af691144f60bb654ad0cbc47d58d4dadf0f7", + "translation_sha256": "63d96306ef71b2ec08545eda3a243e01cf6a3d7a6c8de7ef907af1124188875b" }, { "id": "error-codes", @@ -474,8 +474,8 @@ "전송-실패", "전송-실패-작업-없음" ], - "source_sha256": "fdd866544ae59e232ebd64e9f24d533a2cdd6d082edb813ab195722f220e2119", - "translation_sha256": "596d66ecaf86dcb4f57a314bbb6b9abba4ec70f77a701a6272f2df64cc63b9d3" + "source_sha256": "c98cf2d562179238225427fe4c8b417849894b3895ddfc7add31022e968dfb56", + "translation_sha256": "abdf30713cfd0bd5409064356bf32e8208d370fb9f332bae7e0d2c3cb4eaee56" }, { "id": "codex-reuse", @@ -554,8 +554,8 @@ "핀-6b9826e의-후보", "핵심-대-어댑터" ], - "source_sha256": "408fed15492f21f73f9c1cf1c97ed43b4e964905db68508cad9fb3cd5f9be6dd", - "translation_sha256": "7c206a8c5f58b57460d3a35d4ae74767ca1e22b0621b27f8db17d0657036347d" + "source_sha256": "69d28fae0800ebc4837e7fc4249ad43405967c67f9ba453d8d5144a3f98ab0f8", + "translation_sha256": "7435e3a93f81e400ec084acdf6cf2f333eff56fadf39a8e66e95e5ac7b43e770" }, { "id": "upstream-lock",