Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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입니다. 브라우저에서 사용하는 받은 편지함 화면은 제공하지 않습니다.
Expand All @@ -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 계정 연결도 아직 검증되지 않았습니다.

<a id="라이선스"></a>
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/codex-runtime/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/codex-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
70 changes: 68 additions & 2 deletions crates/codex-runtime/tests/runtime_binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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:?})"
);
}
6 changes: 6 additions & 0 deletions crates/domain/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pub enum ErrorCode {
OperationKeyConflict,
OperationNotFound,
ProcessNotFound,
ProcessNotTty,
ProcessNotRunning,
OutputLimit,
Timeout,
WorkNotFound,
Expand Down Expand Up @@ -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();
Expand Down
84 changes: 82 additions & 2 deletions crates/domain/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
},
}),
};
Expand Down Expand Up @@ -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);
Expand All @@ -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]
Expand Down
12 changes: 7 additions & 5 deletions crates/domain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions crates/domain/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoordinationHint>,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions crates/domain/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
Loading
Loading