diff --git a/README.ko.md b/README.ko.md index fc43ef4..200e19c 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`, `process_status`, `write_stdin`, `terminate_process` | +| 프로세스 실행과 제어 | `exec_command`, `read_process`, `process_status`, `process_resize`, `write_stdin`, `terminate_process` | | 작업과 사용자 추가 지시 관리 | `work_open`, `steer_status`, `steer_claim_next`, `steer_complete`, `work_finish` | 두 전송 방식에서 같은 도구를 사용할 수 있습니다. HTTP `/inbox` API는 사용자용 클라이언트가 지시 초안을 관리하는 JSON API입니다. 브라우저에서 사용하는 받은 편지함 화면은 제공하지 않습니다. @@ -37,8 +37,9 @@ CodeSpace는 외부 코딩 에이전트가 작업 공간의 파일을 읽고 수 - 프로세스 종료는 `process_status`로 판정하세요. `read_process`의 EOF는 성공이 아닙니다. - 프로세스 출력의 보관 크기가 제한되어 있습니다. `output_lost`가 참이면 보관 창이 전체 로그가 아닙니다. +- 실행 중인 PTY 크기는 `process_resize`로 바꿉니다. spawn 크기는 24×80이며 `tty_size`는 `exec_command` 인자가 아닙니다. - 명령이 실행 중이면 같은 작업 공간에서 다른 명령이나 패치를 실행할 수 없습니다. 개발 서버를 켜 둔 채 같은 작업 공간을 수정하는 흐름에는 제약이 있습니다. -- 서버를 재시작하면 프로세스 핸들이 사라집니다. 패치 작업 기록은 데이터베이스 경로를 설정한 경우에만 유지됩니다. +- 서버를 재시작하면 프로세스 핸들이 사라집니다. HTTP/MCP 클라이언트 끊김은 프로세스를 죽이지 않습니다. UDS worker 손실과 게이트웨이 종료는 죽입니다. 패치 작업 기록은 데이터베이스 경로를 설정한 경우에만 유지됩니다. - 컨테이너 실행과 완전한 OAuth 서버는 구현되어 있지 않습니다. 실제 ChatGPT 계정 연결도 아직 검증되지 않았습니다. diff --git a/README.md b/README.md index b2b7ff6..77befb9 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`, `process_status`, `write_stdin`, `terminate_process` | +| Run and control a process | `exec_command`, `read_process`, `process_status`, `process_resize`, `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. @@ -35,8 +35,9 @@ For agent integrations, account for these limits: - 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. +- Resize a running PTY with `process_resize`. Spawn size stays 24×80; `tty_size` is not an `exec_command` argument. - 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. +- Process handles do not survive server restart. HTTP/MCP client disconnect does not kill them. UDS worker loss and gateway shutdown do. 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. ## License diff --git a/crates/codex-runtime/Cargo.lock b/crates/codex-runtime/Cargo.lock index ce26833..b2c5b24 100644 --- a/crates/codex-runtime/Cargo.lock +++ b/crates/codex-runtime/Cargo.lock @@ -761,6 +761,8 @@ dependencies = [ name = "codespace-codex-runtime" version = "0.6.0" dependencies = [ + "codespace-domain", + "codespace-policy", "codespace-runner", "codex-process-hardening", "codex-uds", diff --git a/crates/codex-runtime/Cargo.toml b/crates/codex-runtime/Cargo.toml index 22bbe1b..89a0014 100644 --- a/crates/codex-runtime/Cargo.toml +++ b/crates/codex-runtime/Cargo.toml @@ -28,6 +28,8 @@ tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } [dev-dependencies] +codespace-domain = { path = "../domain" } +codespace-policy = { path = "../policy" } codespace-runner = { path = "../runner" } serde_json = "1" tempfile = "3" diff --git a/crates/codex-runtime/tests/runtime_binary.rs b/crates/codex-runtime/tests/runtime_binary.rs index be38151..e87fa83 100644 --- a/crates/codex-runtime/tests/runtime_binary.rs +++ b/crates/codex-runtime/tests/runtime_binary.rs @@ -3,10 +3,14 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::Arc; use std::time::Duration; +use codespace_domain::{ProcessId, Profile, WorkspaceId}; +use codespace_policy::Workspace; use codespace_runner::{ - read_frame, write_frame, RunnerOp, RunnerOpResult, WireEnvelope, WIRE_PROTOCOL, + read_frame, write_frame, Runner, RunnerExecRequest, RunnerOp, RunnerOpResult, UdsRunner, + WireEnvelope, WIRE_PROTOCOL, }; use tokio::net::UnixStream; @@ -134,8 +138,70 @@ async fn hello_on_live_socket() { match parsed.result { Some(RunnerOpResult::Hello { protocol }) => { assert_eq!(protocol, WIRE_PROTOCOL); - assert_eq!(protocol, 4); + assert_eq!(protocol, 5); } other => panic!("unexpected hello {other:?}"), } } + +#[tokio::test] +async fn worker_socket_drop_terminates_owned_child() { + let dir = tempfile::tempdir().unwrap(); + let socket = runner_socket(dir.path()); + let mut worker = spawn_worker(&socket).await; + let stream = wait_connect(&socket).await; + let runner = UdsRunner::from_stream(stream, Arc::new(|_| {})); + runner.handshake().await.expect("hello"); + + let ws_dir = tempfile::tempdir().unwrap(); + let beat = ws_dir.path().join("heartbeat"); + let ws = Workspace::new( + WorkspaceId("demo".into()), + ws_dir.path().to_path_buf(), + Profile::WorkspaceWrite, + ); + let req = RunnerExecRequest::for_host( + vec![ + "/bin/sh".into(), + "-c".into(), + format!( + "while :; do date +%s > '{}'; sleep 0.1; done", + beat.display() + ), + ], + ProcessId("proc-uds-disconnect".into()), + Profile::WorkspaceWrite, + ); + runner.exec(&ws, req).await.expect("exec heartbeat"); + + let mut last = String::new(); + for _ in 0..50 { + if let Ok(raw) = std::fs::read_to_string(&beat) { + if !raw.trim().is_empty() { + last = raw; + break; + } + } + tokio::time::sleep(Duration::from_millis(40)).await; + } + assert!( + !last.is_empty(), + "child should write a heartbeat before disconnect" + ); + + runner.close().await; + drop(runner); + let _ = tokio::time::timeout(Duration::from_secs(5), worker.wait()) + .await + .expect("worker should exit after socket close") + .expect("wait worker"); + + tokio::time::sleep(Duration::from_millis(400)).await; + let after = std::fs::read_to_string(&beat).unwrap_or_default(); + tokio::time::sleep(Duration::from_millis(400)).await; + let later = std::fs::read_to_string(&beat).unwrap_or_default(); + assert_eq!( + after, later, + "owned child must stop updating heartbeat after worker disconnect (after={after:?} later={later:?})" + ); +} diff --git a/crates/domain/src/error.rs b/crates/domain/src/error.rs index e8cf091..98e2209 100644 --- a/crates/domain/src/error.rs +++ b/crates/domain/src/error.rs @@ -24,6 +24,8 @@ pub enum ErrorCode { OperationKeyConflict, OperationNotFound, ProcessNotFound, + ProcessNotTty, + ProcessNotRunning, OutputLimit, Timeout, WorkNotFound, @@ -108,6 +110,10 @@ mod tests { assert_eq!(json, "\"INVALID_COMMAND\""); let json = serde_json::to_string(&ErrorCode::ProcessSpawnFailed).unwrap(); assert_eq!(json, "\"PROCESS_SPAWN_FAILED\""); + let json = serde_json::to_string(&ErrorCode::ProcessNotTty).unwrap(); + assert_eq!(json, "\"PROCESS_NOT_TTY\""); + let json = serde_json::to_string(&ErrorCode::ProcessNotRunning).unwrap(); + assert_eq!(json, "\"PROCESS_NOT_RUNNING\""); let json = serde_json::to_string(&ErrorCode::FileNotFound).unwrap(); assert_eq!(json, "\"FILE_NOT_FOUND\""); let json = serde_json::to_string(&ErrorCode::PathNotDirectory).unwrap(); diff --git a/crates/domain/src/execution.rs b/crates/domain/src/execution.rs index 12ec14a..54a9d5d 100644 --- a/crates/domain/src/execution.rs +++ b/crates/domain/src/execution.rs @@ -38,6 +38,40 @@ pub struct EffectivePermissionInfo { pub exec: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProcessLifetimeOwner { + Runner, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProcessDisconnectAction { + KeepRunning, + Terminate, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProcessRestartRecovery { + None, +} + +/// Advertised process-lifetime contract. The runner instance owns the +/// process, not the MCP session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProcessLifetimeInfo { + pub owner: ProcessLifetimeOwner, + /// Streamable HTTP session end, request cancel, or MCP client detach. + pub client_disconnect: ProcessDisconnectAction, + /// Gateway↔worker UDS loss. + pub runner_disconnect: ProcessDisconnectAction, + /// Gateway process exit, including stdio EOF. + pub gateway_shutdown: ProcessDisconnectAction, + /// Restart does not restore `process_id`. Lost spawn responses are not discovered. + pub restart_recovery: ProcessRestartRecovery, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct PtyCapabilityInfo { pub supported: bool, @@ -62,6 +96,7 @@ pub struct ProcessCapabilityInfo { /// master stream. pub output_combined: bool, pub tty: PtyCapabilityInfo, + pub lifetime: ProcessLifetimeInfo, } /// Static eligibility of a file operation in this workspace. @@ -193,7 +228,14 @@ impl WorkspaceExecutionInfo { default: false, initial_rows: PTY_INITIAL_ROWS, initial_cols: PTY_INITIAL_COLS, - resize_supported: false, + resize_supported: true, + }, + lifetime: ProcessLifetimeInfo { + owner: ProcessLifetimeOwner::Runner, + client_disconnect: ProcessDisconnectAction::KeepRunning, + runner_disconnect: ProcessDisconnectAction::Terminate, + gateway_shutdown: ProcessDisconnectAction::Terminate, + restart_recovery: ProcessRestartRecovery::None, }, }), }; @@ -305,8 +347,22 @@ mod tests { assert!(!caps.tty.default); assert_eq!(caps.tty.initial_rows, 24); assert_eq!(caps.tty.initial_cols, 80); - assert!(!caps.tty.resize_supported); + assert!(caps.tty.resize_supported); assert!(caps.output_combined); + assert_eq!(caps.lifetime.owner, ProcessLifetimeOwner::Runner); + assert_eq!( + caps.lifetime.client_disconnect, + ProcessDisconnectAction::KeepRunning + ); + assert_eq!( + caps.lifetime.runner_disconnect, + ProcessDisconnectAction::Terminate + ); + assert_eq!( + caps.lifetime.gateway_shutdown, + ProcessDisconnectAction::Terminate + ); + assert_eq!(caps.lifetime.restart_recovery, ProcessRestartRecovery::None); assert_eq!(exec.isolation.command_sandbox, CommandSandboxState::None); assert_eq!(exec.network.policy, NetworkPolicyState::Restricted); assert_eq!(exec.network.enforcement, NetworkEnforcementState::None); @@ -325,6 +381,30 @@ mod tests { assert_eq!(json["network"]["enforcement"], "none"); assert_eq!(json["process"]["available"], true); assert_eq!(json["process"]["capabilities"]["tty"]["supported"], true); + assert_eq!( + json["process"]["capabilities"]["tty"]["resize_supported"], + true + ); + assert_eq!( + json["process"]["capabilities"]["lifetime"]["owner"], + "runner" + ); + assert_eq!( + json["process"]["capabilities"]["lifetime"]["client_disconnect"], + "keep_running" + ); + assert_eq!( + json["process"]["capabilities"]["lifetime"]["runner_disconnect"], + "terminate" + ); + assert_eq!( + json["process"]["capabilities"]["lifetime"]["gateway_shutdown"], + "terminate" + ); + assert_eq!( + json["process"]["capabilities"]["lifetime"]["restart_recovery"], + "none" + ); } #[test] diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 5fd5ef3..a5c2ef3 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -24,7 +24,8 @@ pub use error::{ pub use execution::{ ClientEnvironmentKind, CommandSandboxState, EffectivePermissionInfo, EnvironmentExecutionInfo, FileExecutionInfo, FileOperationInfo, IsolationInfo, NetworkEnforcementState, NetworkInfo, - NetworkPolicyState, ProcessCapabilityInfo, ProcessExecutionInfo, PtyCapabilityInfo, + NetworkPolicyState, ProcessCapabilityInfo, ProcessDisconnectAction, ProcessExecutionInfo, + ProcessLifetimeInfo, ProcessLifetimeOwner, ProcessRestartRecovery, PtyCapabilityInfo, WorkspaceExecutionInfo, WorkspaceSerializationInfo, PTY_INITIAL_COLS, PTY_INITIAL_ROWS, }; pub use files::{FindParams, FindResult, ReadParams, ReadResult}; @@ -36,15 +37,16 @@ pub use patch::{ OperationEventName, OperationKind, OperationStatusParams, OperationStatusResult, PatchStatus, }; pub use process::{ - ExecCommandParams, ExecCommandResult, ExecDispatchStatus, ProcessState, ProcessStatusParams, - ProcessStatusResult, ProcessTermination, ReadProcessParams, ReadProcessResult, - TerminateProcessParams, WriteStdinParams, + ExecCommandParams, ExecCommandResult, ExecDispatchStatus, ProcessResizeParams, + ProcessResizeResult, 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_PROCESS_STATUS, TOOL_READ, TOOL_READ_PROCESS, + TOOL_OPERATION_STATUS, TOOL_PROCESS_RESIZE, 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, diff --git a/crates/domain/src/process.rs b/crates/domain/src/process.rs index 3f9d972..ff369a9 100644 --- a/crates/domain/src/process.rs +++ b/crates/domain/src/process.rs @@ -116,6 +116,22 @@ pub struct TerminateProcessParams { pub process_id: ProcessId, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProcessResizeParams { + pub process_id: ProcessId, + pub rows: u16, + pub cols: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ProcessResizeResult { + pub ok: bool, + pub rows: u16, + pub cols: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coordination: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -302,4 +318,24 @@ mod tests { assert!(!dumped.contains("tty_size"), "{dumped}"); assert!(!dumped.contains("signal"), "{dumped}"); } + + #[test] + fn process_resize_params_require_rows_and_cols() { + let schema = serde_json::to_value(schemars::schema_for!(ProcessResizeParams)).unwrap(); + let dumped = schema.to_string(); + assert!(dumped.contains("process_id"), "{dumped}"); + assert!(dumped.contains("rows"), "{dumped}"); + assert!(dumped.contains("cols"), "{dumped}"); + assert!(!dumped.contains("tty_size"), "{dumped}"); + let json = serde_json::to_value(ProcessResizeResult { + ok: true, + rows: 40, + cols: 120, + coordination: None, + }) + .unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["rows"], 40); + assert_eq!(json["cols"], 120); + } } diff --git a/crates/domain/src/tools.rs b/crates/domain/src/tools.rs index d6945a6..eb121ae 100644 --- a/crates/domain/src/tools.rs +++ b/crates/domain/src/tools.rs @@ -9,6 +9,7 @@ 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_PROCESS_RESIZE: &str = "process_resize"; pub const TOOL_TERMINATE_PROCESS: &str = "terminate_process"; pub const TOOL_OPERATION_STATUS: &str = "operation_status"; pub const TOOL_WORK_OPEN: &str = "work_open"; @@ -35,6 +36,7 @@ pub const LIVE_TOOLS: &[&str] = &[ TOOL_WRITE_STDIN, TOOL_READ_PROCESS, TOOL_PROCESS_STATUS, + TOOL_PROCESS_RESIZE, TOOL_TERMINATE_PROCESS, TOOL_WORK_OPEN, TOOL_STEER_STATUS, diff --git a/crates/pty/src/lib.rs b/crates/pty/src/lib.rs index 539791c..020cdea 100644 --- a/crates/pty/src/lib.rs +++ b/crates/pty/src/lib.rs @@ -1,5 +1,5 @@ //! Isolated PTY adapter. Wraps `codex-utils-pty` and exposes only -//! CodeSpace-owned types. Not an MCP tool. Resize stays off this API. +//! CodeSpace-owned types. Not an MCP tool. use std::collections::HashMap; use std::path::Path; @@ -34,6 +34,13 @@ impl PtySession { self.handle.has_exited() } + /// Change the PTY size in character cells. Codex types stay inside. + pub fn resize(&self, rows: u16, cols: u16) -> Result<(), String> { + self.handle + .resize(codex_utils_pty::TerminalSize { rows, cols }) + .map_err(|err| err.to_string()) + } + /// Kill the child (Unix process group) without exposing Codex signals. pub fn kill(&self) { self.handle.request_terminate(); @@ -46,8 +53,7 @@ pub const DEFAULT_ROWS: u16 = 24; pub const DEFAULT_COLS: u16 = 80; /// Spawn `program` + `args` on a PTY at [`DEFAULT_ROWS`]×[`DEFAULT_COLS`]. -/// Resize is not exposed on this API. `env` is the full environment after the -/// caller applied runner-local defaults. +/// `env` is the full environment after the caller applied runner-local defaults. pub async fn spawn( program: &str, args: &[String], @@ -82,6 +88,7 @@ pub async fn spawn( mod tests { use super::*; use std::time::Duration; + use tokio::sync::mpsc; #[test] fn crate_is_isolated_adapter() { @@ -132,4 +139,59 @@ mod tests { .expect("exit recv"); assert_eq!(code, 0, "stdin should be a TTY"); } + + async fn collect_until(output: &mut mpsc::Receiver>, needle: &str) -> String { + let mut text = String::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while tokio::time::Instant::now() < deadline { + let chunk = tokio::time::timeout(Duration::from_millis(500), output.recv()) + .await + .ok() + .flatten(); + let Some(chunk) = chunk else { + continue; + }; + text.push_str(&String::from_utf8_lossy(&chunk)); + if text.replace("\r\n", "\n").contains(needle) { + return text; + } + } + panic!("timed out waiting for {needle:?}, got {text:?}"); + } + + #[tokio::test] + async fn resize_changes_stty_size() { + let dir = tempfile::tempdir().unwrap(); + let env = HashMap::from([ + ("PATH".into(), "/usr/bin:/bin".into()), + ("HOME".into(), dir.path().display().to_string()), + ("LANG".into(), "C".into()), + ("TERM".into(), "xterm".into()), + ]); + let script = + "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\""; + let mut session = spawn("/bin/sh", &["-c".into(), script.into()], dir.path(), &env) + .await + .expect("spawn stty"); + let mut output = session.take_stdout().expect("stdout"); + let writer = session.writer(); + let start = collect_until(&mut output, "start:24 80").await; + assert!( + start.replace("\r\n", "\n").contains("start:24 80"), + "initial size, got {start:?}" + ); + session.resize(40, 120).expect("resize"); + writer.send(b"go\n".to_vec()).await.expect("write"); + let rest = collect_until(&mut output, "after:40 120").await; + let combined = format!("{start}{rest}").replace("\r\n", "\n"); + assert!( + combined.contains("after:40 120"), + "resized size, got {combined:?}" + ); + let code = tokio::time::timeout(Duration::from_secs(5), session.take_exit().unwrap()) + .await + .expect("exit timeout") + .expect("exit receiver"); + assert_eq!(code, 0); + } } diff --git a/crates/runner/src/api.rs b/crates/runner/src/api.rs index c06a30f..fb461ce 100644 --- a/crates/runner/src/api.rs +++ b/crates/runner/src/api.rs @@ -135,6 +135,12 @@ pub struct RunnerProcessStatus { pub eof: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunnerResizeResult { + pub rows: u16, + pub cols: u16, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunnerApplyPatchRequest { pub patch: String, diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index 4480349..cdd8d3b 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -116,7 +116,7 @@ pub use api::{ default_exec_timeout_ms, runner_local_exec_env, RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerCwd, RunnerError, RunnerExecEnv, RunnerExecPolicy, RunnerExecRequest, RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult, - RunnerWriteStdin, DEFAULT_TIMEOUT_MS, MAX_OUTPUT_BYTES, + RunnerResizeResult, 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; @@ -182,6 +182,12 @@ pub trait Runner: Send + Sync { &self, process_id: &ProcessId, ) -> impl std::future::Future> + Send; + fn resize( + &self, + process_id: &ProcessId, + rows: u16, + cols: u16, + ) -> impl std::future::Future> + Send; fn terminate( &self, process_id: &ProcessId, @@ -243,6 +249,16 @@ impl Runner for InProcessRunner { .map_err(RunnerError::from) } + async fn resize( + &self, + process_id: &ProcessId, + rows: u16, + cols: u16, + ) -> Result { + self.host_resize(process_id, rows, cols) + .map_err(RunnerError::from) + } + async fn terminate(&self, process_id: &ProcessId) -> Result<(), RunnerError> { self.kill_host(process_id).map_err(RunnerError::from) } @@ -337,6 +353,18 @@ impl Runner for RuntimeBackend { } } + async fn resize( + &self, + process_id: &ProcessId, + rows: u16, + cols: u16, + ) -> Result { + match self { + Self::InProcess(runner) => runner.resize(process_id, rows, cols).await, + Self::Uds(runner) => runner.resize(process_id, rows, cols).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 605c6e1..f18d4ca 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -25,7 +25,7 @@ use tokio::task::JoinHandle; use crate::{ runner_local_exec_env, RunnerCwd, RunnerExecRequest, RunnerExecResult, RunnerProcessStatus, - RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, + RunnerReadProcess, RunnerReadResult, RunnerResizeResult, RunnerWriteStdin, }; pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); @@ -594,6 +594,53 @@ impl InProcessRunner { }) } + pub fn host_resize( + &self, + process_id: &ProcessId, + rows: u16, + cols: u16, + ) -> Result { + if rows == 0 || cols == 0 { + return Err(ErrorBody::new( + ErrorCode::InvalidCommand, + "rows and cols must be at least 1", + )); + } + 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; + if snap.state != ProcessState::Running { + return Err(ErrorBody::new( + ErrorCode::ProcessNotRunning, + "process is not running", + )); + } + match &slot.io { + SessionIo::Pipe { .. } => Err(ErrorBody::new( + ErrorCode::ProcessNotTty, + "process is not attached to a PTY", + )), + SessionIo::Pty { session, .. } => { + if session.has_exited() { + return Err(ErrorBody::new( + ErrorCode::ProcessNotRunning, + "process is not running", + )); + } + session.resize(rows, cols).map_err(|err| { + ErrorBody::new( + ErrorCode::InvalidCommand, + format!("PTY resize failed: {err}"), + ) + })?; + Ok(RunnerResizeResult { rows, cols }) + } + } + } + pub fn kill_host(&self, process_id: &ProcessId) -> Result<(), ErrorBody> { let mut map = self.inner.lock().expect("runner"); self.evict_completed(&mut map); @@ -1283,6 +1330,122 @@ mod tests { ); } + #[tokio::test] + async fn pty_resize_updates_stty_size() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-pty-resize".into()); + let mut req = RunnerExecRequest::for_host( + vec![ + "/bin/sh".into(), + "-c".into(), + "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\"".into(), + ], + process_id.clone(), + Profile::WorkspaceWrite, + ); + req.tty = true; + runner.exec(&ws, req).await.unwrap(); + let mut chunk = String::new(); + for _ in 0..50 { + let result = runner + .read_process(RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + chunk = result.chunk.replace("\r\n", "\n"); + if chunk.contains("start:24 80") { + break; + } + tokio::time::sleep(Duration::from_millis(40)).await; + } + assert!( + chunk.contains("start:24 80"), + "initial PTY size, got {chunk:?}" + ); + let resized = runner.resize(&process_id, 40, 120).await.expect("resize"); + assert_eq!(resized.rows, 40); + assert_eq!(resized.cols, 120); + runner + .write_stdin(RunnerWriteStdin { + process_id: process_id.clone(), + data: "go\n".into(), + }) + .await + .unwrap(); + chunk = wait_chunk(&runner, &process_id).await.replace("\r\n", "\n"); + assert!( + chunk.contains("after:40 120"), + "resized PTY size, got {chunk:?}" + ); + } + + #[tokio::test] + async fn pipe_resize_is_process_not_tty() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-pipe-resize".into()); + runner + .exec( + &ws, + RunnerExecRequest::for_host( + vec!["/bin/sleep".into(), "30".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ), + ) + .await + .unwrap(); + let err = runner.resize(&process_id, 40, 120).await.unwrap_err(); + assert_eq!( + err.as_execution().map(|body| body.code), + Some(ErrorCode::ProcessNotTty) + ); + runner.kill_host(&process_id).unwrap(); + let _ = wait_exited(&runner, &process_id).await; + } + + #[tokio::test] + async fn resize_unknown_and_exited_handles() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let missing = runner + .resize(&ProcessId("proc-missing-resize".into()), 24, 80) + .await + .unwrap_err(); + assert_eq!( + missing.as_execution().map(|body| body.code), + Some(ErrorCode::ProcessNotFound) + ); + let zero = runner + .resize(&ProcessId("proc-missing-resize".into()), 0, 80) + .await + .unwrap_err(); + assert_eq!( + zero.as_execution().map(|body| body.code), + Some(ErrorCode::InvalidCommand) + ); + let process_id = ProcessId("proc-exited-resize".into()); + let mut req = RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "done".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ); + req.tty = true; + runner.exec(&ws, req).await.unwrap(); + let _ = wait_exited(&runner, &process_id).await; + let err = runner.resize(&process_id, 40, 120).await.unwrap_err(); + assert_eq!( + err.as_execution().map(|body| body.code), + Some(ErrorCode::ProcessNotRunning) + ); + } + #[tokio::test] async fn terminate_reaps_sandboxed_sleep() { if !crate::linux_sandbox_available() { diff --git a/crates/runner/src/uds.rs b/crates/runner/src/uds.rs index ea5df28..a3b1ffc 100644 --- a/crates/runner/src/uds.rs +++ b/crates/runner/src/uds.rs @@ -26,8 +26,8 @@ use crate::wire::{ }; use crate::{ Runner, RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerError, RunnerExecRequest, - RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult, RunnerWriteStdin, - ShellRelease, + RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult, RunnerResizeResult, + RunnerWriteStdin, ShellRelease, }; /// Transport deadline for one RPC. Longer than the default exec timeout @@ -398,6 +398,25 @@ impl Runner for UdsRunner { } } + async fn resize( + &self, + process_id: &ProcessId, + rows: u16, + cols: u16, + ) -> Result { + match self + .call(RunnerOp::Resize { + process_id: process_id.clone(), + rows, + cols, + }) + .await? + { + RunnerOpResult::Resize(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 ada7db7..238b8db 100644 --- a/crates/runner/src/wire.rs +++ b/crates/runner/src/wire.rs @@ -16,10 +16,10 @@ use tokio::sync::{mpsc, Mutex}; use crate::{ InProcessRunner, Runner, RunnerApplyPatchRequest, RunnerApplyPatchResult, RunnerError, RunnerExecRequest, RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult, - RunnerWriteStdin, ShellRelease, + RunnerResizeResult, RunnerWriteStdin, ShellRelease, }; -pub const WIRE_PROTOCOL: u32 = 4; +pub const WIRE_PROTOCOL: u32 = 5; const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; const MAX_REPLAY: usize = 32; @@ -140,6 +140,11 @@ pub enum RunnerOp { ProcessStatus { process_id: ProcessId, }, + Resize { + process_id: ProcessId, + rows: u16, + cols: u16, + }, Terminate { process_id: ProcessId, }, @@ -166,6 +171,7 @@ pub enum RunnerOpResult { WriteStdin, ReadProcess(RunnerReadResult), ProcessStatus(RunnerProcessStatus), + Resize(RunnerResizeResult), Terminate, WorkspaceOf(Option), TerminateWorkspace(u32), @@ -349,6 +355,14 @@ async fn dispatch(runner: &InProcessRunner, op: RunnerOp) -> Result runner + .resize(&process_id, rows, cols) + .await + .map(RunnerOpResult::Resize), RunnerOp::Terminate { process_id } => runner .terminate(&process_id) .await @@ -379,8 +393,8 @@ mod tests { } #[test] - fn wire_protocol_is_v4() { - assert_eq!(WIRE_PROTOCOL, 4); + fn wire_protocol_is_v5() { + assert_eq!(WIRE_PROTOCOL, 5); } #[tokio::test] @@ -504,6 +518,25 @@ mod tests { assert!(parsed.error.is_some()); } + #[tokio::test] + async fn protocol_4_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 = 4; + 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 06032ae..d553202 100644 --- a/crates/runner/tests/uds_runner.rs +++ b/crates/runner/tests/uds_runner.rs @@ -112,7 +112,7 @@ async fn uds_runner_read_and_exec_over_length_prefix() { } #[tokio::test] -async fn uds_process_status_echo_and_protocol_4_hello() { +async fn uds_process_status_echo_and_protocol_5_hello() { let (client, server) = UnixStream::pair().expect("unix pair"); let (worker, events) = host_worker(); tokio::spawn(async move { @@ -121,7 +121,7 @@ async fn uds_process_status_echo_and_protocol_4_hello() { .expect("serve runner"); }); let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); - runner.handshake().await.expect("hello protocol 4"); + runner.handshake().await.expect("hello protocol 5"); let dir = tempdir().unwrap(); let ws = workspace(dir.path()); let process_id = ProcessId("proc-uds-status".into()); @@ -376,6 +376,79 @@ async fn uds_tty_exec_sees_a_tty() { ); } +#[tokio::test] +async fn uds_pty_resize_updates_stty_size() { + let (client, server) = UnixStream::pair().expect("unix pair"); + let (worker, events) = host_worker(); + tokio::spawn(async move { + serve_runner_connection(server, worker, events) + .await + .expect("serve runner"); + }); + let runner = UdsRunner::from_stream(client, Arc::new(|_| {})); + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let process_id = ProcessId("proc-uds-resize".into()); + let mut req = RunnerExecRequest::for_host( + vec![ + "/bin/sh".into(), + "-c".into(), + "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\"".into(), + ], + process_id.clone(), + Profile::WorkspaceWrite, + ); + req.tty = true; + runner.exec(&ws, req).await.unwrap(); + let mut chunk = String::new(); + for _ in 0..50 { + let result = runner + .read_process(codespace_runner::RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + chunk = result.chunk.replace("\r\n", "\n"); + if chunk.contains("start:24 80") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + } + assert!( + chunk.contains("start:24 80"), + "UDS initial PTY size, got {chunk:?}" + ); + let resized = runner.resize(&process_id, 40, 120).await.unwrap(); + assert_eq!(resized.rows, 40); + assert_eq!(resized.cols, 120); + runner + .write_stdin(codespace_runner::RunnerWriteStdin { + process_id: process_id.clone(), + data: "go\n".into(), + }) + .await + .unwrap(); + for _ in 0..50 { + let result = runner + .read_process(codespace_runner::RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + chunk = result.chunk.replace("\r\n", "\n"); + if chunk.contains("after:40 120") || result.eof { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + } + assert!( + chunk.contains("after:40 120"), + "UDS resized PTY size, got {chunk:?}" + ); +} + fn execution_code(err: &RunnerError) -> ErrorCode { match err { RunnerError::Execution(body) => body.code, diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 9fec30c..0dfc5d9 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, ProcessStatusParams, - ProcessStatusResult, ReadParams, ReadProcessParams, ReadProcessResult, ReadResult, - SteerClaimNextResult, SteerCompleteParams, SteerStatusResult, TerminateProcessParams, - WorkFinishResult, WorkId, WorkIdParams, WorkOpenParams, WorkOpenResult, WorkspaceExecutionInfo, - WorkspaceInfo, WorkspaceInfoParams, WriteStdinParams, + OperationStatusParams, OperationStatusResult, PatchStatus, ProcessId, ProcessResizeParams, + ProcessResizeResult, 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, @@ -65,7 +65,8 @@ continue, but apply_patch or another exec_command may return WORKSPACE_BUSY \ until the process exits or is terminated. exec_command.tty is optional and defaults to false. tty=true attaches a \ -fixed 24x80 PTY. PTY resize is not currently supported. Use tty=true only \ +fixed 24x80 PTY. Use process_resize on a running PTY to change rows and \ +cols. tty_size is not an exec_command argument. Use tty=true only \ when the command requires terminal semantics or an interactive TUI. Executable workspaces currently use host execution. When \ @@ -88,9 +89,9 @@ 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, process_status, 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, process_resize, 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. \ @@ -102,8 +103,18 @@ 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. +Managed process lifetime is owned by the runner instance, not the MCP \ +session. Client or HTTP disconnect keeps the process running. Losing the \ +UDS worker connection or shutting down the gateway terminates the owned \ +subtree and drops handles. Restart does not recover process_id. If you lose \ +the spawn response before receiving process_id, do not search for the lost \ +handle and do not start a duplicate command. + +process_resize requires a running PTY-backed process. Pipe processes return \ +PROCESS_NOT_TTY. An exited handle returns PROCESS_NOT_RUNNING. A missing \ +handle returns PROCESS_NOT_FOUND. + +tty_size is not an exec_command argument. Claim user intents only at major checkpoints and before work_finish."; @@ -240,7 +251,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, 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." + 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 24x80 PTY; use process_resize to change the size of a running PTY. tty_size is not an exec_command argument. 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, process_resize, 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, process_resize, 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, @@ -337,6 +348,27 @@ impl CodeSpace { })) } + #[tool( + name = "process_resize", + description = "Change the PTY size of a running managed process. Requires tty=true spawn. rows and cols must be at least 1. Pipe-backed processes return PROCESS_NOT_TTY. An exited handle returns PROCESS_NOT_RUNNING. Unknown process_id is rejected." + )] + async fn process_resize( + &self, + Parameters(params): Parameters, + ) -> Result, String> { + let result = self + .runner + .resize(¶ms.process_id, params.rows, params.cols) + .await + .map_err(runner_err_json)?; + Ok(Json(ProcessResizeResult { + ok: true, + rows: result.rows, + cols: result.cols, + coordination: self.process_hint(¶ms.process_id.0).await, + })) + } + #[tool( name = "terminate_process", description = "Terminate a managed process. Only server-minted process_id values are accepted." @@ -1028,10 +1060,14 @@ mod tests { "{text}" ); assert!(text.contains("fixed 24x80 PTY"), "{text}"); + assert!(text.contains("process_resize"), "{text}"); + assert!(text.contains("owned by the runner instance"), "{text}"); assert!( - text.contains("PTY resize is not currently supported"), + text.contains("Client or HTTP disconnect keeps the process running"), "{text}" ); + assert!(text.contains("PROCESS_NOT_TTY"), "{text}"); + assert!(text.contains("PROCESS_NOT_RUNNING"), "{text}"); assert!(text.contains("WORKSPACE_BUSY"), "{text}"); assert!(text.contains("command_sandbox is linux-sandbox"), "{text}"); assert!( @@ -1124,9 +1160,25 @@ mod tests { .expect("capabilities") .tty; assert!(tty.supported); - assert!(!tty.resize_supported); + assert!(tty.resize_supported); assert_eq!(exec.network.policy, NetworkPolicyState::Restricted); let json = serde_json::to_value(&info).unwrap(); + assert_eq!( + json["execution"]["process"]["capabilities"]["lifetime"]["owner"], + "runner" + ); + assert_eq!( + json["execution"]["process"]["capabilities"]["lifetime"]["client_disconnect"], + "keep_running" + ); + assert_eq!( + json["execution"]["process"]["capabilities"]["lifetime"]["runner_disconnect"], + "terminate" + ); + assert_eq!( + json["execution"]["process"]["capabilities"]["lifetime"]["restart_recovery"], + "none" + ); assert!(json.get("environment_id").is_none()); assert!(!json.to_string().contains("\"environment_id\"")); } diff --git a/crates/server/tests/process.rs b/crates/server/tests/process.rs index 9e8da09..b95d97a 100644 --- a/crates/server/tests/process.rs +++ b/crates/server/tests/process.rs @@ -1,6 +1,7 @@ use codespace_domain::{ - 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, + LIVE_TOOLS, TOOL_APPLY_PATCH, TOOL_EXEC_COMMAND, TOOL_FIND, TOOL_PROCESS_RESIZE, + 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; @@ -667,6 +668,10 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { names.contains(&TOOL_PROCESS_STATUS), "LIVE_TOOLS must include process_status, got {names:?}" ); + assert!( + names.contains(&TOOL_PROCESS_RESIZE), + "LIVE_TOOLS must include process_resize, got {names:?}" + ); let status_tool = tools .iter() @@ -683,6 +688,29 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { "process_status result schema must include output_total: {status_dumped}" ); + let resize_tool = tools + .iter() + .find(|tool| tool.name.as_ref() == TOOL_PROCESS_RESIZE) + .expect("process_resize"); + let resize_in = serde_json::to_value(&resize_tool.input_schema).unwrap(); + let resize_in_dumped = resize_in.to_string(); + assert!( + resize_in_dumped.contains("process_id"), + "process_resize input must include process_id: {resize_in_dumped}" + ); + assert!( + resize_in_dumped.contains("rows") && resize_in_dumped.contains("cols"), + "process_resize input must include rows and cols: {resize_in_dumped}" + ); + let resize_out = serde_json::to_value(resize_tool.output_schema.as_ref()).unwrap(); + let resize_out_dumped = resize_out.to_string(); + assert!( + resize_out_dumped.contains("\"ok\"") + && resize_out_dumped.contains("rows") + && resize_out_dumped.contains("cols"), + "process_resize result schema must include ok, rows, cols: {resize_out_dumped}" + ); + let exec = tools .iter() .find(|tool| tool.name.as_ref() == TOOL_EXEC_COMMAND) @@ -762,7 +790,27 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { assert_eq!(exec["process"]["capabilities"]["tty"]["supported"], true); assert_eq!( exec["process"]["capabilities"]["tty"]["resize_supported"], - false + true + ); + assert_eq!( + exec["process"]["capabilities"]["lifetime"]["owner"], + "runner" + ); + assert_eq!( + exec["process"]["capabilities"]["lifetime"]["client_disconnect"], + "keep_running" + ); + assert_eq!( + exec["process"]["capabilities"]["lifetime"]["runner_disconnect"], + "terminate" + ); + assert_eq!( + exec["process"]["capabilities"]["lifetime"]["gateway_shutdown"], + "terminate" + ); + assert_eq!( + exec["process"]["capabilities"]["lifetime"]["restart_recovery"], + "none" ); if codespace_runner::linux_sandbox_available() { assert_eq!(exec["isolation"]["command_sandbox"], "linux-sandbox"); @@ -821,3 +869,201 @@ async fn exec_tty_true_sees_a_tty() { .await; client.cancel().await.expect("cancel"); } + +#[tokio::test] +async fn process_resize_pty_roundtrip_and_error_codes() { + let (_root, cfg) = write_workspace("workspace-write"); + let client = spawn_client(&cfg, false, &[]).await; + let started = client + .call_tool( + CallToolRequestParams::new(TOOL_EXEC_COMMAND).with_arguments(object!({ + "workspace_id": "demo", + "command": [ + "/bin/sh", + "-c", + "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\"" + ], + "tty": true + })), + ) + .await + .expect("exec tty resize"); + let pid = payload(&started)["process_id"] + .as_str() + .unwrap() + .to_string(); + let mut chunk = String::new(); + for _ in 0..50 { + let read = client + .call_tool( + CallToolRequestParams::new(TOOL_READ_PROCESS) + .with_arguments(object!({ "process_id": pid, "cursor": 0 })), + ) + .await + .expect("read"); + chunk = payload(&read)["chunk"] + .as_str() + .unwrap_or("") + .replace("\r\n", "\n"); + if chunk.contains("start:24 80") { + break; + } + sleep(Duration::from_millis(40)).await; + } + assert!( + chunk.contains("start:24 80"), + "initial PTY size, got {chunk:?}" + ); + let resized = client + .call_tool( + CallToolRequestParams::new(TOOL_PROCESS_RESIZE).with_arguments(object!({ + "process_id": pid, + "rows": 40, + "cols": 120 + })), + ) + .await + .expect("process_resize"); + let body = payload(&resized); + assert_eq!(body["ok"], true); + assert_eq!(body["rows"], 40); + assert_eq!(body["cols"], 120); + let _ = client + .call_tool( + CallToolRequestParams::new(TOOL_WRITE_STDIN) + .with_arguments(object!({ "process_id": pid, "data": "go\n" })), + ) + .await + .expect("write go"); + for _ in 0..50 { + let read = client + .call_tool( + CallToolRequestParams::new(TOOL_READ_PROCESS) + .with_arguments(object!({ "process_id": pid, "cursor": 0 })), + ) + .await + .expect("read after resize"); + chunk = payload(&read)["chunk"] + .as_str() + .unwrap_or("") + .replace("\r\n", "\n"); + if chunk.contains("after:40 120") || payload(&read)["eof"] == true { + break; + } + sleep(Duration::from_millis(40)).await; + } + assert!( + chunk.contains("after:40 120"), + "resized PTY size, got {chunk:?}" + ); + for _ in 0..50 { + let status = client + .call_tool( + CallToolRequestParams::new(TOOL_PROCESS_STATUS) + .with_arguments(object!({ "process_id": pid })), + ) + .await + .expect("status"); + if payload(&status)["state"] == "exited" { + break; + } + sleep(Duration::from_millis(40)).await; + } + + let missing = client + .call_tool( + CallToolRequestParams::new(TOOL_PROCESS_RESIZE).with_arguments(object!({ + "process_id": "proc-invented", + "rows": 24, + "cols": 80 + })), + ) + .await; + let missing_text = err_text(&missing); + assert!(missing_text.contains("PROCESS_NOT_FOUND"), "{missing_text}"); + + let pipe = client + .call_tool( + CallToolRequestParams::new(TOOL_EXEC_COMMAND).with_arguments(object!({ + "workspace_id": "demo", + "command": ["/bin/sleep", "30"] + })), + ) + .await + .expect("pipe sleep"); + let pipe_pid = payload(&pipe)["process_id"].as_str().unwrap().to_string(); + let not_tty = client + .call_tool( + CallToolRequestParams::new(TOOL_PROCESS_RESIZE).with_arguments(object!({ + "process_id": pipe_pid, + "rows": 24, + "cols": 80 + })), + ) + .await; + let not_tty_text = err_text(¬_tty); + assert!(not_tty_text.contains("PROCESS_NOT_TTY"), "{not_tty_text}"); + let _ = client + .call_tool( + CallToolRequestParams::new(TOOL_TERMINATE_PROCESS) + .with_arguments(object!({ "process_id": pipe_pid })), + ) + .await; + for _ in 0..50 { + let status = client + .call_tool( + CallToolRequestParams::new(TOOL_PROCESS_STATUS) + .with_arguments(object!({ "process_id": pipe_pid })), + ) + .await + .expect("pipe status"); + if payload(&status)["state"] == "exited" { + break; + } + sleep(Duration::from_millis(40)).await; + } + + let finished = client + .call_tool( + CallToolRequestParams::new(TOOL_EXEC_COMMAND).with_arguments(object!({ + "workspace_id": "demo", + "command": ["/bin/echo", "done"], + "tty": true + })), + ) + .await + .expect("tty true exit"); + let finished_pid = payload(&finished)["process_id"] + .as_str() + .unwrap() + .to_string(); + for _ in 0..50 { + let status = client + .call_tool( + CallToolRequestParams::new(TOOL_PROCESS_STATUS) + .with_arguments(object!({ "process_id": finished_pid })), + ) + .await + .expect("status"); + if payload(&status)["state"] == "exited" { + break; + } + sleep(Duration::from_millis(40)).await; + } + let not_running = client + .call_tool( + CallToolRequestParams::new(TOOL_PROCESS_RESIZE).with_arguments(object!({ + "process_id": finished_pid, + "rows": 24, + "cols": 80 + })), + ) + .await; + let not_running_text = err_text(¬_running); + assert!( + not_running_text.contains("PROCESS_NOT_RUNNING"), + "{not_running_text}" + ); + + client.cancel().await.expect("cancel"); +} diff --git a/crates/server/tests/protocol_compat.rs b/crates/server/tests/protocol_compat.rs index 5eb1c34..185edb1 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_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, + TOOL_OPERATION_STATUS, TOOL_PROCESS_RESIZE, 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}; @@ -61,6 +61,10 @@ fn assert_live_tools(names: impl IntoIterator>) { names.iter().any(|n| n == TOOL_PROCESS_STATUS), "tools/list must include process_status, got {names:?}" ); + assert!( + names.iter().any(|n| n == TOOL_PROCESS_RESIZE), + "tools/list must include process_resize, got {names:?}" + ); assert_eq!(names, expected); } @@ -288,6 +292,35 @@ async fn connect_http( .expect("http initialize") } +#[tokio::test] +async fn http_forced_2025_11_25_process_resize_caps() { + let (_root, addr, _store) = spawn_http_workspace().await; + let client = connect_http(addr, ProtocolVersion::V_2025_11_25, lifecycle_1125()).await; + let tools = client.list_all_tools().await.expect("tools/list"); + assert_live_tools(tools.iter().map(|t| t.name.as_ref())); + assert!(tools.iter().any(|t| t.name.as_ref() == TOOL_PROCESS_RESIZE)); + let body = payload( + &client + .call_tool( + CallToolRequestParams::new(TOOL_WORKSPACE_INFO) + .with_arguments(object!({ "workspace_id": "demo" })), + ) + .await + .expect("workspace_info"), + ); + let tty = &body["execution"]["process"]["capabilities"]["tty"]; + assert_eq!(tty["resize_supported"], true); + assert_eq!(tty["initial_rows"], 24); + assert_eq!(tty["initial_cols"], 80); + let lifetime = &body["execution"]["process"]["capabilities"]["lifetime"]; + assert_eq!(lifetime["owner"], "runner"); + assert_eq!(lifetime["client_disconnect"], "keep_running"); + assert_eq!(lifetime["runner_disconnect"], "terminate"); + assert_eq!(lifetime["gateway_shutdown"], "terminate"); + assert_eq!(lifetime["restart_recovery"], "none"); + client.cancel().await.expect("cancel http"); +} + #[tokio::test] async fn stdio_forced_2025_11_25_read_patch_exec() { let (_root, cfg, db) = write_workspace(); diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 063afc2..f5783f5 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -71,7 +71,7 @@ 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. The size is fixed at 24×80. `tty_size` is not an `exec_command` argument, and there is no resize tool. +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. Spawn size is 24×80. `tty_size` is not an `exec_command` argument. Change the size of a running PTY with `process_resize`. 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`. @@ -96,7 +96,16 @@ Each result has `chunk`, `cursor`, `eof`, `output_lost`, and `retained_from`. Ou } ``` - +```json +{ + "name": "process_resize", + "arguments": { + "process_id": "PROCESS_ID_FROM_EXEC", + "rows": 40, + "cols": 120 + } +} +``` These example result bodies illustrate the command above. IDs are placeholders: use the values from your own responses. The examples omit the MCP envelope and show a call without coordination context. @@ -141,6 +150,8 @@ Interactive input and cancellation use the same handle: A live command occupies the workspace. Wait for it to end or terminate it before applying a patch or starting another command. Reads and searches remain available. Long-lived development servers therefore require a workflow that stops them before edits. +`workspace_info.execution.process.capabilities.lifetime` advertises the owner: the runner instance, not the MCP session. Streamable HTTP or MCP client disconnect keeps the process running; reconnect with the saved `process_id`. Losing the UDS worker connection or shutting down the gateway (including stdio EOF) terminates the owned subtree and drops handles. Restart does not restore `process_id`. If the spawn response is lost before you receive `process_id`, do not search with `process_status` and do not start a duplicate command. + ## Confirm a held mutation Default workspaces run allowed patches and commands immediately. If the operator set `approvals` to `confirm`, those tools return `APPROVAL_REQUIRED` and an `approval_id` instead of writing or spawning. This is a workflow pause, not a privilege grant, isolation boundary, or a way to raise `read-only` to write or exec. Extra arguments such as `approved: true` or `network: true` do not grant rights. The same MCP caller can grant the hold. @@ -178,6 +189,8 @@ Default workspaces run allowed patches and commands immediately. If the operator | `OPERATION_KEY_CONFLICT` | The key belongs to different arguments; inspect the earlier request | | Patch `unknown` or `failed_partial` | Inspect affected files and report uncertainty before deciding on a new operation | | Exec `dispatch_status: unknown` | A process may exist. Inspect or terminate the returned handle if reachable; do not blindly start another | +| Lost spawn response / no `process_id` | Do not invent or search for a handle. Do not start a duplicate; the process may still occupy the workspace | +| Client/HTTP session lost after spawn | Process keeps running. Reconnect and use the saved `process_id` | | `WORKSPACE_BUSY` | Wait for the owning task or cancel the process; avoid a tight retry loop | | `TIMEOUT` | Treat execution as interrupted; inspect partial effects | | Server/worker lost | Reconnect and inspect capabilities/files; old process handles are not recoverable | diff --git a/docs/codex-reuse.md b/docs/codex-reuse.md index 925431e..2bf42fa 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 4 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 5 and sandbox-helper protocol version 1 are separate contracts. diff --git a/docs/error-codes.md b/docs/error-codes.md index e061618..f6a2561 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -38,6 +38,8 @@ Errors use uppercase identifiers and a message. An `operation_id` may be present | `OPERATION_KEY_CONFLICT` | Same patch key was used with different arguments | | `OPERATION_NOT_FOUND` | Unknown lookup ID/key, or not exactly one identifier supplied | | `PROCESS_NOT_FOUND` | Process handle missing, expired, or stdin already closed on write | +| `PROCESS_NOT_TTY` | `process_resize` on a pipe-backed (`tty: false`) process | +| `PROCESS_NOT_RUNNING` | `process_resize` on a handle that exists but is not running | | `OUTPUT_LIMIT` | Helper output exceeded its bound; process reads instead discard old bytes | | `TIMEOUT` | A managed command or helper exceeded its time limit | | `WORK_NOT_FOUND` | Unknown logical work | diff --git a/docs/execution-substrate.md b/docs/execution-substrate.md index 7fa5040..af7048b 100644 --- a/docs/execution-substrate.md +++ b/docs/execution-substrate.md @@ -37,13 +37,13 @@ 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. `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. +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 spawn-time terminal option. `tty_size` is not a spawn argument. A running PTY is resized with `process_resize`. 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. +`exec_command` returns dispatch identity (`process_id`, `dispatch_status`). `process_status` reports `running` or `exited` plus termination metadata. `process_resize` changes the size of a running PTY. `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. Pipe-backed resize is `PROCESS_NOT_TTY`; an exited handle is `PROCESS_NOT_RUNNING`. 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. -UDS transport and Linux sandbox preparation have distinct protocols and failure boundaries. A partially delivered UDS mutation may yield an uncertain result; never retry it as a new mutation merely because the connection failed. The complete process and isolation rules belong in [runner isolation](runner-isolation.md). +UDS transport and Linux sandbox preparation have distinct protocols and failure boundaries. A partially delivered UDS mutation may yield an uncertain result; never retry it as a new mutation merely because the connection failed. Process lifetime is owned by the runner instance: MCP/HTTP client disconnect keeps the process running, while UDS gateway↔worker loss or gateway shutdown terminates the owned subtree. There is no durable process recovery. The complete process and isolation rules belong in [runner isolation](runner-isolation.md). @@ -69,7 +69,7 @@ When the operator sets workspace `approvals` to `confirm`, a policy-allowed `app ## What remains unimplemented -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. +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 4d6bf2f..705d183 100644 --- a/docs/ko/agent-integration.md +++ b/docs/ko/agent-integration.md @@ -71,7 +71,7 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 } ``` -명령은 인자 배열입니다. 셸을 명시적으로 실행하지 않는 한 셸 따옴표, 파이프, `&&`는 해석되지 않습니다. 작업 디렉터리는 작업 공간 루트이며 환경변수와 제한 시간은 운영자 설정을 따릅니다. 터미널이 필요한 프로그램은 `"tty": true`로 가상 터미널(PTY)을 할당합니다. 크기는 24×80으로 고정됩니다. `tty_size`는 `exec_command` 인자가 아니며, 크기 변경 도구는 없습니다. +명령은 인자 배열입니다. 셸을 명시적으로 실행하지 않는 한 셸 따옴표, 파이프, `&&`는 해석되지 않습니다. 작업 디렉터리는 작업 공간 루트이며 환경변수와 제한 시간은 운영자 설정을 따릅니다. 터미널이 필요한 프로그램은 `"tty": true`로 가상 터미널(PTY)을 할당합니다. spawn 크기는 24×80입니다. `tty_size`는 `exec_command` 인자가 아닙니다. 실행 중인 PTY 크기는 `process_resize`로 바꿉니다. 응답에는 서버가 발급한 `process_id`와 `dispatch_status`가 있습니다. `confirmed`는 실행 요청이 확인되었다는 뜻이며 **명령의 성공을 뜻하지 않습니다**. ID를 저장한 뒤 출력을 `read_process`로 조회하고, 종료는 `process_status`로 판정하세요. 출력 조회 시 매번 반환된 커서를 다음 조회에 사용합니다. @@ -96,7 +96,16 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 } ``` - +```json +{ + "name": "process_resize", + "arguments": { + "process_id": "PROCESS_ID_FROM_EXEC", + "rows": 40, + "cols": 120 + } +} +``` 다음은 위 명령의 결과를 해석하는 예시입니다. ID는 설명용이며 실제 응답의 값을 사용해야 합니다. `coordination`이 없는 호출의 도구 결과 본문만 표시했습니다. @@ -141,6 +150,8 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 실행 중인 명령은 작업 공간을 점유합니다. 패치를 적용하거나 다른 명령을 시작하려면 기존 명령이 끝날 때까지 기다리거나 종료하세요. 읽기와 검색은 계속 가능합니다. 개발 서버를 오래 실행하는 작업 흐름이라면 수정 전에 서버를 멈추는 절차가 필요합니다. +`workspace_info.execution.process.capabilities.lifetime`은 소유자가 MCP 세션이 아니라 러너 인스턴스임을 알립니다. Streamable HTTP나 MCP 클라이언트 끊김은 프로세스를 유지하므로, 저장한 `process_id`로 다시 연결하면 됩니다. UDS worker 연결이 끊기거나 게이트웨이가 종료되면(stdio EOF 포함) 소유 서브트리가 종료되고 핸들이 사라집니다. 재시작은 `process_id`를 복구하지 않습니다. spawn 응답을 받기 전에 `process_id`를 잃으면 `process_status`로 찾지 말고 명령을 중복 시작하지 마세요. + ## 보류된 변경 확인 기본 작업 공간에서는 허용된 패치와 명령이 바로 실행됩니다. 운영자가 `approvals`를 `confirm`으로 두면 해당 도구는 디스크에 쓰거나 프로세스를 만들지 않고 `APPROVAL_REQUIRED`와 `approval_id`를 반환합니다. 워크플로 일시정지이며 권한 부여나 격리 경계가 아니고, `read-only`를 쓰기·실행으로 올리는 방법도 아닙니다. `approved: true`나 `network: true` 같은 추가 인자도 권한을 주지 않습니다. 같은 MCP 호출자가 홀드를 grant할 수 있습니다. @@ -178,6 +189,8 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 | `OPERATION_KEY_CONFLICT` | 다른 인자에 사용된 키이므로 이전 요청 확인 | | 패치 `unknown` 또는 `failed_partial` | 해당 파일을 확인하고 불확실성을 보고한 뒤 새 작업 여부 판단 | | 실행 `dispatch_status: unknown` | 프로세스가 존재할 수 있음. 연결 가능하면 해당 핸들을 조회·종료하고 무조건 재실행하지 않기 | +| spawn 응답 유실 / `process_id` 없음 | 핸들을 만들어 내거나 검색하지 않기. 중복 시작하지 않기. 프로세스가 작업 공간을 점유 중일 수 있음 | +| spawn 이후 클라이언트·HTTP 세션 끊김 | 프로세스는 계속 실행됨. 재연결 후 저장한 `process_id` 사용 | | `WORKSPACE_BUSY` | 점유 중인 작업을 기다리거나 프로세스 취소. 빠른 반복 재시도 피하기 | | `TIMEOUT` | 실행이 중단된 것으로 처리하고 일부 변경이 남았는지 확인 | | 서버·worker 연결 손실 | 재연결 후 기능과 파일 상태 확인. 기존 프로세스 핸들은 복구되지 않음 | diff --git a/docs/ko/codex-reuse.md b/docs/ko/codex-reuse.md index 7e4d9ff..9155b78 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 프로토콜 버전 4와 샌드박스 도우미 프로토콜 버전 1은 별개의 계약입니다. +Linux 샌드박스 도우미는 실행 파일만 제공합니다. `codespace-linux-sandbox-protocol`에는 CodeSpace가 정의한 핸드셰이크 데이터만 있고 Codex 타입은 없습니다. worker의 UDS 프로토콜 버전 5와 샌드박스 도우미 프로토콜 버전 1은 별개의 계약입니다. diff --git a/docs/ko/error-codes.md b/docs/ko/error-codes.md index a99b456..fb2e130 100644 --- a/docs/ko/error-codes.md +++ b/docs/ko/error-codes.md @@ -41,6 +41,8 @@ | `OPERATION_KEY_CONFLICT` | 같은 패치 키를 다른 인자에 사용함 | | `OPERATION_NOT_FOUND` | 조회 ID·키가 없거나 조회 식별자를 정확히 하나 지정하지 않음 | | `PROCESS_NOT_FOUND` | 프로세스 핸들이 없거나 만료됨. 입력 시 stdin이 이미 닫힌 경우도 포함 | +| `PROCESS_NOT_TTY` | 파이프(`tty: false`) 프로세스에 `process_resize`를 호출함 | +| `PROCESS_NOT_RUNNING` | 핸들은 있으나 실행 중이 아닌 프로세스에 `process_resize`를 호출함 | | `OUTPUT_LIMIT` | 도우미 출력 상한 초과. 프로세스 출력 조회는 오래된 바이트를 버리는 방식 | | `TIMEOUT` | 관리 명령 또는 도우미의 제한 시간 초과 | | `WORK_NOT_FOUND` | 논리적 작업을 찾을 수 없음 | diff --git a/docs/ko/execution-substrate.md b/docs/ko/execution-substrate.md index bf63187..8a00d60 100644 --- a/docs/ko/execution-substrate.md +++ b/docs/ko/execution-substrate.md @@ -42,13 +42,13 @@ ## 실행과 결과 관측 -게이트웨이는 내부 Runner 요청에 작업 공간 루트 cwd, 러너 환경 기본값, 시간·출력 제한, PTY 선택, 정책을 채웁니다. 현재 터미널 옵션으로 공개된 것은 `tty`뿐입니다. `tty_size`는 spawn 인자가 아닙니다. 공개 호출은 임의의 cwd·환경변수·제한 시간 변경을 받지 않습니다. 기본값은 [운영](operations.md), 결과 처리는 [Agent Loop 연동](agent-integration.md)을 참고하세요. +게이트웨이는 내부 Runner 요청에 작업 공간 루트 cwd, 러너 환경 기본값, 시간·출력 제한, PTY 선택, 정책을 채웁니다. spawn 시 공개된 터미널 옵션은 `tty`뿐입니다. `tty_size`는 spawn 인자가 아닙니다. 실행 중인 PTY는 `process_resize`로 크기를 바꿉니다. 공개 호출은 임의의 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와 동일하다고 문서화하지 않습니다. +`exec_command`는 디스패치 식별(`process_id`, `dispatch_status`)만 반환합니다. 종료 판정은 `process_status`의 `running`/`exited`와 termination 메타데이터를 사용합니다. 실행 중인 PTY 크기는 `process_resize`로 바꿉니다. `read_process`는 `output_lost`와 `retained_from`을 포함한 출력을 반환합니다. EOF는 성공이 아닙니다. 핸들이 만료된 뒤의 조회는 새 상태가 아니라 `PROCESS_NOT_FOUND`입니다. 파이프 프로세스의 크기 변경은 `PROCESS_NOT_TTY`, 종료된 핸들은 `PROCESS_NOT_RUNNING`입니다. Linux 샌드박스에서 wait 상태는 관리 자식(헬퍼 argv)의 코드이며, 사용자 argv와 동일하다고 문서화하지 않습니다. 작업 공간 잠금은 패치와 명령이 동시에 파일을 변경하지 못하게 합니다. 명령 실행 중에도 읽기와 검색은 가능하므로 파일 I/O는 사전 경로 검사에만 의존하지 않고 파일을 여는 시점의 심볼릭 링크 변경도 거부해야 합니다. Runner 파일 작업은 `codespace-fs`, 패치 적용은 별도 패치 도우미를 사용합니다. `operation_status`는 기록된 패치 원장(`kind`는 `patch`)을 조회하며, 실행 중인 명령은 `process_id`로만 다루고 이 조회로 복구하지 않습니다. -UDS 전송과 Linux 샌드박스 준비는 서로 다른 프로토콜과 실패 경계를 가집니다. UDS 변경 요청이 일부만 전달되면 결과가 불확실할 수 있습니다. 연결이 끊겼다는 이유만으로 새 변경 요청을 보내지 마세요. 프로세스·격리 규칙 전체는 [러너 격리](runner-isolation.md)에 설명합니다. +UDS 전송과 Linux 샌드박스 준비는 서로 다른 프로토콜과 실패 경계를 가집니다. UDS 변경 요청이 일부만 전달되면 결과가 불확실할 수 있습니다. 연결이 끊겼다는 이유만으로 새 변경 요청을 보내지 마세요. 프로세스 수명은 러너 인스턴스가 소유합니다. MCP/HTTP 클라이언트 끊김은 프로세스를 유지하고, UDS 게이트웨이↔worker 단절이나 게이트웨이 종료는 소유 서브트리를 종료합니다. 영속적인 프로세스 복구는 없습니다. 프로세스·격리 규칙 전체는 [러너 격리](runner-isolation.md)에 설명합니다. @@ -78,7 +78,7 @@ Runner는 해당 작업 공간에서 처음 `read`·`find`·`version`·`apply_pa ## 아직 제공하지 않는 기능 -PTY 크기 변경(`process_resize`는 제공하지 않음), 파일 범위·페이지 인자, 영속적인 프로세스 복구, 컨테이너·원격 실행, 자원 큐 스케줄러도 제공하지 않습니다. MCP `fs/watch`, UDS watch 이벤트, 쓰기 원인 분류도 제공하지 않습니다. 내부 타입이나 협상된 프로토콜 플래그가 존재한다고 해당 기능을 호출할 수 있는 것은 아닙니다. +파일 범위·페이지 인자, 영속적인 프로세스 복구, 컨테이너·원격 실행, 자원 큐 스케줄러도 제공하지 않습니다. MCP `fs/watch`, UDS watch 이벤트, 쓰기 원인 분류도 제공하지 않습니다. 내부 타입이나 협상된 프로토콜 플래그가 존재한다고 해당 기능을 호출할 수 있는 것은 아닙니다. ## 구현 경계 유지 diff --git a/docs/ko/operations.md b/docs/ko/operations.md index 0197cb5..2548ade 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -113,7 +113,7 @@ export CODESPACE_RUNNER=uds export CODESPACE_RUNTIME_BIN="$PWD/dist/codespace-codex-runtime" ``` -worker는 같은 호스트에서 실행하는 별도 프로세스이며 컨테이너가 아닙니다. 게이트웨이가 전용 소켓 디렉터리를 만들고 자식 프로세스를 관리합니다. worker 연결이 끊기거나 게이트웨이가 종료되면 해당 worker의 프로세스도 종료됩니다. 재접속과 프로세스 복구는 지원하지 않습니다. 기본값은 `in-process`입니다. +worker는 같은 호스트에서 실행하는 별도 프로세스이며 컨테이너가 아닙니다. 게이트웨이가 전용 소켓 디렉터리를 만들고 자식 프로세스를 관리합니다. worker 연결이 끊기거나 게이트웨이가 종료되면 해당 worker의 프로세스도 종료됩니다. 재접속과 프로세스 복구는 지원하지 않습니다. MCP/HTTP 클라이언트 끊김은 그 프로세스를 죽이지 않습니다. 기본값은 `in-process`입니다. @@ -128,7 +128,7 @@ worker는 같은 호스트에서 실행하는 별도 프로세스이며 컨테 | `CODESPACE_OPERATIONS_DB` 미설정 | 패치 작업과 지시 큐를 메모리에 보관하며 재시작 시 사라짐 | | `CODESPACE_PROCESS_TIMEOUT_SECS` | 양의 정수. 기본 30초. 러너 환경에 설정 | | `CODESPACE_MAX_PROCESSES` | 러너 전체의 실행 중 프로세스 기본 상한 8개. 작업 공간별 점유 규칙도 적용 | -| 프로세스 출력 | 마지막 256 KiB 보관. stdout/stderr를 합침. `read_process`는 `output_lost`와 `retained_from`을 보고하고, 종료는 `process_status`로 조회 | +| 프로세스 출력 | 마지막 256 KiB 보관. stdout/stderr를 합침. `read_process`는 `output_lost`와 `retained_from`을 보고하고, 종료는 `process_status`로 조회. 실행 중인 PTY 크기는 `process_resize`로 변경 | | 종료된 핸들 | 기본 최대 15분, 최대 64개 보관. 영구 저장하지 않음 | 로그와 데이터베이스는 토큰·게이트웨이 설정과 같이 관리 대상 작업 공간 밖에 두세요. stderr 로그의 보관·순환은 운영자가 관리합니다. Bearer 토큰을 로그나 커밋에 넣지 마세요. 데이터베이스를 삭제하면 패치 중복 실행 방지 기록과 확인 홀드 행도 사라집니다. diff --git a/docs/ko/runner-isolation.md b/docs/ko/runner-isolation.md index 6d2b058..82cd493 100644 --- a/docs/ko/runner-isolation.md +++ b/docs/ko/runner-isolation.md @@ -13,7 +13,9 @@ `in-process`는 `codespace-mcp` 안에서 프로세스를 관리합니다. `uds`(Unix domain socket, Unix 도메인 소켓)는 같은 호스트의 `codespace-codex-runtime` 안에서 같은 관리 코드를 실행합니다. worker는 시작 시 Codex의 프로세스 보호 설정을 적용하고 전용 Unix 소켓을 엽니다. worker 자체를 보호하는 것과 실행할 명령에 샌드박스를 적용하는 것은 별개입니다. -게이트웨이는 임시 디렉터리 또는 `CODESPACE_RUNNER_DIR` 아래에 권한 0700의 고유 디렉터리를 만듭니다. 내부 통신은 u32 길이 접두부, 버전 4 핸드셰이크, 요청 ID, 프로세스 종료 이벤트를 사용하는 CodeSpace JSON입니다. Codex App Server RPC가 아닙니다. 같은 연결에서의 요청 재처리는 재접속 복구를 뜻하지 않습니다. 게이트웨이와 worker 연결이 끊기면 관리 중인 worker와 자식 프로세스가 종료되고 핸들이 사라집니다. +게이트웨이는 임시 디렉터리 또는 `CODESPACE_RUNNER_DIR` 아래에 권한 0700의 고유 디렉터리를 만듭니다. 내부 통신은 u32 길이 접두부, 버전 5 핸드셰이크, 요청 ID, 프로세스 종료 이벤트를 사용하는 CodeSpace JSON입니다. Codex App Server RPC가 아닙니다. 같은 연결에서의 요청 재처리는 재접속 복구를 뜻하지 않습니다. + +관리 프로세스의 수명은 MCP 연결이 아니라 러너 인스턴스가 소유합니다. Streamable HTTP나 MCP 클라이언트 끊김은 프로세스를 유지합니다. 게이트웨이와 worker의 UDS 연결이 끊기거나 게이트웨이가 종료되면(stdio EOF 포함) 관리 중인 worker와 자식 프로세스가 종료되고 핸들이 사라집니다. 재시작은 `process_id`를 복구하지 않습니다. diff --git a/docs/operations.md b/docs/operations.md index 486ce12..db45ce3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -107,7 +107,7 @@ export CODESPACE_RUNNER=uds export CODESPACE_RUNTIME_BIN="$PWD/dist/codespace-codex-runtime" ``` -The worker runs on the same host and is not a container. The gateway creates a private socket directory and owns the child. Worker connection loss or gateway shutdown ends that worker's processes; reconnect and process recovery are not supported. The default remains `in-process`. +The worker runs on the same host and is not a container. The gateway creates a private socket directory and owns the child. Worker connection loss or gateway shutdown ends that worker's processes; reconnect and process recovery are not supported. MCP/HTTP client disconnect does not kill those processes. The default remains `in-process`. @@ -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; `read_process` reports `output_lost` and `retained_from`; `process_status` reports termination | +| Process output | Last 256 KiB retained; stdout/stderr combined; `read_process` reports `output_lost` and `retained_from`; `process_status` reports termination; `process_resize` resizes a running PTY | | 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 4d35696..f0ca43e 100644 --- a/docs/runner-isolation.md +++ b/docs/runner-isolation.md @@ -10,7 +10,9 @@ 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 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. +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 5, request IDs, and process-exit events. It is not Codex App Server RPC. Same-connection replay is not reconnect recovery. + +Managed process lifetime is owned by the runner instance, not the MCP connection. Streamable HTTP or MCP client disconnect keeps processes running. Gateway/worker UDS disconnect or gateway shutdown (including stdio EOF) ends the owned worker and its children; process handles are lost. Restart does not restore `process_id`. diff --git a/docs/translations.json b/docs/translations.json index 98410b4..f32412d 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -74,8 +74,8 @@ "작업-공간을-등록하고-시작하기", "제공하는-도구" ], - "source_sha256": "55ba1a154bb3023a458c9dcbb395e465004c45073a9b623be50e42795590acc6", - "translation_sha256": "2e13cae976e4d4baa4a997c223714cf5cd6bdd4dde59ee2ce673e10cbf11cd70" + "source_sha256": "069786747f49cd6cca6dd685a13b27211ca7be808582a19585c0c1978a0443f2", + "translation_sha256": "121edfb9fe6234d68aab458f49182507c8770e82463a775dd99b04dfb714df18" }, { "id": "agent-integration", @@ -100,8 +100,8 @@ "재시도와-복구", "파일-읽기와-패치" ], - "source_sha256": "1686f596d2c7b19c9f557b791a0b1ff242950efca0d4cd6ac3c34730f9eb4609", - "translation_sha256": "8a4d988446b81e1fc139596ff6bd858bb26b8a48b06465d2035efda6cdbe2afe" + "source_sha256": "066783790544a79361a1367f2a567901df4d48468d44817da77456db3cd5a171", + "translation_sha256": "c9b0f682c684f4503b8197bc5b75fe97ce38a5f2efbe6a3865960bdde79f158a" }, { "id": "operations", @@ -152,8 +152,8 @@ "작업-공간-등록", "첫-연결-확인" ], - "source_sha256": "aefe37ed26f44ca4cc620b085df753ea5c24d03f74538b83e791a6a4d67504d2", - "translation_sha256": "a39fb9446b2270c41ff739a14acff192d864c996758dbea97cc05230b5636e5e" + "source_sha256": "db6477f49619dfdb0582a74a549731f1b4073607437913e7210b012c4a45ea20", + "translation_sha256": "ca90d0dc3900b9e24bf1d4679a0c43a8b9c724233d584ce5fc2c7315b10a68ac" }, { "id": "chatgpt-connector", @@ -303,8 +303,8 @@ "확인-홀드", "훅과-스킬" ], - "source_sha256": "c84e0751e3fb3c7d1cd0b418caa66ff6778777437d4c29a83e93b1ff2a5a6910", - "translation_sha256": "eb6b798eea0cc1ca1ca8664878c20b364c9b3d32939a09f8bfd4b104361b65cf" + "source_sha256": "ceefd1314c3bbf318630d6aee519e180d7b044d6bbbf6b995b1d79267430871e", + "translation_sha256": "8a6dff78d6cc08d6b425b4097592b78a71a4c116900578fc57557648b8c4d443" }, { "id": "protocol-compatibility", @@ -445,8 +445,8 @@ "컨테이너-실험-구성과-검증", "호스트와-worker-실행" ], - "source_sha256": "174e4e9586869fe059cc9fdd2560af691144f60bb654ad0cbc47d58d4dadf0f7", - "translation_sha256": "63d96306ef71b2ec08545eda3a243e01cf6a3d7a6c8de7ef907af1124188875b" + "source_sha256": "065e07e43a16452e27a3c269fbf735e76ac1797eba850efbb29a2cb2656f18df", + "translation_sha256": "6795cc0eb1a25dc7a60f998ffc218e5745756ced88806bb76bd9d7be7893e03f" }, { "id": "error-codes", @@ -474,8 +474,8 @@ "전송-실패", "전송-실패-작업-없음" ], - "source_sha256": "c98cf2d562179238225427fe4c8b417849894b3895ddfc7add31022e968dfb56", - "translation_sha256": "abdf30713cfd0bd5409064356bf32e8208d370fb9f332bae7e0d2c3cb4eaee56" + "source_sha256": "9b765468d3045a3705f376a108aee22bf9d36efc8b094a7b3fa2047ca1596cae", + "translation_sha256": "75aed3982430e609f0dcea9f92a66cce9ededd7ed112a481819e8fce73da3197" }, { "id": "codex-reuse", @@ -554,8 +554,8 @@ "핀-6b9826e의-후보", "핵심-대-어댑터" ], - "source_sha256": "69d28fae0800ebc4837e7fc4249ad43405967c67f9ba453d8d5144a3f98ab0f8", - "translation_sha256": "7435e3a93f81e400ec084acdf6cf2f333eff56fadf39a8e66e95e5ac7b43e770" + "source_sha256": "f30c4a2bb1de3094733bb042db156db14a4e8eec3a651abdfcc92e3823296d66", + "translation_sha256": "5f389f7fc23213ea87fc508d1dfdf2b23d6d704130752c0263f0288e2c0911cd" }, { "id": "upstream-lock", diff --git a/tests/e2e/flow.rs b/tests/e2e/flow.rs index 3bc61b0..8904dad 100644 --- a/tests/e2e/flow.rs +++ b/tests/e2e/flow.rs @@ -79,7 +79,11 @@ async fn info_read_patch_exec_flow() { assert_eq!(info_body["execution"]["files"]["patch"]["available"], true); assert_eq!( info_body["execution"]["process"]["capabilities"]["tty"]["resize_supported"], - false + true + ); + assert_eq!( + info_body["execution"]["process"]["capabilities"]["lifetime"]["owner"], + "runner" ); if codespace_runner::linux_sandbox_available() { assert_eq!(