From 3bf0ae126a3d1bbb7d182d72dcaacb39614fc5b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:59:31 +0900 Subject: [PATCH 01/21] feat(remote): carry bounded executor and hub runtime adapters Carry #3458 runtime foundations with explicit session grants, private state stores and fail-closed Windows command support. Keep server and dashboard activation for the dependent integration layer. Co-authored-by: Ingwannu --- .gitignore | 3 + .npmignore | 1 + .../020_executor_runtime.md | 22 + native/remote-workspace-helper/Cargo.lock | 130 +++ native/remote-workspace-helper/Cargo.toml | 24 + native/remote-workspace-helper/src/main.rs | 49 ++ .../remote-workspace-helper/src/protocol.rs | 246 ++++++ .../src/sandbox/macos.rs | 19 + .../src/sandbox/mod.rs | 77 ++ .../src/sandbox/windows.rs | 15 + .../tests/live_confinement.rs | 75 ++ package.json | 5 + scripts/test-layout/layout.json | 16 + src/cli/remote-workspace.ts | 154 ++++ src/lib/windows-atomic-replace.ts | 1 + src/remote-control/index.ts | 233 +++++- .../workspace-agent-connection.ts | 366 +++++++++ .../workspace-claude-runtime.ts | 243 ++++++ src/remote-control/workspace-codex-runtime.ts | 531 +++++++++++++ src/remote-control/workspace-codex-sandbox.ts | 115 +++ .../workspace-command-runner.ts | 749 ++++++++++++++++++ src/remote-control/workspace-coordinator.ts | 230 ++++++ src/remote-control/workspace-device.ts | 585 ++++++++++++++ src/remote-control/workspace-executable.ts | 43 + src/remote-control/workspace-executor.ts | 396 +++++++++ src/remote-control/workspace-hub.ts | 519 ++++++++++++ src/remote-control/workspace-pi-runtime.ts | 382 +++++++++ src/remote-control/workspace-process.ts | 129 +++ src/remote-control/workspace-rpc.ts | 304 +++++++ src/remote-control/workspace-runtime.ts | 60 ++ src/remote-control/workspace-secret-store.ts | 39 + src/remote-control/workspace-sessions.ts | 730 +++++++++++++++++ src/remote-control/workspace-tool-bridge.ts | 192 +++++ structure/clients/claude-desktop.md | 2 + structure/clients/integrations.md | 2 + structure/config.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/overview.md | 2 + structure/remote-workspace.md | 18 +- structure/runtime.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + .../remote-workspace-agent-wire.test.ts | 324 ++++++++ ...e-workspace-app-server.integration.test.ts | 426 ++++++++++ ...emote-workspace-claude.integration.test.ts | 166 ++++ .../remote-workspace-cli-runtimes.test.ts | 67 ++ tests/clients/remote-workspace-cli.test.ts | 105 +++ .../remote-workspace-codex-runtime.test.ts | 120 +++ .../remote-workspace-command-runner.test.ts | 328 ++++++++ tests/clients/remote-workspace-device.test.ts | 158 ++++ tests/clients/remote-workspace-hub.test.ts | 211 +++++ ...remote-workspace-linux-confinement.test.ts | 114 +++ .../clients/remote-workspace-platform.test.ts | 182 +++++ .../remote-workspace-secret-store.test.ts | 105 +++ .../remote-workspace-session-binding.test.ts | 75 ++ .../clients/remote-workspace-sessions.test.ts | 352 ++++++++ .../remote-workspace-tool-bridge.test.ts | 87 ++ tests/clients/remote-workspace.test.ts | 464 +++++++++++ tests/fake-codex-server.ts | 4 + tests/fixtures/fake-claude-stream.ts | 8 + tests/fixtures/test-layout-expected.json | 16 + 62 files changed, 9984 insertions(+), 47 deletions(-) create mode 100644 native/remote-workspace-helper/Cargo.lock create mode 100644 native/remote-workspace-helper/Cargo.toml create mode 100644 native/remote-workspace-helper/src/main.rs create mode 100644 native/remote-workspace-helper/src/protocol.rs create mode 100644 native/remote-workspace-helper/src/sandbox/macos.rs create mode 100644 native/remote-workspace-helper/src/sandbox/mod.rs create mode 100644 native/remote-workspace-helper/src/sandbox/windows.rs create mode 100644 native/remote-workspace-helper/tests/live_confinement.rs create mode 100644 src/cli/remote-workspace.ts create mode 100644 src/remote-control/workspace-agent-connection.ts create mode 100644 src/remote-control/workspace-claude-runtime.ts create mode 100644 src/remote-control/workspace-codex-runtime.ts create mode 100644 src/remote-control/workspace-codex-sandbox.ts create mode 100644 src/remote-control/workspace-command-runner.ts create mode 100644 src/remote-control/workspace-coordinator.ts create mode 100644 src/remote-control/workspace-device.ts create mode 100644 src/remote-control/workspace-executable.ts create mode 100644 src/remote-control/workspace-executor.ts create mode 100644 src/remote-control/workspace-hub.ts create mode 100644 src/remote-control/workspace-pi-runtime.ts create mode 100644 src/remote-control/workspace-process.ts create mode 100644 src/remote-control/workspace-rpc.ts create mode 100644 src/remote-control/workspace-runtime.ts create mode 100644 src/remote-control/workspace-secret-store.ts create mode 100644 src/remote-control/workspace-sessions.ts create mode 100644 src/remote-control/workspace-tool-bridge.ts create mode 100644 tests/clients/remote-workspace-agent-wire.test.ts create mode 100644 tests/clients/remote-workspace-app-server.integration.test.ts create mode 100644 tests/clients/remote-workspace-claude.integration.test.ts create mode 100644 tests/clients/remote-workspace-cli-runtimes.test.ts create mode 100644 tests/clients/remote-workspace-cli.test.ts create mode 100644 tests/clients/remote-workspace-codex-runtime.test.ts create mode 100644 tests/clients/remote-workspace-command-runner.test.ts create mode 100644 tests/clients/remote-workspace-device.test.ts create mode 100644 tests/clients/remote-workspace-hub.test.ts create mode 100644 tests/clients/remote-workspace-linux-confinement.test.ts create mode 100644 tests/clients/remote-workspace-platform.test.ts create mode 100644 tests/clients/remote-workspace-secret-store.test.ts create mode 100644 tests/clients/remote-workspace-session-binding.test.ts create mode 100644 tests/clients/remote-workspace-sessions.test.ts create mode 100644 tests/clients/remote-workspace-tool-bridge.test.ts create mode 100644 tests/clients/remote-workspace.test.ts create mode 100644 tests/fixtures/fake-claude-stream.ts diff --git a/.gitignore b/.gitignore index ce10233dcc..f32218aafd 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ tests/**/.tmp-* # `git add` three separate times and reached `dev` once — see # tests/ci-workflows/repo-hygiene.test.ts, which fails if any path here becomes tracked again. go/ + +# Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. +native/**/target/ diff --git a/.npmignore b/.npmignore index acf3a0c4d0..cfbe1d3750 100644 --- a/.npmignore +++ b/.npmignore @@ -19,6 +19,7 @@ gui/eslint.config.* gui/bun.lock # misc +native/remote-workspace-helper/target/ *.test.ts *.map .DS_Store diff --git a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md index 550b2a5bb5..e20200c7c3 100644 --- a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md +++ b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md @@ -82,3 +82,25 @@ REMOTE-ARCH-003: Separate persisted enrollment capabilities from current connect REMOTE-ARCH-006: Use existing required private-file/Windows ACL primitives for new identity and bearer stores. Check permission setup failures and refuse loading/saving secrets when enforcement fails. Do not change global config-store behavior. Record exact selected existing helper in phase-2 P after reading the owner; no best-effort function is accepted as proof. REMOTE-ARCH-007: Codex real App Server tests depend on OCX_CODEX_BIN; Claude real integration on OCX_CLAUDE_BIN; Pi on OCX_PI_BIN. The Linux confinement case can return without execution unless OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT=1 or bwrap is available. Current generic CI alone does not prove those paths. Mock tests prove lifecycle and tool-routing contracts only; native Hub isolation and executor confinement stay explicit final acceptance gaps when not activated. For each adapter separately record denied local tools, inherited plugins/hooks/config, offline refusal and teardown; inspect source plus hosted mocks, no claims of live CLI confinement from flags alone. + +## Phase-2 revalidation and exact owner choices + +Previous D: wp1 inactive foundation source cycle complete at 726ddc7fc0; final hosted proof remains wp4. Continue in child branch codex/260912-60plus-remote-runtime. Existing public exports and added host-negative coverage are retained. + +REMOTE-ARCH-004: storage modules import atomicWriteFile directly from src/config/atomic-write.ts and getConfigDir from src/config/paths.ts, avoiding the broad config.ts barrel. Device CLI orchestration retains explicit runner construction because it computes actual availability after root approval; no import-time probe exists. This is intentional sequential coupling. Server seams in phase 3 use narrow structural connection/session interfaces rather than pulling concrete remote classes into shared request types. No remote module imports server surfaces. + +REMOTE-ARCH-006 exact helpers: NEW src/remote-control/workspace-secret-store.ts owns prepareWorkspaceSecretDirectory(directory) and hardenWorkspaceSecretFile(path). On POSIX use chmodSync with propagated failure and lstat directory/file identity/type checks. On Windows call existing src/lib/windows-secret-acl.ts hardenSecretDir/hardenSecretPath with required:true. Reject symlink state targets. All three stores use this before reads and before atomicWriteFile. Existing atomic-write.ts already creates an empty private descriptor, hardens before writing bytes, and scrubs failures; retain it. Tests: NEW tests/clients/remote-workspace-secret-store.test.ts covers owner-only POSIX file mode, unexpected path types/symlinks and failed reads; hosted Windows ACL owner tests remain applicable. No global config behavior changes. + +src/lib/windows-atomic-replace.ts change is the new ReplacePublisher literal remote-workspace (the function is already exported). Use existing counter serialization/consumers unchanged: creation at executor write, diagnostic key serialization, dynamic record readers; no closed switch to extend. + +NEW tests/clients/remote-workspace-session-binding.test.ts covers session/device/root/capability mismatches with zero execution and a valid positive control, using encrypted messages and independent fixtures. MODIFY agent-wire, hub, sessions and device tests to assert subset negotiation and presence intersection. Platform runner source retains existing fail-closed native paths; remove stale comment claiming supported macOS commands. + +### Audit amendment: store-level failure propagation + +Hub/Device/Session file-store constructors accept an optional narrow permissions dependency containing prepareDirectory and hardenFile, defaulting to the required production helper. Load returns null for absent files; existing files require directory and file checks before secret reads. Save prepares directory, hardens an existing target, then invokes the existing private atomic writer. For each store, injected directory/file hardening throws must propagate, preserve existing bytes and prevent secret IO. New-state first-run controls return null then save/load valid fixtures. Add all three store cases to remote-workspace-secret-store.test.ts; this injection observes caller ordering rather than relying on ACL-owner tests alone. + +### Native containment amendment + +Independent source review requires a protected Linux bubblewrap executable outside writable roots, with identity revalidation before use. Custom executable files and their parent chain must not be writable by group/other; canonical system symlinks are resolved before checking. Workspace roots cannot contain the executable; every invocation rechecks. Add source/runner regression fixtures without claiming a local run. + +Windows command availability remains disabled in this carry: nativeRemoteWorkspaceCommandRunnerAvailable returns false before invoking the helper, and the official Windows helper rejects public probe/run without allocating OS resources. The candidate Windows implementation remains in original PR history; do not retain callable unverified entrypoints. This matches the fail-closed macOS policy and preserves independently authorized file tools. Update native denial tests and docs; Windows working-command acceptance stays OPEN. A future lifecycle owner and hosted cancellation/cleanup evidence are required before re-enablement. This is a safety limitation, not completion of Windows commands. diff --git a/native/remote-workspace-helper/Cargo.lock b/native/remote-workspace-helper/Cargo.lock new file mode 100644 index 0000000000..8dba097e9d --- /dev/null +++ b/native/remote-workspace-helper/Cargo.lock @@ -0,0 +1,130 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "opencodex-remote-workspace-helper" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "windows-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/remote-workspace-helper/Cargo.toml b/native/remote-workspace-helper/Cargo.toml new file mode 100644 index 0000000000..65bd1d0ba7 --- /dev/null +++ b/native/remote-workspace-helper/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "opencodex-remote-workspace-helper" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[dependencies] +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_Storage_FileSystem", + "Win32_System_JobObjects", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_Threading", +] } diff --git a/native/remote-workspace-helper/src/main.rs b/native/remote-workspace-helper/src/main.rs new file mode 100644 index 0000000000..8312186b8d --- /dev/null +++ b/native/remote-workspace-helper/src/main.rs @@ -0,0 +1,49 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod protocol; +mod sandbox; + +use std::io::{self, Read, Write}; + +use protocol::{HelperRequest, HelperResponse, MAX_REQUEST_BYTES, PROTOCOL_VERSION}; + +fn main() { + if std::env::args().nth(1).as_deref() == Some("__probe-child") { + std::process::exit(sandbox::run_probe_child()); + } + + let response = match read_request().and_then(handle_request) { + Ok(response) => response, + Err(error) => HelperResponse::error(error), + }; + let mut stdout = io::stdout().lock(); + if serde_json::to_writer(&mut stdout, &response).is_err() || stdout.write_all(b"\n").is_err() { + std::process::exit(2); + } +} + +fn read_request() -> Result { + let mut body = Vec::new(); + io::stdin() + .take((MAX_REQUEST_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| "could not read helper request".to_owned())?; + if body.len() > MAX_REQUEST_BYTES { + return Err("helper request exceeds its size limit".to_owned()); + } + let request: HelperRequest = + serde_json::from_slice(&body).map_err(|_| "helper request is invalid".to_owned())?; + request.validate()?; + Ok(request) +} + +fn handle_request(request: HelperRequest) -> Result { + if request.version != PROTOCOL_VERSION { + return Err("unsupported helper protocol version".to_owned()); + } + match request.operation.as_str() { + "probe" => sandbox::probe().map(|()| HelperResponse::probe_success()), + "run" => sandbox::run(&request).map(HelperResponse::command_success), + _ => Err("unsupported helper operation".to_owned()), + } +} diff --git a/native/remote-workspace-helper/src/protocol.rs b/native/remote-workspace-helper/src/protocol.rs new file mode 100644 index 0000000000..900f630e5c --- /dev/null +++ b/native/remote-workspace-helper/src/protocol.rs @@ -0,0 +1,246 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +#[cfg(target_os = "windows")] +use std::path::PathBuf; + +pub const PROTOCOL_VERSION: u8 = 1; +pub const MAX_REQUEST_BYTES: usize = 64 * 1024; +pub const MAX_OUTPUT_BYTES: usize = 256 * 1024; +const MAX_PATH_BYTES: usize = 4096; +const MAX_COMMAND_ARGUMENTS: usize = 64; +const MAX_COMMAND_ARGUMENT_BYTES: usize = 4096; +const MAX_COMMAND_BYTES: usize = 16 * 1024; +const MAX_TOOLCHAIN_ROOTS: usize = 16; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct HelperRequest { + pub version: u8, + pub operation: String, + #[serde(default)] + pub root: String, + #[serde(default)] + pub cwd: String, + #[serde(default)] + pub command: Vec, + #[serde(default)] + pub toolchain_roots: Vec, + #[serde(default)] + pub timeout_ms: u64, + #[serde(default)] + pub max_output_bytes: usize, + #[serde(default)] + pub network_access: bool, +} + +impl HelperRequest { + pub fn validate(&self) -> Result<(), String> { + if self.operation == "probe" { + if !self.root.is_empty() + || !self.cwd.is_empty() + || !self.command.is_empty() + || !self.toolchain_roots.is_empty() + || self.timeout_ms != 0 + || self.max_output_bytes != 0 + || self.network_access + { + return Err("probe request must not carry command authority".to_owned()); + } + return Ok(()); + } + if self.operation != "run" { + return Ok(()); + } + validate_path(&self.root, "workspace root")?; + validate_path(&self.cwd, "command cwd")?; + if !Path::new(&self.root).is_absolute() || !Path::new(&self.cwd).is_absolute() { + return Err("workspace root and cwd must be absolute".to_owned()); + } + if self.command.is_empty() || self.command.len() > MAX_COMMAND_ARGUMENTS { + return Err("invalid command vector".to_owned()); + } + let mut command_bytes = 0usize; + for value in &self.command { + if value.is_empty() || value.len() > MAX_COMMAND_ARGUMENT_BYTES || value.contains('\0') + { + return Err("invalid command vector".to_owned()); + } + command_bytes = command_bytes + .checked_add(value.len()) + .ok_or_else(|| "command vector is too large".to_owned())?; + } + if command_bytes > MAX_COMMAND_BYTES { + return Err("command vector is too large".to_owned()); + } + if self.toolchain_roots.len() > MAX_TOOLCHAIN_ROOTS { + return Err("too many toolchain roots".to_owned()); + } + for path in &self.toolchain_roots { + validate_path(path, "toolchain root")?; + if !Path::new(path).is_absolute() { + return Err("toolchain roots must be absolute".to_owned()); + } + } + if !(1..=60_000).contains(&self.timeout_ms) { + return Err("command timeout is outside its limit".to_owned()); + } + if !(1024..=MAX_OUTPUT_BYTES).contains(&self.max_output_bytes) { + return Err("command output limit is outside its limit".to_owned()); + } + Ok(()) + } + + #[cfg(target_os = "windows")] + pub fn canonical_paths(&self) -> Result { + let root = canonical_directory(&self.root, "workspace root")?; + let cwd = canonical_directory(&self.cwd, "command cwd")?; + if !cwd.starts_with(&root) { + return Err("command cwd escaped its workspace root".to_owned()); + } + let mut toolchain_roots = Vec::with_capacity(self.toolchain_roots.len()); + for value in &self.toolchain_roots { + let canonical = canonical_directory(value, "toolchain root")?; + if !toolchain_roots.contains(&canonical) { + toolchain_roots.push(canonical); + } + } + Ok(CanonicalPaths { + root, + cwd, + toolchain_roots, + }) + } +} + +fn validate_path(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() || value.len() > MAX_PATH_BYTES || value.contains('\0') { + return Err(format!("invalid {label}")); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn canonical_directory(value: &str, label: &str) -> Result { + let original = Path::new(value); + let metadata = + std::fs::symlink_metadata(original).map_err(|_| format!("{label} is unavailable"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} must remain a real directory")); + } + original + .canonicalize() + .map_err(|_| format!("{label} is unavailable")) +} + +#[cfg(target_os = "windows")] +#[derive(Debug)] +pub struct CanonicalPaths { + pub root: PathBuf, + pub cwd: PathBuf, + pub toolchain_roots: Vec, +} + +#[derive(Debug)] +pub struct CommandOutcome { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelperResponse { + version: u8, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + probe: Option, + #[serde(skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stdout_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stderr_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl HelperResponse { + pub fn error(error: String) -> Self { + Self { + version: PROTOCOL_VERSION, + ok: false, + probe: None, + exit_code: None, + stdout_base64: None, + stderr_base64: None, + error: Some(limit_error(error)), + } + } + + pub fn probe_success() -> Self { + Self { + version: PROTOCOL_VERSION, + ok: true, + probe: Some(true), + exit_code: None, + stdout_base64: None, + stderr_base64: None, + error: None, + } + } + + pub fn command_success(outcome: CommandOutcome) -> Self { + Self { + version: PROTOCOL_VERSION, + ok: true, + probe: None, + exit_code: Some(outcome.exit_code), + stdout_base64: Some(STANDARD.encode(outcome.stdout)), + stderr_base64: Some(STANDARD.encode(outcome.stderr)), + error: None, + } + } +} + +fn limit_error(mut value: String) -> String { + const MAX_ERROR_CHARS: usize = 512; + if value.chars().count() <= MAX_ERROR_CHARS { + return value; + } + value = value.chars().take(MAX_ERROR_CHARS).collect(); + value.push('…'); + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_authority_smuggled_into_probe() { + let request: HelperRequest = + serde_json::from_str(r#"{"version":1,"operation":"probe","command":["whoami"]}"#) + .expect("valid JSON fixture"); + assert!(request.validate().is_err()); + } + + #[test] + fn rejects_unknown_wire_fields() { + assert!( + serde_json::from_str::( + r#"{"version":1,"operation":"probe","surprise":true}"#, + ) + .is_err() + ); + } + + #[test] + fn bounds_command_shape_before_platform_code() { + let request: HelperRequest = serde_json::from_str( + r#"{"version":1,"operation":"run","root":"/tmp/a","cwd":"/tmp/a","command":["x"],"timeoutMs":0,"maxOutputBytes":262144}"#, + ) + .expect("valid JSON fixture"); + assert!(request.validate().is_err()); + } +} diff --git a/native/remote-workspace-helper/src/sandbox/macos.rs b/native/remote-workspace-helper/src/sandbox/macos.rs new file mode 100644 index 0000000000..2052f82707 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/macos.rs @@ -0,0 +1,19 @@ +use crate::protocol::{CommandOutcome, HelperRequest}; + +const MACOS_CONFINEMENT_UNAVAILABLE: &str = + "macOS Remote Workspace command confinement is unavailable; file tools remain enabled"; + +/// macOS has no unprivileged Job Object or cgroup equivalent that can revoke every descendant's +/// workspace access. A Seatbelt profile can constrain a process, but allowing subprocesses lets a +/// descendant call `setsid()` and outlive cancellation. Importing broad system profiles merely to +/// make a single-process probe start would also widen unrelated host-service authority. Until a +/// native containment owner closes both boundaries, command execution must stay unavailable. +pub fn probe() -> Result<(), String> { + Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned()) +} + +/// Keep the helper itself fail-closed even if a caller bypasses OCX capability negotiation and +/// submits a `run` request directly. +pub fn run(_request: &HelperRequest) -> Result { + Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned()) +} diff --git a/native/remote-workspace-helper/src/sandbox/mod.rs b/native/remote-workspace-helper/src/sandbox/mod.rs new file mode 100644 index 0000000000..4b9bf551d4 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/mod.rs @@ -0,0 +1,77 @@ +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +use crate::protocol::{CommandOutcome, HelperRequest}; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +#[cfg(target_os = "macos")] +pub use macos::{probe, run}; +#[cfg(target_os = "windows")] +pub use windows::{probe, run}; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn probe() -> Result<(), String> { + Err("native helper is supported only on macOS and Windows".to_owned()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn run(_request: &HelperRequest) -> Result { + Err("native helper is supported only on macOS and Windows".to_owned()) +} + +pub fn run_probe_child() -> i32 { + let mut args = std::env::args().skip(2); + let Some(workspace) = args.next() else { + return 20; + }; + let Some(outside_file) = args.next() else { + return 21; + }; + let Some(outside_write) = args.next() else { + return 22; + }; + let Some(listener_address) = args.next() else { + return 23; + }; + let Some(existing_workspace_file) = args.next() else { + return 24; + }; + if args.next().is_some() { + return 24; + } + + let marker = std::path::Path::new(&workspace).join("probe-marker"); + if fs::write(&marker, b"sandboxed").is_err() { + return 25; + } + if !matches!(fs::read(&existing_workspace_file), Ok(value) if value == b"existing") + || fs::write(&existing_workspace_file, b"updated").is_err() + { + return 29; + } + let mut outside = Vec::new(); + if OpenOptions::new() + .read(true) + .open(&outside_file) + .and_then(|mut file| file.read_to_end(&mut outside)) + .is_ok() + { + return 26; + } + if fs::write(&outside_write, b"escaped").is_ok() { + return 27; + } + let Ok(listener_address) = listener_address.parse::() else { + return 23; + }; + if TcpStream::connect_timeout(&listener_address, Duration::from_millis(500)).is_ok() { + return 28; + } + 0 +} diff --git a/native/remote-workspace-helper/src/sandbox/windows.rs b/native/remote-workspace-helper/src/sandbox/windows.rs new file mode 100644 index 0000000000..2ecefef055 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/windows.rs @@ -0,0 +1,15 @@ +use crate::protocol::{CommandOutcome, HelperRequest}; + +const WINDOWS_CONFINEMENT_UNAVAILABLE: &str = + "Windows Remote Workspace command confinement is unavailable; command execution is disabled"; + +// A command-capable implementation must retain cleanup ownership through helper cancellation +// and establish Job membership atomically. Until that owner is implemented and verified, +// direct helper requests and capability probes refuse before allocating OS resources. +pub fn probe() -> Result<(), String> { + Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned()) +} + +pub fn run(_request: &HelperRequest) -> Result { + Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned()) +} diff --git a/native/remote-workspace-helper/tests/live_confinement.rs b/native/remote-workspace-helper/tests/live_confinement.rs new file mode 100644 index 0000000000..e735029ac8 --- /dev/null +++ b/native/remote-workspace-helper/tests/live_confinement.rs @@ -0,0 +1,75 @@ +#![cfg(any(target_os = "macos", target_os = "windows"))] + +use serde_json::Value; +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run_helper(request: &Value) -> Value { + let binary = env!("CARGO_BIN_EXE_opencodex-remote-workspace-helper"); + let mut child = Command::new(binary) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("native helper starts"); + child + .stdin + .take() + .expect("native helper stdin") + .write_all(&serde_json::to_vec(request).expect("helper request serializes")) + .expect("helper request is written"); + let output = child.wait_with_output().expect("native helper exits"); + assert!( + output.status.success(), + "helper stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("helper response is JSON") +} + +fn run_probe() -> Value { + run_helper(&serde_json::json!({ "version": 1, "operation": "probe" })) +} + +#[cfg(target_os = "windows")] +#[test] +fn native_helper_keeps_windows_command_execution_fail_closed() { + let unavailable = serde_json::json!({ + "version": 1, + "ok": false, + "error": "Windows Remote Workspace command confinement is unavailable; command execution is disabled" + }); + assert_eq!(run_probe(), unavailable); + let root = std::env::current_dir().expect("test cwd"); + assert_eq!(run_helper(&serde_json::json!({ + "version": 1, "operation": "run", "root": root, "cwd": root, + "command": ["cmd.exe", "/c", "exit"], "timeoutMs": 1000, "maxOutputBytes": 4096 + })), unavailable); +} + +#[cfg(target_os = "macos")] +#[test] +fn native_helper_keeps_macos_command_execution_fail_closed() { + let unavailable = serde_json::json!({ + "version": 1, + "ok": false, + "error": "macOS Remote Workspace command confinement is unavailable; file tools remain enabled" + }); + assert_eq!(run_probe(), unavailable); + + let root = std::env::current_dir().expect("test cwd"); + assert_eq!( + run_helper(&serde_json::json!({ + "version": 1, + "operation": "run", + "root": root, + "cwd": root, + "command": ["/usr/bin/true"], + "toolchainRoots": [], + "timeoutMs": 5_000, + "maxOutputBytes": 16 * 1024, + "networkAccess": false + })), + unavailable + ); +} diff --git a/package.json b/package.json index 6fae3e4d49..593ae79698 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "README.md", "SPONSORS.md", "AGENTS_INSTALL.md", + "native/remote-workspace-helper/Cargo.toml", + "native/remote-workspace-helper/Cargo.lock", + "native/remote-workspace-helper/src", "LICENSE" ], "engines": { @@ -52,6 +55,8 @@ "structure:check": "bun scripts/structure-ssot.ts", "generate:model-metadata": "bun scripts/generate-model-metadata.ts", "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", + "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fa89d735b8..55b8a9bf44 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1051,6 +1051,22 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", "remote-control-prototype.test.ts": "clients", "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", diff --git a/src/cli/remote-workspace.ts b/src/cli/remote-workspace.ts new file mode 100644 index 0000000000..5eb8a59c44 --- /dev/null +++ b/src/cli/remote-workspace.ts @@ -0,0 +1,154 @@ +import type { RemoteWorkspaceDeviceState } from "../remote-control/workspace-device"; +import { + RemoteWorkspaceDeviceFileStore, + pairRemoteWorkspaceDevice, + remoteWorkspaceCapabilitiesForCommandRunner, + runRemoteWorkspaceAgent, + type PairRemoteWorkspaceDeviceOptions, + type RemoteWorkspaceAgentRunStatus, + type RemoteWorkspaceDeviceStateStore, +} from "../remote-control/workspace-device"; +import { createPlatformRemoteWorkspaceCommandRunner } from "../remote-control/workspace-command-runner"; +import { + CliUsageError, + readSecretLine, + rejectArgs, + takeFlag, + takeJsonFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +export const REMOTE_WORKSPACE_USAGE = `Usage: + ocx remote-workspace pair --pairing-code-stdin --root [--root ...] [--toolchain-root ...] [--executor-helper ] [--name ] [--json] + ocx remote-workspace agent + ocx remote-workspace status [--json]`; + +export interface RemoteWorkspaceCliDeps extends RuntimeApiDeps { + store?: RemoteWorkspaceDeviceStateStore; + pair?: (options: PairRemoteWorkspaceDeviceOptions) => Promise; + runAgent?: typeof runRemoteWorkspaceAgent; + signal?: AbortSignal; + onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void; +} + +function takeRepeatedPathFlag(args: string[], flag: "--root" | "--toolchain-root"): string[] { + const roots: string[] = []; + for (;;) { + const index = args.indexOf(flag); + if (index < 0) break; + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new CliUsageError(`${flag} requires an absolute path`, REMOTE_WORKSPACE_USAGE); + roots.push(value); + args.splice(index, 2); + } + return roots; +} + +function publicStatus(state: RemoteWorkspaceDeviceState | null): Record { + if (!state) return { paired: false }; + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner( + createPlatformRemoteWorkspaceCommandRunner({ + linux: { + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + }, + ...(state.nativeHelper ? { native: { + helper: state.nativeHelper, + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + } } : {}), + }), + state.capabilities, + ); + return { + paired: true, + hubUrl: state.hubUrl, + deviceId: state.deviceId, + deviceName: state.deviceName, + devicePlatform: state.devicePlatform, + capabilities, + roots: state.roots.map(root => ({ id: root.id, label: root.label, path: root.path })), + toolchainRoots: state.toolchainRoots, + }; +} + +export async function runRemoteWorkspaceCommand(rawArgs: string[], deps: RemoteWorkspaceCliDeps = {}): Promise { + const args = [...rawArgs]; + const command = args.shift(); + const store = deps.store ?? new RemoteWorkspaceDeviceFileStore(); + if (command === "status") { + const wantsJson = takeJsonFlag(args); + rejectArgs(args, REMOTE_WORKSPACE_USAGE); + const status = publicStatus(store.load()); + if (wantsJson) console.log(JSON.stringify(status, null, 2)); + else if (!status.paired) console.log("Remote Workspace executor is not paired."); + else { + console.log(`Remote Workspace executor: ${status.deviceName}`); + console.log(`Hub: ${status.hubUrl}`); + console.log(`Capabilities: ${(status.capabilities as string[]).join(", ")}`); + console.log(`Workspace roots: ${(status.roots as unknown[]).length}`); + } + return 0; + } + if (command === "pair") { + const wantsJson = takeJsonFlag(args); + const readCode = takeFlag(args, "--pairing-code-stdin"); + const name = takeOption(args, "--name"); + const nativeHelperPath = takeOption(args, "--executor-helper"); + const roots = takeRepeatedPathFlag(args, "--root"); + const toolchainRoots = takeRepeatedPathFlag(args, "--toolchain-root"); + const hubUrl = args.shift(); + if (!hubUrl || !readCode || roots.length === 0) throw new CliUsageError( + "pair requires , --pairing-code-stdin, and at least one --root", + REMOTE_WORKSPACE_USAGE, + ); + rejectArgs(args, REMOTE_WORKSPACE_USAGE, { redactValues: true }); + const pairingCode = await readSecretLine(deps, "Remote Workspace pairing code"); + const state = await (deps.pair ?? pairRemoteWorkspaceDevice)({ + hubUrl, + pairingCode, + ...(name ? { name } : {}), + roots: roots.map(path => ({ path })), + toolchainRoots, + ...(nativeHelperPath ? { nativeHelperPath } : {}), + store, + }); + const status = publicStatus(state); + if (wantsJson) console.log(JSON.stringify(status, null, 2)); + else { + console.log(`Paired ${state.deviceName} with ${state.hubUrl}.`); + console.log("Run `ocx remote-workspace agent` to keep this executor online."); + } + return 0; + } + if (command === "agent") { + rejectArgs(args, REMOTE_WORKSPACE_USAGE); + const state = store.load(); + if (!state) throw new CliUsageError("Remote Workspace executor is not paired. Run the pair command first.", REMOTE_WORKSPACE_USAGE); + const controller = deps.signal ? null : new AbortController(); + const signal = deps.signal ?? controller!.signal; + const stop = () => controller?.abort(); + if (controller) { + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + } + try { + await (deps.runAgent ?? runRemoteWorkspaceAgent)({ + state, + signal, + onStatus: deps.onStatus ?? (status => { + if (status.state === "online") console.log(`Remote Workspace executor online: ${state.deviceName}`); + if (status.state === "reconnecting" && status.message) console.error(`Remote Workspace reconnecting: ${status.message}`); + }), + }); + } finally { + if (controller) { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + } + } + return 0; + } + throw new CliUsageError("choose pair, agent, or status", REMOTE_WORKSPACE_USAGE); +} diff --git a/src/lib/windows-atomic-replace.ts b/src/lib/windows-atomic-replace.ts index 0f3ba94552..a876c98bca 100644 --- a/src/lib/windows-atomic-replace.ts +++ b/src/lib/windows-atomic-replace.ts @@ -33,6 +33,7 @@ export type ReplacePublisher = | "claude-agents" | "lab-automation" | "lab-ledger" + | "remote-workspace" | "storage-cleanup" | "tray"; diff --git a/src/remote-control/index.ts b/src/remote-control/index.ts index 256832a324..352ff68043 100644 --- a/src/remote-control/index.ts +++ b/src/remote-control/index.ts @@ -1,3 +1,25 @@ +export { + parseRemoteControlClientHello, + parseRemoteControlHostHello, + serializeRemoteControlHello, + generateRemoteControlIdentityKeyPair, + RemoteControlCipher, + RemoteControlClientHandshake, + acceptRemoteControlClientHello, +} from "./crypto"; +export type { + RemoteControlIdentityKeyPair, + CreateRemoteControlClientHandshakeOptions, + AcceptRemoteControlClientHelloOptions, +} from "./crypto"; +export { + RemoteControlHost, +} from "./host"; +export type { + RemoteControlTerminal, + RemoteControlTerminalFactory, + RemoteControlHostOptions, +} from "./host"; export { REMOTE_CONTROL_PROTOCOL_VERSION, REMOTE_CONTROL_RELAY_HEADER_BYTES, @@ -24,28 +46,6 @@ export type { RemoteControlRelayFrame, RemoteControlApplicationFrame, } from "./protocol"; -export { - parseRemoteControlClientHello, - parseRemoteControlHostHello, - serializeRemoteControlHello, - generateRemoteControlIdentityKeyPair, - RemoteControlCipher, - RemoteControlClientHandshake, - acceptRemoteControlClientHello, -} from "./crypto"; -export type { - RemoteControlIdentityKeyPair, - CreateRemoteControlClientHandshakeOptions, - AcceptRemoteControlClientHelloOptions, -} from "./crypto"; -export { - RemoteControlHost, -} from "./host"; -export type { - RemoteControlTerminal, - RemoteControlTerminalFactory, - RemoteControlHostOptions, -} from "./host"; export { OpaqueRemoteControlRelay, } from "./relay"; @@ -53,6 +53,176 @@ export type { RemoteControlRelayPeer, OpaqueRemoteControlRelayOptions, } from "./relay"; +export { + RemoteWorkspaceHubAgentConnection, + RemoteWorkspaceExecutorAgentConnection, +} from "./workspace-agent-connection"; +export type { + RemoteWorkspaceControlSocket, +} from "./workspace-agent-connection"; +export { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES, + isRemoteWorkspaceAgentProfile, + serializeRemoteWorkspaceHubMessage, + serializeRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + parseRemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +export type { + RemoteWorkspaceAgentProfile, + RemoteWorkspaceHubMessage, + RemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +export { + ClaudeRemoteWorkspaceRuntimeFactory, +} from "./workspace-claude-runtime"; +export type { + ClaudeRemoteWorkspaceRuntimeOptions, +} from "./workspace-claude-runtime"; +export { + CodexRemoteWorkspaceRuntimeFactory, +} from "./workspace-codex-runtime"; +export type { + CodexRemoteWorkspaceRuntimeOptions, +} from "./workspace-codex-runtime"; +export { + resolveCodexLinuxSandboxBinary, + codexRemotePermissionProfileCompatibility, +} from "./workspace-codex-sandbox"; +export { + pinRemoteWorkspaceNativeHelper, + discoverRemoteWorkspaceNativeHelper, + parseRemoteWorkspaceNativeHelperDescriptor, + linuxRemoteWorkspaceCommandArgv, + createLinuxRemoteWorkspaceCommandRunner, + createNativeRemoteWorkspaceCommandRunner, + nativeRemoteWorkspaceCommandRunnerAvailable, + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandRunnerAvailable, +} from "./workspace-command-runner"; +export type { + LinuxRemoteWorkspaceCommandRunnerOptions, + RemoteWorkspaceNativeHelperDescriptor, + NativeRemoteWorkspaceCommandRunnerOptions, +} from "./workspace-command-runner"; +export { + remoteWorkspaceThreadStartParams, + RemoteWorkspaceCoordinator, +} from "./workspace-coordinator"; +export type { + RemoteWorkspaceSessionBinding, + RemoteWorkspaceTransport, + AppServerDynamicToolRequest, + AppServerDynamicToolResponse, +} from "./workspace-coordinator"; +export { + REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + normalizeRemoteWorkspaceHubUrl, + parseRemoteWorkspaceDeviceState, + RemoteWorkspaceDeviceFileStore, + pairRemoteWorkspaceDevice, + remoteWorkspaceCapabilitiesForCommandRunner, + connectRemoteWorkspaceAgent, + runRemoteWorkspaceAgent, +} from "./workspace-device"; +export type { + RemoteWorkspaceDeviceRoot, + RemoteWorkspaceDeviceState, + RemoteWorkspaceDeviceStateStore, + PairRemoteWorkspaceDeviceOptions, + RemoteWorkspaceWebSocketLike, + RemoteWorkspaceWebSocketFactory, + RemoteWorkspaceAgentHandle, + RemoteWorkspaceAgentRunStatus, +} from "./workspace-device"; +export { + findExecutableOnPath, +} from "./workspace-executable"; +export { + validateRemoteWorkspaceRelativePath, + RemoteWorkspaceExecutor, +} from "./workspace-executor"; +export type { + RemoteWorkspaceRoot, + RemoteWorkspaceExecutionRequest, + RemoteWorkspaceExecutorOptions, + RemoteWorkspaceCommandRequest, + RemoteWorkspaceCommandResult, + RemoteWorkspaceCommandRunner, +} from "./workspace-executor"; +export { + REMOTE_WORKSPACE_HUB_STATE_VERSION, + REMOTE_WORKSPACE_MAX_DEVICES, + REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE, + RemoteWorkspacePairingRateLimitError, + parseRemoteWorkspaceHubState, + RemoteWorkspaceHubFileStore, + RemoteWorkspaceHub, +} from "./workspace-hub"; +export type { + RemoteWorkspaceRootAdvertisement, + RemoteWorkspaceStoredDevice, + RemoteWorkspaceHubState, + RemoteWorkspaceHubStateStore, + RemoteWorkspacePublicDevice, + RemoteWorkspacePairingGrant, + RemoteWorkspacePairDeviceInput, + RemoteWorkspacePairDeviceResult, +} from "./workspace-hub"; +export { + PiRemoteWorkspaceRuntimeFactory, +} from "./workspace-pi-runtime"; +export type { + PiRemoteWorkspaceRuntimeOptions, +} from "./workspace-pi-runtime"; +export { + remoteWorkspaceProcessInvocation, + waitForRemoteWorkspaceProcessExit, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + removeRemoteWorkspaceIsolation, +} from "./workspace-process"; +export type { + RemoteWorkspaceProcessInvocationOptions, + RemoteWorkspaceOwnedProcess, + StopRemoteWorkspaceProcessOptions, +} from "./workspace-process"; +export { + REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, + frameRemoteWorkspaceRpcMessage, + RemoteWorkspaceRpcReassembler, +} from "./workspace-rpc-framing"; +export { + EncryptedRemoteWorkspaceTransport, + EncryptedRemoteWorkspaceExecutorEndpoint, +} from "./workspace-rpc"; +export type { + EncryptedRemoteWorkspaceTransportOptions, + EncryptedRemoteWorkspaceExecutorEndpointOptions, +} from "./workspace-rpc"; +export { + REMOTE_WORKSPACE_SESSION_STATE_VERSION, + parseRemoteWorkspaceSessionState, + RemoteWorkspaceSessionFileStore, + RemoteWorkspaceSessionService, +} from "./workspace-sessions"; +export type { + RemoteWorkspaceSessionStatus, + RemoteWorkspaceAccessMode, + RemoteWorkspaceSessionEvent, + RemoteWorkspaceSessionSummary, + RemoteWorkspaceRuntimeHandle, + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceSessionState, + RemoteWorkspaceSessionStateStore, +} from "./workspace-sessions"; +export { + startRemoteWorkspaceToolBridge, +} from "./workspace-tool-bridge"; +export type { + RemoteWorkspaceToolBridge, +} from "./workspace-tool-bridge"; export { REMOTE_WORKSPACE_TOOL_NAMESPACE, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, @@ -76,25 +246,6 @@ export type { RemoteWorkspaceToolCallParams, RemoteWorkspaceToolResult, } from "./workspace-tools"; -export { - REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, - REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES, - isRemoteWorkspaceAgentProfile, - serializeRemoteWorkspaceHubMessage, - serializeRemoteWorkspaceAgentMessage, - parseRemoteWorkspaceHubMessage, - parseRemoteWorkspaceAgentMessage, -} from "./workspace-agent-protocol"; -export type { - RemoteWorkspaceAgentProfile, - RemoteWorkspaceHubMessage, - RemoteWorkspaceAgentMessage, -} from "./workspace-agent-protocol"; -export { - REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, - frameRemoteWorkspaceRpcMessage, - RemoteWorkspaceRpcReassembler, -} from "./workspace-rpc-framing"; export { truncateRemoteWorkspaceUtf8, } from "./workspace-utf8"; diff --git a/src/remote-control/workspace-agent-connection.ts b/src/remote-control/workspace-agent-connection.ts new file mode 100644 index 0000000000..095fcad6ca --- /dev/null +++ b/src/remote-control/workspace-agent-connection.ts @@ -0,0 +1,366 @@ +import type { RemoteControlIdentityKeyPair } from "./crypto"; +import { + RemoteControlClientHandshake, + acceptRemoteControlClientHello, +} from "./crypto"; +import type { RemoteWorkspaceExecutor } from "./workspace-executor"; +import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol"; +import { + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, +} from "./workspace-rpc"; +import { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + serializeRemoteWorkspaceAgentMessage, + serializeRemoteWorkspaceHubMessage, + type RemoteWorkspaceAgentProfile, +} from "./workspace-agent-protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; + +const SESSION_OPEN_TIMEOUT_MS = 10_000; + +export interface RemoteWorkspaceControlSocket { + send(value: string): void | Promise; + close(code: number, reason: string): void; +} + +interface PendingHubSession { + handshake: RemoteControlClientHandshake; + resolve(transport: EncryptedRemoteWorkspaceTransport): void; + reject(error: Error): void; + timer: ReturnType; +} + +function safeReason(value: string): string { + const cleaned = value.replace(/[\x00-\x1f\x7f]/g, " ").trim(); + const selected = cleaned || "remote workspace session closed"; + return truncateRemoteWorkspaceUtf8(selected, 120); +} + +/** Hub-side representation of one authenticated, online OCX-only executor. */ +export class RemoteWorkspaceHubAgentConnection { + private readonly pending = new Map(); + private readonly active = new Map(); + private readonly cancelledSessionIds = new Set(); + private closed = false; + private presenceAccepted = false; + private presencePending = false; + private currentCapabilities: RemoteWorkspaceCapability[]; + + constructor(private readonly options: { + deviceId: string; + devicePublicKey: string; + hubIdentity: RemoteControlIdentityKeyPair; + socket: RemoteWorkspaceControlSocket; + capabilities?: readonly RemoteWorkspaceCapability[]; + onCapabilities?: (capabilities: readonly RemoteWorkspaceCapability[]) => void; + sessionOpenTimeoutMs?: number; + }) { + this.currentCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + } + + isOnline(): boolean { + return !this.closed && this.presenceAccepted; + } + + capabilities(): RemoteWorkspaceCapability[] { + return [...this.currentCapabilities]; + } + + async openSession(options: { + sessionId: string; + rootId: string; + profile: RemoteWorkspaceAgentProfile; + capabilities: readonly RemoteWorkspaceCapability[]; + }): Promise { + if (!this.isOnline()) throw new Error("remote workspace executor is offline"); + if (this.pending.has(options.sessionId) || this.active.has(options.sessionId)) { + throw new Error("remote workspace session already exists"); + } + if (this.pending.size + this.active.size >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + if (!Array.isArray(options.capabilities)) throw new Error("remote workspace session requires explicit capabilities"); + const requestedCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + if (requestedCapabilities.some(capability => !this.currentCapabilities.includes(capability))) { + throw new Error("remote workspace session requests an unavailable capability"); + } + const handshake = RemoteControlClientHandshake.create({ + sessionId: options.sessionId, + deviceId: this.options.deviceId, + commandProfile: options.profile, + capabilities: requestedCapabilities, + accountPrivateKey: this.options.hubIdentity.privateKey, + }); + const timeoutMs = this.options.sessionOpenTimeoutMs ?? SESSION_OPEN_TIMEOUT_MS; + const opened = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(options.sessionId); + this.rememberCancelledSession(options.sessionId); + reject(new Error("remote workspace session handshake timed out")); + }, timeoutMs); + this.pending.set(options.sessionId, { handshake, resolve, reject, timer }); + }); + try { + await this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: options.rootId, + clientHello: handshake.hello, + })); + } catch (error) { + const pending = this.pending.get(options.sessionId); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(options.sessionId); + pending.reject(error instanceof Error ? error : new Error("remote workspace session send failed")); + } + } + return await opened; + } + + receive(raw: string | Uint8Array): void { + if (this.closed) throw new Error("remote workspace executor is offline"); + const message = parseRemoteWorkspaceAgentMessage(raw); + if (message.type === "presence") { + if (this.presenceAccepted || this.presencePending) { + throw new Error("remote workspace executor sent duplicate presence"); + } + const approved = parseRemoteWorkspaceCapabilities(this.options.capabilities); + const capabilities = parseRemoteWorkspaceCapabilities(message.capabilities.filter(capability => approved.includes(capability))); + this.presencePending = true; + const accept = () => { + if (this.closed) return; + this.options.onCapabilities?.(capabilities); + this.currentCapabilities = capabilities; + this.presenceAccepted = true; + this.presencePending = false; + }; + let sent: void | Promise; + try { + sent = this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence_ack", + capabilities, + })); + } catch (error) { + this.presencePending = false; + throw error; + } + if (sent && typeof sent.then === "function") { + void sent.then(accept).catch(() => this.close("remote workspace presence acknowledgement failed")); + } else { + accept(); + } + return; + } + if (!this.presenceAccepted) { + throw new Error("remote workspace executor presence is required before session traffic"); + } + if (message.type === "heartbeat") return; + if (message.type === "session_accept") { + const pending = this.pending.get(message.sessionId); + if (!pending) { + if (!this.cancelledSessionIds.delete(message.sessionId)) { + throw new Error("remote workspace accepted an unknown session"); + } + void Promise.resolve(this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_close", + sessionId: message.sessionId, + reason: "remote workspace session was already cancelled", + }))).catch(() => this.close("remote workspace cancelled-session cleanup failed")); + return; + } + const cipher = pending.handshake.complete(message.hostHello, this.options.devicePublicKey); + const transport = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: this.options.deviceId, + cipher, + sendCiphertext: value => this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "ciphertext", + sessionId: message.sessionId, + payload: value, + })), + }); + clearTimeout(pending.timer); + this.pending.delete(message.sessionId); + this.active.set(message.sessionId, transport); + pending.resolve(transport); + return; + } + if (message.type === "session_reject") { + const pending = this.pending.get(message.sessionId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.sessionId); + pending.reject(new Error(safeReason(message.reason))); + return; + } + const transport = this.active.get(message.sessionId); + if (!transport) throw new Error("remote workspace ciphertext targeted an unknown session"); + transport.receiveCiphertext(message.payload); + } + + async closeSession(sessionId: string, reason = "remote workspace session closed"): Promise { + const pending = this.pending.get(sessionId); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(sessionId); + this.rememberCancelledSession(sessionId); + pending.reject(new Error(safeReason(reason))); + } + const transport = this.active.get(sessionId); + if (transport) { + this.active.delete(sessionId); + transport.close(safeReason(reason)); + } + if (this.closed) return; + await this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_close", + sessionId, + reason: safeReason(reason), + })); + } + + close(reason = "remote workspace executor disconnected"): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(safeReason(reason))); + } + this.pending.clear(); + for (const transport of this.active.values()) transport.close(safeReason(reason)); + this.active.clear(); + this.cancelledSessionIds.clear(); + try { this.options.socket.close(1008, safeReason(reason)); } catch { /* socket is already gone */ } + } + + private rememberCancelledSession(sessionId: string): void { + this.cancelledSessionIds.add(sessionId); + while (this.cancelledSessionIds.size > 16) { + const oldest = this.cancelledSessionIds.values().next(); + if (oldest.done) break; + this.cancelledSessionIds.delete(oldest.value); + } + } +} + +/** Executor-side connection. It owns no Codex, Claude Code, Pi, provider key, or model session. */ +export class RemoteWorkspaceExecutorAgentConnection { + private readonly sessions = new Map(); + private closed = false; + + constructor(private readonly options: { + deviceId: string; + deviceIdentity: RemoteControlIdentityKeyPair; + hubPublicKey: string; + executor: RemoteWorkspaceExecutor; + capabilities?: readonly RemoteWorkspaceCapability[]; + onPresenceAccepted?: () => void; + socket: RemoteWorkspaceControlSocket; + }) { + this.currentCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + } + + private currentCapabilities: RemoteWorkspaceCapability[]; + + async receive(raw: string | Uint8Array): Promise { + if (this.closed) throw new Error("remote workspace agent connection is closed"); + const message = parseRemoteWorkspaceHubMessage(raw); + if (message.type === "presence_ack") { + if (message.capabilities.some(capability => !this.currentCapabilities.includes(capability))) { + throw new Error("remote workspace Hub acknowledged different executor capabilities"); + } + this.currentCapabilities = [...message.capabilities]; + this.options.onPresenceAccepted?.(); + return; + } + if (message.type === "session_open") { + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint | null = null; + try { + if (this.sessions.has(message.clientHello.sessionId)) { + throw new Error("remote workspace executor session already exists"); + } + if (this.sessions.size >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + if (message.clientHello.deviceId !== this.options.deviceId) { + throw new Error("remote workspace session targeted another executor"); + } + if (!this.options.executor.hasApprovedRoot(message.rootId)) { + throw new Error("remote workspace root is not approved"); + } + const accepted = acceptRemoteControlClientHello(message.clientHello, { + expectedSessionId: message.clientHello.sessionId, + expectedDeviceId: this.options.deviceId, + accountPublicKey: this.options.hubPublicKey, + devicePrivateKey: this.options.deviceIdentity.privateKey, + allowedCapabilities: this.currentCapabilities, + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: this.options.deviceId, + sessionId: message.clientHello.sessionId, + rootId: message.rootId, + capabilities: parseRemoteWorkspaceCapabilities(accepted.hello.capabilities), + cipher: accepted.cipher, + executor: this.options.executor, + sendCiphertext: value => this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "ciphertext", + sessionId: message.clientHello.sessionId, + payload: value, + })), + }); + this.sessions.set(message.clientHello.sessionId, endpoint); + await this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_accept", + sessionId: message.clientHello.sessionId, + hostHello: accepted.hello, + })); + } catch (error) { + if (endpoint) { + this.sessions.delete(message.clientHello.sessionId); + endpoint.close(); + } + await this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_reject", + sessionId: message.clientHello.sessionId, + reason: safeReason(error instanceof Error ? error.message : "remote workspace session refused"), + })); + } + return; + } + if (message.type === "session_close") { + this.sessions.get(message.sessionId)?.close(); + this.sessions.delete(message.sessionId); + return; + } + const endpoint = this.sessions.get(message.sessionId); + if (!endpoint) throw new Error("remote workspace ciphertext targeted an unknown executor session"); + // Decryption and counter validation happen synchronously before this returns. The execution + // promise is intentionally detached so an unencrypted session_close control frame can abort a + // long-running command instead of waiting behind that command on the socket's ordered queue. + void endpoint.receiveCiphertext(message.payload).catch(() => { + this.close(); + this.options.socket.close(1008, "remote workspace protocol error"); + }); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const endpoint of this.sessions.values()) endpoint.close(); + this.sessions.clear(); + } +} diff --git a/src/remote-control/workspace-claude-runtime.ts b/src/remote-control/workspace-claude-runtime.ts new file mode 100644 index 0000000000..243ee5e1da --- /dev/null +++ b/src/remote-control/workspace-claude-runtime.ts @@ -0,0 +1,243 @@ +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { remoteWorkspaceDeveloperInstructions } from "./workspace-tools"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, +} from "./workspace-process"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, +} from "./workspace-sessions"; + +const MAX_OUTPUT_LINE_BYTES = 2 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; + +function safeError(value: unknown, fallback: string): string { + return (value instanceof Error ? value.message : typeof value === "string" ? value : fallback) + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function assistantText(value: unknown): string | null { + const message = record(value); + if (!message || !Array.isArray(message.content)) return null; + const text = message.content.flatMap(raw => { + const part = record(raw); + return part?.type === "text" && typeof part.text === "string" ? [part.text] : []; + }).join(""); + return text || null; +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let retained = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + if (retained >= MAX_STDERR_BYTES) continue; + const chunk = next.value.subarray(0, MAX_STDERR_BYTES - retained); + chunks.push(chunk); + retained += chunk.byteLength; + } + } finally { + reader.releaseLock(); + } + const merged = new Uint8Array(retained); + let offset = 0; + for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder().decode(merged); +} + +export interface ClaudeRemoteWorkspaceRuntimeOptions { + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class ClaudeRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "claude" as const; + + constructor(private readonly options: ClaudeRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + const command = this.options.command && this.options.command.length > 0 + ? this.options.command[0] + : findExecutableOnPath("claude"); + return command + ? { available: true, ...(this.options.version ? { version: this.options.version } : {}) } + : { available: false, reason: "Claude Code is not installed on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const configuredCommand = this.options.command && this.options.command.length > 0 + ? [...this.options.command] + : null; + const executable = configuredCommand?.[0] ?? findExecutableOnPath("claude"); + if (!executable) throw new Error("Claude Code is not installed on this Hub"); + const commandPrefix = configuredCommand ?? [executable]; + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-claude-")); + try { + chmodSync(isolation, 0o700); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const threadId = options.resumeThreadId ?? randomUUID(); + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const mcpPath = join(isolation, "mcp.json"); + try { + writeFileSync(mcpPath, `${JSON.stringify({ + mcpServers: { + ocx_remote_workspace: { + type: "http", + url: `${bridge.url}/mcp`, + headers: { Authorization: `Bearer ${bridge.token}` }, + }, + }, + })}\n`, { mode: 0o600 }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + let firstTurn = options.resumeThreadId === undefined; + let active: Bun.Subprocess<"pipe", "pipe", "pipe"> | null = null; + let stopped = false; + let stopOperation: Promise | null = null; + + const runPrompt = async (text: string): Promise => { + if (stopped) throw new Error("Claude Remote Workspace session is stopped"); + if (active) throw new Error("Claude Remote Workspace turn is already active"); + const args = [ + ...commandPrefix, + "-p", + "--input-format", "text", + "--output-format", "stream-json", + "--verbose", + "--strict-mcp-config", + "--mcp-config", mcpPath, + "--setting-sources", "", + "--tools", "", + "--allowedTools", "mcp__ocx_remote_workspace__*", + "--permission-mode", "dontAsk", + "--disable-slash-commands", + "--no-chrome", + "--system-prompt", remoteWorkspaceDeveloperInstructions(options.deviceName, options.tools), + firstTurn ? "--session-id" : "--resume", + threadId, + ]; + const childEnv = { ...process.env, ...this.options.env }; + const invocation = remoteWorkspaceProcessInvocation(args, { env: childEnv }); + const child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + active = child; + try { + child.stdin.write(text); + child.stdin.end(); + } catch (error) { + await stopRemoteWorkspaceProcess(child); + if (active === child) active = null; + throw error; + } + const stderrPromise = drain(child.stderr); + const reader = child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + let emittedAssistant = false; + let resultError: string | null = null; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_OUTPUT_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Claude Code output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_OUTPUT_LINE_BYTES) throw new Error("Claude Code output line is too large"); + if (line) { + const event = record(JSON.parse(line)); + if (event?.type === "assistant") { + const answer = assistantText(event.message); + if (answer) { options.emit("assistant", answer); emittedAssistant = true; } + } + if (event?.type === "result") { + if (event.is_error === true) resultError = safeError(event.result, "Claude Code turn failed"); + else if (!emittedAssistant && typeof event.result === "string" && event.result) { + options.emit("assistant", event.result); + emittedAssistant = true; + } + } + } + newline = buffer.indexOf("\n"); + } + } + const exitCode = await child.exited; + const stderr = await stderrPromise; + if (resultError) throw new Error(resultError); + if (exitCode !== 0) throw new Error(safeError(stderr, `Claude Code exited with code ${exitCode}`)); + firstTurn = false; + } catch (error) { + await stopRemoteWorkspaceProcess(child); + await stderrPromise.catch(() => ""); + throw error; + } finally { + reader.releaseLock(); + if (active === child) active = null; + } + }; + + return { + threadId, + canResume: () => !firstTurn, + prompt: runPrompt, + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + const child = active; + stopOperation = runRemoteWorkspaceCleanupSteps([ + async () => { if (child) await stopRemoteWorkspaceProcess(child); }, + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } +} diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts new file mode 100644 index 0000000000..b064e3c9b8 --- /dev/null +++ b/src/remote-control/workspace-codex-runtime.ts @@ -0,0 +1,531 @@ +import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { resolveCodexRuntime } from "../codex/runtime"; +import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; +import { REMOTE_WORKSPACE_TOOL_NAMESPACE } from "./workspace-tools"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + waitForRemoteWorkspaceProcessExit, +} from "./workspace-process"; +import { + codexRemotePermissionProfileCompatibility, + resolveCodexLinuxSandboxBinary, +} from "./workspace-codex-sandbox"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, + RemoteWorkspaceSessionEvent, +} from "./workspace-sessions"; + +const MAX_JSON_LINE_BYTES = 2 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; +const MAX_BUFFERED_ASSISTANT_ITEMS = 32; +const MAX_BUFFERED_ASSISTANT_BYTES = 64 * 1024; +const MAX_EARLY_TURN_COMPLETIONS = 16; +const START_TIMEOUT_MS = 15_000; +const REQUEST_TIMEOUT_MS = 60_000; + +interface JsonRpcMessage { + id?: string | number; + method?: string; + params?: Record; + result?: Record; + error?: { message?: unknown }; +} + +interface PendingRpc { + resolve(message: JsonRpcMessage): void; + reject(error: Error): void; + timer: ReturnType; +} + +function parseJsonRpcMessage(value: unknown): JsonRpcMessage { + const raw = object(value); + if (!raw) throw new Error("invalid Codex App Server message"); + if (raw.id !== undefined && typeof raw.id !== "string" && typeof raw.id !== "number") { + throw new Error("invalid Codex App Server message ID"); + } + if (raw.method !== undefined && typeof raw.method !== "string") { + throw new Error("invalid Codex App Server method"); + } + const params = raw.params === undefined ? undefined : object(raw.params); + const result = raw.result === undefined ? undefined : object(raw.result); + const error = raw.error === undefined ? undefined : object(raw.error); + if ((raw.params !== undefined && !params) + || (raw.result !== undefined && !result) + || (raw.error !== undefined && !error)) { + throw new Error("invalid Codex App Server message fields"); + } + return { + ...(raw.id !== undefined ? { id: raw.id } : {}), + ...(typeof raw.method === "string" ? { method: raw.method } : {}), + ...(params ? { params } : {}), + ...(result ? { result } : {}), + ...(error ? { error: { message: error.message } } : {}), + }; +} + +function errorMessage(value: unknown, fallback: string): string { + const raw = value instanceof Error ? value.message : typeof value === "string" ? value : fallback; + return raw.replace(/[^\x20-\x7e\n\t]/g, " ").slice(0, 4_096) || fallback; +} + +function object(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function nestedString(value: unknown, keys: readonly string[]): string | null { + let current: unknown = value; + for (const key of keys) current = object(current)?.[key]; + return typeof current === "string" && current.length > 0 ? current : null; +} + +function itemText(value: unknown): string | null { + const item = object(value); + if (!item) return null; + if (typeof item.text === "string" && item.text.length > 0) return item.text; + if (!Array.isArray(item.content)) return null; + const parts: string[] = []; + for (const raw of item.content) { + const part = object(raw); + const text = part && typeof part.text === "string" ? part.text : null; + if (text) parts.push(text); + } + return parts.length > 0 ? parts.join("") : null; +} + +function appendBoundedUtf8(current: string, delta: string, maximum: number): string { + const marker = "\n[truncated]"; + if (current.endsWith(marker)) return current; + const combined = `${current}${delta}`; + if (Buffer.byteLength(combined, "utf8") <= maximum) return combined; + const bodyLimit = maximum - Buffer.byteLength(marker, "utf8"); + return `${truncateRemoteWorkspaceUtf8(combined, bodyLimit)}${marker}`; +} + +function setBounded(map: Map, key: K, value: V, maximum: number): void { + if (!map.has(key) && map.size >= maximum) { + const oldest = map.keys().next(); + if (!oldest.done) map.delete(oldest.value); + } + map.set(key, value); +} + +class JsonLineRpcProcess { + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + private closeError: Error | null = null; + + onRequest: ((message: JsonRpcMessage) => Promise) | null = null; + onNotification: ((message: JsonRpcMessage) => void) | null = null; + onClose: ((error: Error) => void) | null = null; + + constructor(private readonly child: Bun.Subprocess<"pipe", "pipe", "pipe">) { + void this.readStdout(); + void this.drainStderr(); + void child.exited.then(code => this.fail(new Error(`Codex App Server exited with code ${code}`))); + } + + request(method: string, params: Record, timeoutMs = REQUEST_TIMEOUT_MS): Promise { + if (this.closed) return Promise.reject(this.closeError ?? new Error("Codex App Server is closed")); + const id = ++this.nextId; + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Codex App Server ${method} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + }); + try { + this.send({ jsonrpc: "2.0", id, method, params }); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error instanceof Error ? error : new Error("Codex App Server write failed")); + } + } + return result; + } + + notify(method: string, params: Record): void { + this.send({ jsonrpc: "2.0", method, params }); + } + + async close(): Promise { + try { + if (!this.closed) { + try { this.child.stdin.end(); } catch { /* child already closed */ } + } + const graceful = await waitForRemoteWorkspaceProcessExit(this.child, 1_500); + if (!graceful) { + await stopRemoteWorkspaceProcess(this.child); + } + } finally { + // Pending callers must settle even if the OS refuses to reap the child. + this.fail(new Error("Codex App Server session closed")); + } + } + + private send(message: Record): void { + if (this.closed) throw this.closeError ?? new Error("Codex App Server is closed"); + const line = `${JSON.stringify(message)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Codex App Server message is too large"); + this.child.stdin.write(line); + this.child.stdin.flush(); + } + + private async readStdout(): Promise { + const reader = this.child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_JSON_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Codex App Server output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Codex App Server output line is too large"); + if (line) this.receive(parseJsonRpcMessage(JSON.parse(line))); + newline = buffer.indexOf("\n"); + } + } + } catch (error) { + void stopRemoteWorkspaceProcess(this.child).catch(() => {}); + this.fail(new Error(errorMessage(error, "Codex App Server output failed"))); + } finally { + reader.releaseLock(); + } + } + + private async drainStderr(): Promise { + const reader = this.child.stderr.getReader(); + let retained = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + retained = Math.min(MAX_STDERR_BYTES, retained + next.value.byteLength); + } + } catch { + // stdout and the exit code own the user-visible process failure. + } finally { + reader.releaseLock(); + void retained; + } + } + + private receive(message: JsonRpcMessage): void { + if (!message || typeof message !== "object") throw new Error("invalid Codex App Server message"); + if (message.id !== undefined && typeof message.method !== "string") { + const pending = this.pending.get(message.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(errorMessage(message.error.message, "Codex App Server request failed"))); + else pending.resolve(message); + return; + } + if (typeof message.method !== "string") return; + if (message.id === undefined) { + this.onNotification?.(message); + return; + } + const id = message.id; + const request = this.onRequest; + if (!request) { + this.send({ jsonrpc: "2.0", id, error: { code: -32_601, message: "client request handler is unavailable" } }); + return; + } + void request(message).then( + response => this.send({ jsonrpc: "2.0", ...response }), + error => this.send({ + jsonrpc: "2.0", + id, + error: { code: -32_000, message: errorMessage(error, "Remote Workspace tool failed") }, + }), + ); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + this.closeError = error; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + this.onClose?.(error); + } +} + +interface ActiveTurn { + id: string; + resolve(): void; + reject(error: Error): void; +} + +export interface CodexRemoteWorkspaceRuntimeOptions { + /** Test seam. Production resolves the configured, trusted Codex runtime. */ + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "codex" as const; + + constructor(private readonly options: CodexRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + if (this.options.command && this.options.command.length > 0) { + return { available: true, version: this.options.version ?? "test" }; + } + const resolved = resolveCodexRuntime(); + const compatibility = codexRemotePermissionProfileCompatibility(); + if (!compatibility.compatible) return { available: false, reason: compatibility.reason }; + return resolved.runtime.version + ? { available: true, version: resolved.runtime.version } + : { available: false, reason: "Codex CLI is not installed or runnable on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const command = this.options.command + ? [...this.options.command] + : [resolveCodexRuntime().runtime.command]; + if (command.length < 1) throw new Error("Codex CLI is unavailable on this Hub"); + const executablePath = isAbsolute(command[0]!) ? command[0]! : findExecutableOnPath(command[0]!); + if (!executablePath) throw new Error("Codex CLI executable could not be resolved on this Hub"); + command[0] = executablePath; + const runtimeDirectory = dirname(realpathSync(executablePath)); + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-codex-")); + let processPath = process.env.PATH ?? "/usr/bin:/bin"; + const runtimeReadPaths = [runtimeDirectory]; + try { + chmodSync(isolation, 0o700); + if (process.platform === "linux") { + const native = resolveCodexLinuxSandboxBinary(executablePath); + if (!native) { + throw new Error("Codex Remote Workspace could not locate the native Linux permission-profile helper"); + } + const helperDir = join(isolation, "sandbox-bin"); + mkdirSync(helperDir, { mode: 0o700 }); + const helper = join(helperDir, "codex-linux-sandbox"); + try { linkSync(native, helper); } + catch { symlinkSync(native, helper); } + processPath = `${helperDir}:${processPath}`; + runtimeReadPaths.push(dirname(native), helperDir); + } + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const thread = { id: "" }; + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId: () => thread.id, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const tokenEnvVar = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; + const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; + const childEnv = { ...process.env, ...this.options.env, PATH: processPath, [tokenEnvVar]: bridge.token }; + const invocation = remoteWorkspaceProcessInvocation([ + ...command, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(tokenEnvVar)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=${JSON.stringify(options.tools)}`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { env: childEnv }); + let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const peer = new JsonLineRpcProcess(child); + let activeTurn: ActiveTurn | null = null; + let stopped = false; + const completedBeforeWait = new Map(); + const assistantDeltas = new Map(); + let stopOperation: Promise | null = null; + + const finishTurn = (turnId: string, status: string, detail: string | null): void => { + if (!activeTurn || activeTurn.id !== turnId) { + setBounded(completedBeforeWait, turnId, { status, error: detail }, MAX_EARLY_TURN_COMPLETIONS); + return; + } + const current = activeTurn; + activeTurn = null; + assistantDeltas.clear(); + if (status === "completed") current.resolve(); + else current.reject(new Error(detail ?? `Codex turn ${status}`)); + }; + + peer.onRequest = async message => { + if (message.method !== "item/tool/call" || message.id === undefined) { + throw new Error("unsupported Codex App Server client request"); + } + const tool = nestedString(message.params, ["tool"]) ?? "remote tool"; + options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`); + return options.coordinator.handle({ + method: "item/tool/call", + id: message.id, + params: message.params, + }); + }; + peer.onNotification = message => { + const params = message.params ?? {}; + if (message.method === "item/agentMessage/delta") { + const itemId = nestedString(params, ["itemId"]) ?? nestedString(params, ["item", "id"]); + const delta = nestedString(params, ["delta"]); + if (itemId && delta) { + setBounded( + assistantDeltas, + itemId, + appendBoundedUtf8(assistantDeltas.get(itemId) ?? "", delta, MAX_BUFFERED_ASSISTANT_BYTES), + MAX_BUFFERED_ASSISTANT_ITEMS, + ); + } + return; + } + if (message.method === "item/completed") { + const item = object(params.item); + const itemId = item && typeof item.id === "string" ? item.id : null; + const text = itemText(item) ?? (itemId ? assistantDeltas.get(itemId) ?? null : null); + if (itemId) assistantDeltas.delete(itemId); + if (text) options.emit("assistant", text); + return; + } + if (message.method === "turn/completed") { + const turn = object(params.turn); + const turnId = turn && typeof turn.id === "string" ? turn.id : null; + if (!turnId) return; + const status = typeof turn?.status === "string" ? turn.status : "failed"; + const detail = nestedString(turn, ["error", "message"]); + finishTurn(turnId, status, detail); + } + }; + peer.onClose = error => { + const current = activeTurn; + activeTurn = null; + completedBeforeWait.clear(); + assistantDeltas.clear(); + current?.reject(error); + }; + + try { + await peer.request("initialize", { + clientInfo: { name: "opencodex_remote_workspace", title: "OpenCodex Remote Workspace", version: "1" }, + capabilities: { experimentalApi: true }, + }, START_TIMEOUT_MS); + peer.notify("initialized", {}); + const effective = await peer.request("config/read", { cwd: isolation, includeLayers: false }, START_TIMEOUT_MS); + const effectiveConfig = object(effective.result?.config) ?? {}; + if (typeof effectiveConfig.sandbox_mode === "string" || effectiveConfig.sandbox_workspace_write) { + throw new Error("Codex Remote Workspace requires permission profiles; remove legacy sandbox_mode settings from the selected Codex profile first"); + } + const disabledServerNames = Object.keys(object(effectiveConfig.mcp_servers) ?? {}); + const disabledHookNames = Object.keys(object(effectiveConfig.hooks) ?? {}); + const threadParams = remoteWorkspaceThreadStartParams({ + executorName: options.deviceName, + coordinatorIsolationPath: isolation, + tools: options.tools, + mcp: { + url: `${bridge.url}/mcp`, + bearerTokenEnvVar: tokenEnvVar, + disabledServerNames, + disabledHookNames, + hubRuntimeReadPaths: runtimeReadPaths, + }, + }); + const { ephemeral: _startOnlyEphemeral, ...resumeParams } = threadParams; + const started = options.resumeThreadId + ? await peer.request("thread/resume", { ...resumeParams, threadId: options.resumeThreadId }, START_TIMEOUT_MS) + : await peer.request("thread/start", threadParams, START_TIMEOUT_MS); + const threadId = nestedString(started.result, ["thread", "id"]); + if (!threadId) throw new Error("Codex App Server returned no thread ID"); + if (options.resumeThreadId && threadId !== options.resumeThreadId) { + throw new Error("Codex App Server resumed a different Remote Workspace thread"); + } + thread.id = threadId; + + return { + threadId, + async prompt(text: string): Promise { + if (stopped) throw new Error("Codex Remote Workspace session is stopped"); + if (activeTurn) throw new Error("Codex Remote Workspace turn is already active"); + const startedTurn = await peer.request("turn/start", { + threadId, + input: [{ type: "text", text }], + approvalPolicy: "never", + }); + const turnId = nestedString(startedTurn.result, ["turn", "id"]); + if (!turnId) throw new Error("Codex App Server returned no turn ID"); + const early = completedBeforeWait.get(turnId); + if (early) { + completedBeforeWait.delete(turnId); + if (early.status === "completed") return; + throw new Error(early.error ?? `Codex turn ${early.status}`); + } + await new Promise((resolve, reject) => { activeTurn = { id: turnId, resolve, reject }; }); + }, + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + const turn = activeTurn; + stopOperation = runRemoteWorkspaceCleanupSteps([ + async () => { + if (turn) await peer.request("turn/interrupt", { threadId, turnId: turn.id }, 3_000).catch(() => {}); + }, + () => peer.close(), + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } catch (error) { + await peer.close().catch(() => {}); + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + } +} diff --git a/src/remote-control/workspace-codex-sandbox.ts b/src/remote-control/workspace-codex-sandbox.ts new file mode 100644 index 0000000000..21630db764 --- /dev/null +++ b/src/remote-control/workspace-codex-sandbox.ts @@ -0,0 +1,115 @@ +import { accessSync, constants, existsSync, openSync, closeSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"; +import { arch } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { inspectCodexShimBackingForCommand } from "../codex/shim"; +import { findExecutableOnPath } from "./workspace-executable"; +import { resolveCodexHomeDir } from "../codex/home"; + +function isNativeExecutable(path: string): boolean { + let descriptor: number | null = null; + try { + descriptor = openSync(path, "r"); + const header = Buffer.alloc(4); + if (readSync(descriptor, header, 0, header.length, 0) !== header.length) return false; + return header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + } catch { + return false; + } finally { + if (descriptor !== null) closeSync(descriptor); + } +} + +function packageRootForEntrypoint(path: string): string | null { + let current = dirname(path); + for (let depth = 0; depth < 10; depth += 1) { + const manifest = join(current, "package.json"); + if (existsSync(manifest)) { + try { + const parsed = JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }; + if (parsed.name === "@openai/codex") return current; + } catch { /* keep walking */ } + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +function checkedNative(path: string): string | null { + try { + const canonical = realpathSync(path); + if (!statSync(canonical).isFile() || !isNativeExecutable(canonical)) return null; + accessSync(canonical, constants.X_OK); + return canonical; + } catch { + return null; + } +} + +function generatedShimBacking(path: string): string | null { + try { + const source = readFileSync(path, "utf8"); + if (Buffer.byteLength(source, "utf8") > 128 * 1024 + || !source.includes("# opencodex codex autostart shim")) return null; + const match = /^exec '([^'\r\n]+)' "\$@"\s*$/m.exec(source); + return match?.[1] && isAbsolute(match[1]) ? match[1] : null; + } catch { + return null; + } +} + +/** + * Permission profiles invoke the same native Codex binary under argv[0] + * `codex-linux-sandbox`. npm and OpenCodex shims expose a JS/shell launcher instead, + * so resolve the package-owned native binary without executing or modifying the install. + */ +export function resolveCodexLinuxSandboxBinary(command: string): string | null { + if (process.platform !== "linux") return null; + const selected = isAbsolute(command) ? command : findExecutableOnPath(command); + if (!selected) return null; + const shim = inspectCodexShimBackingForCommand(selected); + const entrypoint = shim.status === "matched" + ? shim.backingPath + : generatedShimBacking(selected) ?? selected; + const direct = checkedNative(entrypoint); + if (direct) return direct; + let canonical: string; + try { canonical = realpathSync(entrypoint); } catch { return null; } + const root = packageRootForEntrypoint(canonical); + if (!root) return null; + const target = arch() === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"; + const packageName = arch() === "arm64" ? "codex-linux-arm64" : "codex-linux-x64"; + const candidates = [ + join(root, "node_modules", "@openai", packageName, "vendor", target, "bin", "codex"), + join(root, "vendor", target, "bin", "codex"), + ]; + for (const candidate of candidates) { + const native = checkedNative(candidate); + if (native) return native; + } + return null; +} + +export function codexRemotePermissionProfileCompatibility( + codexHome = resolveCodexHomeDir(), +): { compatible: boolean; reason?: string } { + const configPath = join(codexHome, "config.toml"); + if (!existsSync(configPath)) return { compatible: true }; + try { + const metadata = statSync(configPath); + if (!metadata.isFile() || metadata.size > 4 * 1024 * 1024) { + return { compatible: false, reason: "Codex config cannot be safely inspected for Remote Workspace permissions." }; + } + const config = Bun.TOML.parse(readFileSync(configPath, "utf8")) as Record; + if (typeof config.sandbox_mode === "string" || config.sandbox_workspace_write !== undefined) { + return { + compatible: false, + reason: "Codex Remote Workspace needs permission profiles, but this Codex config still selects legacy sandbox_mode.", + }; + } + return { compatible: true }; + } catch { + return { compatible: false, reason: "Codex config could not be parsed for Remote Workspace permissions." }; + } +} diff --git a/src/remote-control/workspace-command-runner.ts b/src/remote-control/workspace-command-runner.ts new file mode 100644 index 0000000000..f9a3625caa --- /dev/null +++ b/src/remote-control/workspace-command-runner.ts @@ -0,0 +1,749 @@ +import { createHash } from "node:crypto"; +import { + accessSync, + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + opendirSync, + openSync, + readSync, + realpathSync, + statSync, +} from "node:fs"; +import { arch } from "node:os"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import type { + RemoteWorkspaceCommandRequest, + RemoteWorkspaceCommandResult, + RemoteWorkspaceCommandRunner, +} from "./workspace-executor"; + +const DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const NATIVE_HELPER_PROTOCOL_VERSION = 1; +const MAX_NATIVE_HELPER_BYTES = 64 * 1024 * 1024; +const MAX_NATIVE_HELPER_ERROR_CHARS = 512; +const MAX_NATIVE_HELPER_STDERR_BYTES = 16 * 1024; +const MAX_WORKSPACE_PREFLIGHT_ENTRIES = 250_000; +const SANDBOX_BUN_PATH = "/ocx-runtime/bin/bun"; +const READABLE_SYSTEM_PATHS = [ + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib64", +] as const; +const READABLE_ETC_PATHS = [ + "/etc/alternatives", + "/etc/ca-certificates", + "/etc/ssl", + "/etc/hosts", + "/etc/nsswitch.conf", + "/etc/passwd", + "/etc/group", + "/etc/localtime", + "/etc/resolv.conf", +] as const; + +export interface LinuxRemoteWorkspaceCommandRunnerOptions { + bubblewrapPath?: string; + networkAccess?: boolean; + /** Additional read-only toolchain trees explicitly approved by the device owner. */ + toolchainRoots?: readonly string[]; + /** Exact Bun executable used by OCX; mounted as one file rather than exposing its host directory. */ + runtimeExecutablePath?: string; + /** Writable roots inspected before command capability is advertised. */ + writableRoots?: readonly string[]; + spawn?: typeof Bun.spawn; + /** Cross-platform test seam for the real namespace capability probe. */ + probe?: (argv: readonly string[]) => boolean; +} + +export interface RemoteWorkspaceNativeHelperDescriptor { + path: string; + sha256: string; +} + +interface NativeHelperRequest { + version: typeof NATIVE_HELPER_PROTOCOL_VERSION; + operation: "probe" | "run"; + root?: string; + cwd?: string; + command?: string[]; + toolchainRoots?: string[]; + timeoutMs?: number; + maxOutputBytes?: number; + networkAccess?: boolean; +} + +interface NativeHelperProbeResponse { + version: typeof NATIVE_HELPER_PROTOCOL_VERSION; + ok: true; + probe: true; +} + +export interface NativeRemoteWorkspaceCommandRunnerOptions { + helper: RemoteWorkspaceNativeHelperDescriptor; + toolchainRoots?: readonly string[]; + /** Writable workspace roots that must never contain the executable enforcing their sandbox. */ + writableRoots: readonly string[]; + networkAccess?: boolean; + platform?: NodeJS.Platform; + spawn?: typeof Bun.spawn; + spawnSync?: typeof Bun.spawnSync; + /** Pure test seam. Production always executes the digest-pinned helper's real probe. */ + probe?: (request: NativeHelperRequest) => unknown; +} + +const availabilityCache = new Map(); + +function exactObject(value: unknown, keys: readonly string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("remote workspace native helper returned an invalid response"); + } + const record = value as Record; + const allowed = new Set(keys); + if (Object.keys(record).some(key => !allowed.has(key))) { + throw new Error("remote workspace native helper returned an invalid response"); + } + return record; +} + +function parseNativeHelperProbeResponse(value: unknown): NativeHelperProbeResponse { + const raw = exactObject(value, ["version", "ok", "probe"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== true || raw.probe !== true) { + throw new Error("remote workspace native helper failed its confinement probe"); + } + return { version: NATIVE_HELPER_PROTOCOL_VERSION, ok: true, probe: true }; +} + +function boundedBase64(value: unknown, label: string, maximum: number): Buffer { + if (typeof value !== "string" || value.length > Math.ceil(maximum / 3) * 4 + 4 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error(`remote workspace native helper returned invalid ${label}`); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.byteLength > maximum || decoded.toString("base64") !== value) { + throw new Error(`remote workspace native helper returned invalid ${label}`); + } + return decoded; +} + +function parseNativeHelperCommandResponse(value: unknown, maximum: number): RemoteWorkspaceCommandResult { + const raw = exactObject(value, ["version", "ok", "exitCode", "stdoutBase64", "stderrBase64"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== true + || typeof raw.exitCode !== "number" || !Number.isSafeInteger(raw.exitCode) + || raw.exitCode < -2_147_483_648 || raw.exitCode > 4_294_967_295) { + throw new Error("remote workspace native helper returned an invalid command result"); + } + const stdout = boundedBase64(raw.stdoutBase64, "stdout", maximum); + const stderr = boundedBase64(raw.stderrBase64, "stderr", maximum); + if (stdout.byteLength + stderr.byteLength > maximum) { + throw new Error("remote workspace native helper exceeded its output contract"); + } + const decoder = new TextDecoder("utf-8", { fatal: false }); + return { + exitCode: raw.exitCode, + stdout: decoder.decode(stdout), + stderr: decoder.decode(stderr), + }; +} + +function parseNativeHelperJson(value: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error("remote workspace native helper returned malformed JSON"); + } +} + +function sha256File(path: string): string { + const descriptor = openSync(path, constants.O_RDONLY); + try { + const metadata = fstatSync(descriptor); + if (!metadata.isFile() || metadata.size < 1 || metadata.size > MAX_NATIVE_HELPER_BYTES) { + throw new Error("remote workspace native helper has an invalid size"); + } + const hash = createHash("sha256"); + const chunk = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < metadata.size) { + const count = readSync(descriptor, chunk, 0, Math.min(chunk.byteLength, metadata.size - offset), offset); + if (count === 0) throw new Error("remote workspace native helper changed while hashing"); + hash.update(chunk.subarray(0, count)); + offset += count; + } + const after = fstatSync(descriptor); + if (after.size !== metadata.size || after.mtimeMs !== metadata.mtimeMs + || after.dev !== metadata.dev || after.ino !== metadata.ino) { + throw new Error("remote workspace native helper changed while hashing"); + } + return hash.digest("hex"); + } finally { + closeSync(descriptor); + } +} + +export function pinRemoteWorkspaceNativeHelper(path: string): RemoteWorkspaceNativeHelperDescriptor { + if (!isAbsolute(path) || path.includes("\0")) { + throw new Error("remote workspace native helper must be an absolute path"); + } + const linked = lstatSync(path); + if (!linked.isFile() || linked.isSymbolicLink()) { + throw new Error("remote workspace native helper must remain a real file"); + } + const canonical = realpathSync(path); + accessSync(canonical, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (process.platform !== "win32" && (statSync(canonical).mode & 0o022) !== 0) { + throw new Error("remote workspace native helper must not be group or world writable"); + } + return { path: canonical, sha256: sha256File(canonical) }; +} + +export function discoverRemoteWorkspaceNativeHelper(options: { + platform?: NodeJS.Platform; + architecture?: string; +} = {}): RemoteWorkspaceNativeHelperDescriptor | undefined { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "win32") return undefined; + const architecture = options.architecture ?? arch(); + const executable = platform === "win32" + ? "opencodex-remote-workspace-helper.exe" + : "opencodex-remote-workspace-helper"; + const candidates = [ + // Signed release bundles place the helper here. + `${import.meta.dir}/../../native-bin/${platform}-${architecture}/${executable}`, + // Source/private-dogfood builds produced by `bun run build:remote-workspace-helper`. + `${import.meta.dir}/../../native/remote-workspace-helper/target/release/${executable}`, + ]; + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + try { + return pinRemoteWorkspaceNativeHelper(candidate); + } catch { + return undefined; + } + } + return undefined; +} + +export function parseRemoteWorkspaceNativeHelperDescriptor(value: unknown): RemoteWorkspaceNativeHelperDescriptor { + const raw = exactObject(value, ["path", "sha256"]); + if (typeof raw.path !== "string" || !isAbsolute(raw.path) || raw.path.includes("\0") || raw.path.length > 4096 + || typeof raw.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(raw.sha256)) { + throw new Error("invalid remote workspace native helper descriptor"); + } + return { path: raw.path, sha256: raw.sha256 }; +} + +function assertNativeHelperIntegrity(value: RemoteWorkspaceNativeHelperDescriptor): RemoteWorkspaceNativeHelperDescriptor { + const helper = parseRemoteWorkspaceNativeHelperDescriptor(value); + const linked = lstatSync(helper.path); + if (!linked.isFile() || linked.isSymbolicLink() || realpathSync(helper.path) !== helper.path) { + throw new Error("remote workspace native helper identity changed; pair it again"); + } + accessSync(helper.path, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (process.platform !== "win32" && (linked.mode & 0o022) !== 0) { + throw new Error("remote workspace native helper permissions are unsafe"); + } + if (sha256File(helper.path) !== helper.sha256) { + throw new Error("remote workspace native helper digest changed; pair it again"); + } + return helper; +} + +function assertNativeHelperOutsideWritableRoots( + helper: RemoteWorkspaceNativeHelperDescriptor, + roots: readonly string[], +): string[] { + if (roots.length < 1 || roots.length > 32) { + throw new Error("remote workspace native runner needs one to 32 writable roots"); + } + const canonicalRoots: string[] = []; + for (const root of roots) { + if (!isAbsolute(root) || root.includes("\0")) { + throw new Error("remote workspace writable root must be an absolute path"); + } + const canonicalRoot = realpathSync(root); + if (canonicalRoots.includes(canonicalRoot)) { + throw new Error("remote workspace native runner received a duplicate writable root"); + } + if (inside(canonicalRoot, helper.path)) { + // A sandboxed command can write anywhere below its approved root. Executing the sandbox + // helper from that same tree would turn the hash-then-spawn pathname into a writable trust + // anchor that a workspace command can replace before a later invocation. + throw new Error("remote workspace native helper must be outside every writable workspace root"); + } + canonicalRoots.push(canonicalRoot); + } + return canonicalRoots; +} + +function nativeHelperEnvironment(platform: NodeJS.Platform): Record { + const result: Record = {}; + const names = platform === "win32" + ? ["SystemRoot", "WINDIR", "TEMP", "TMP"] + : ["TMPDIR"]; + for (const name of names) { + const value = process.env[name]; + if (value) result[name] = value; + } + return result; +} + +function inside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function assertWorkspaceHasNoExternalHardlinkAliases(root: string): void { + const canonicalRoot = realpathSync(root); + const pending = [canonicalRoot]; + let entries = 0; + while (pending.length > 0) { + const current = pending.pop()!; + const directory = opendirSync(current); + try { + for (;;) { + const entry = directory.readSync(); + if (!entry) break; + entries += 1; + if (entries > MAX_WORKSPACE_PREFLIGHT_ENTRIES) { + throw new Error("remote workspace is too large for safe command preflight"); + } + const target = join(current, entry.name); + const metadata = lstatSync(target); + if (metadata.isDirectory() && !metadata.isSymbolicLink()) { + pending.push(target); + } else if (!metadata.isDirectory() && metadata.nlink > 1) { + // A bind mount or Seatbelt path rule cannot distinguish two names for one inode. Reject + // rather than let a workspace alias read or mutate a file whose other name is outside. + throw new Error("remote workspace command root contains a hard-linked file"); + } + } + } finally { + directory.closeSync(); + } + } +} + +function assertCommandRootsSafe(roots: readonly string[]): void { + for (const root of roots) assertWorkspaceHasNoExternalHardlinkAliases(root); +} + +function sandboxPath(root: string, cwd: string): string { + if (!inside(root, cwd)) throw new Error("remote workspace command cwd escaped its root"); + const rel = relative(root, cwd); + return rel ? `/workspace/${rel.split(sep).join("/")}` : "/workspace"; +} + +function bindArgs(flag: "--ro-bind" | "--ro-bind-try", paths: readonly string[]): string[] { + const result: string[] = []; + for (const path of paths) { + if (flag === "--ro-bind-try" || existsSync(path)) result.push(flag, path, path); + } + return result; +} + +function approvedToolchainRoots(values: readonly string[]): string[] { + const result: string[] = []; + for (const value of values) { + if (!isAbsolute(value) || !existsSync(value) || value.includes("\0")) { + throw new Error("remote workspace toolchain root must be an existing absolute path"); + } + const metadata = lstatSync(value); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace toolchain root must remain a real directory"); + } + result.push(realpathSync(value)); + } + return [...new Set(result)]; +} + +function approvedRuntimeExecutable(value: string | undefined): string | null { + if (value === undefined) return null; + if (!isAbsolute(value) || value.includes("\0")) { + throw new Error("remote workspace runtime executable must be an absolute path"); + } + const canonical = realpathSync(value); + if (!statSync(canonical).isFile()) throw new Error("remote workspace runtime executable must be a file"); + accessSync(canonical, constants.X_OK); + return canonical; +} + +function trustedBubblewrap(path: string, roots: readonly string[]): string { + if (!isAbsolute(path)) throw new Error("bubblewrap must be an absolute executable path"); + const canonical = realpathSync(path); + const file = lstatSync(canonical); + if (!file.isFile() || file.nlink !== 1) throw new Error("bubblewrap must be a private executable file"); + for (const root of roots) { + if (inside(realpathSync(root), canonical)) { + throw new Error("bubblewrap must be outside every writable workspace root"); + } + } + accessSync(canonical, constants.X_OK); + let current = canonical; + for (;;) { + const metadata = lstatSync(current); + if (process.platform !== "win32" && (metadata.mode & 0o022) !== 0) { + throw new Error("bubblewrap executable and parent directories must not be group or world writable"); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return canonical; +} + +export function linuxRemoteWorkspaceCommandArgv( + request: RemoteWorkspaceCommandRequest, + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): string[] { + const bubblewrap = trustedBubblewrap(options.bubblewrapPath ?? "/usr/bin/bwrap", [...(options.writableRoots ?? []), request.root]); + const toolchains = approvedToolchainRoots(options.toolchainRoots ?? []); + const runtimeExecutable = approvedRuntimeExecutable(options.runtimeExecutablePath); + const commandPath = [...(runtimeExecutable ? ["/ocx-runtime/bin"] : []), ...toolchains, DEFAULT_PATH].join(":"); + return [ + bubblewrap, + "--die-with-parent", + "--new-session", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ...(options.networkAccess === true ? [] : ["--unshare-net"]), + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + ...(runtimeExecutable ? [ + "--dir", "/ocx-runtime", + "--dir", "/ocx-runtime/bin", + "--ro-bind", runtimeExecutable, SANDBOX_BUN_PATH, + ] : []), + ...bindArgs("--ro-bind", READABLE_SYSTEM_PATHS), + ...bindArgs("--ro-bind-try", READABLE_ETC_PATHS), + ...toolchains.flatMap(path => ["--ro-bind", path, path]), + "--bind", request.root, "/workspace", + "--chdir", sandboxPath(request.root, request.cwd), + "--clearenv", + "--setenv", "HOME", "/workspace", + "--setenv", "PATH", commandPath, + "--setenv", "LANG", "C.UTF-8", + "--setenv", "LC_ALL", "C.UTF-8", + "--", + ...request.command, + ]; +} + +async function collectBoundedOutput( + stream: ReadableStream, + reserve: (bytes: number) => boolean, + onOverflow: () => void, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + if (!reserve(next.value.byteLength)) { + onOverflow(); + throw new Error("remote workspace command output limit exceeded"); + } + chunks.push(next.value); + total += next.value.byteLength; + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder("utf-8", { fatal: false }).decode(body); +} + +export function createLinuxRemoteWorkspaceCommandRunner( + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): RemoteWorkspaceCommandRunner { + const spawn = options.spawn ?? Bun.spawn; + return { + async run(request): Promise { + assertWorkspaceHasNoExternalHardlinkAliases(request.root); + const argv = linuxRemoteWorkspaceCommandArgv(request, options); + const child = spawn(argv, { + cwd: request.root, + env: { PATH: DEFAULT_PATH, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let retained = 0; + let timedOut = false; + let overflowed = false; + let cancelled = false; + const stop = () => { + try { child.kill(); } catch { /* process already exited */ } + }; + const cancel = () => { + cancelled = true; + stop(); + }; + request.signal?.addEventListener("abort", cancel, { once: true }); + if (request.signal?.aborted) cancel(); + const reserve = (bytes: number): boolean => { + if (retained + bytes > request.maxOutputBytes) { + overflowed = true; + return false; + } + retained += bytes; + return true; + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, request.timeoutMs); + try { + const [stdoutResult, stderrResult, exitCode] = await Promise.allSettled([ + collectBoundedOutput(child.stdout, reserve, stop), + collectBoundedOutput(child.stderr, reserve, stop), + child.exited, + ]); + if (cancelled) throw new Error("remote workspace command was cancelled"); + if (timedOut) throw new Error("remote workspace command timed out"); + if (overflowed) throw new Error("remote workspace command output limit exceeded"); + if (stdoutResult.status === "rejected") throw stdoutResult.reason; + if (stderrResult.status === "rejected") throw stderrResult.reason; + if (exitCode.status === "rejected") throw exitCode.reason; + return { exitCode: exitCode.value, stdout: stdoutResult.value, stderr: stderrResult.value }; + } finally { + clearTimeout(timer); + request.signal?.removeEventListener("abort", cancel); + } + }, + }; +} + +function nativeHelperFailure(value: unknown): Error { + const raw = exactObject(value, ["version", "ok", "error"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== false + || typeof raw.error !== "string" || raw.error.length < 1 + || [...raw.error].length > MAX_NATIVE_HELPER_ERROR_CHARS || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(raw.error)) { + return new Error("remote workspace native helper returned an invalid failure"); + } + return new Error(raw.error); +} + +function nativeHelperRequest(options: NativeRemoteWorkspaceCommandRunnerOptions, request: RemoteWorkspaceCommandRequest): NativeHelperRequest { + return { + version: NATIVE_HELPER_PROTOCOL_VERSION, + operation: "run", + root: request.root, + cwd: request.cwd, + command: [...request.command], + toolchainRoots: approvedToolchainRoots(options.toolchainRoots ?? []), + timeoutMs: request.timeoutMs, + maxOutputBytes: request.maxOutputBytes, + networkAccess: options.networkAccess === true, + }; +} + +export function createNativeRemoteWorkspaceCommandRunner( + options: NativeRemoteWorkspaceCommandRunnerOptions, +): RemoteWorkspaceCommandRunner { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "win32") { + throw new Error("remote workspace native command helper is supported only on macOS and Windows"); + } + if (!nativeRemoteWorkspaceCommandRunnerAvailable(options)) { + throw new Error("remote workspace native command helper failed its confinement probe"); + } + const spawn = options.spawn ?? Bun.spawn; + return { + async run(request): Promise { + const helper = assertNativeHelperIntegrity(options.helper); + const writableRoots = assertNativeHelperOutsideWritableRoots(helper, options.writableRoots); + const requestRoot = realpathSync(request.root); + if (!writableRoots.includes(requestRoot)) { + throw new Error("remote workspace command root is outside the native runner grant"); + } + assertWorkspaceHasNoExternalHardlinkAliases(requestRoot); + const body = JSON.stringify(nativeHelperRequest(options, request)); + if (Buffer.byteLength(body, "utf8") > 64 * 1024) { + throw new Error("remote workspace native helper request is too large"); + } + const child = spawn([helper.path], { + cwd: request.root, + env: nativeHelperEnvironment(platform), + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }); + let retained = 0; + let overflowed = false; + let cancelled = false; + let timedOut = false; + const stop = () => { + try { child.kill(); } catch { /* helper already exited */ } + }; + const cancel = () => { + cancelled = true; + stop(); + }; + request.signal?.addEventListener("abort", cancel, { once: true }); + if (request.signal?.aborted) cancel(); + const maximumResponseBytes = Math.ceil(request.maxOutputBytes / 3) * 4 + 4_096; + const reserve = (bytes: number): boolean => { + if (retained + bytes > maximumResponseBytes + MAX_NATIVE_HELPER_STDERR_BYTES) { + overflowed = true; + return false; + } + retained += bytes; + return true; + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, request.timeoutMs + 2_000); + try { + if (!cancelled) { + child.stdin.write(body); + child.stdin.end(); + } + const [stdoutResult, stderrResult, exitResult] = await Promise.allSettled([ + collectBoundedOutput(child.stdout, reserve, stop), + collectBoundedOutput(child.stderr, reserve, stop), + child.exited, + ]); + if (cancelled) throw new Error("remote workspace command was cancelled"); + if (timedOut) throw new Error("remote workspace native helper timed out"); + if (overflowed) throw new Error("remote workspace native helper output limit exceeded"); + if (stdoutResult.status === "rejected" || stderrResult.status === "rejected" || exitResult.status === "rejected") { + throw new Error("remote workspace native helper failed"); + } + if (Buffer.byteLength(stdoutResult.value, "utf8") > maximumResponseBytes + || Buffer.byteLength(stderrResult.value, "utf8") > MAX_NATIVE_HELPER_STDERR_BYTES + || exitResult.value !== 0) { + throw new Error("remote workspace native helper failed"); + } + const response = parseNativeHelperJson(Buffer.from(stdoutResult.value, "utf8")); + if (response && typeof response === "object" && !Array.isArray(response) + && (response as Record).ok === false) { + throw nativeHelperFailure(response); + } + return parseNativeHelperCommandResponse(response, request.maxOutputBytes); + } finally { + clearTimeout(timer); + request.signal?.removeEventListener("abort", cancel); + try { child.stdin.end(); } catch { /* helper already closed stdin */ } + } + }, + }; +} + +export function nativeRemoteWorkspaceCommandRunnerAvailable( + options: NativeRemoteWorkspaceCommandRunnerOptions, +): boolean { + const platform = options.platform ?? process.platform; + if (platform !== "darwin") return false; // Windows awaits a surviving native cleanup owner. + try { + const helper = assertNativeHelperIntegrity(options.helper); + const writableRoots = assertNativeHelperOutsideWritableRoots(helper, options.writableRoots); + assertCommandRootsSafe(writableRoots); + const request: NativeHelperRequest = { version: NATIVE_HELPER_PROTOCOL_VERSION, operation: "probe" }; + const raw = options.probe + ? options.probe(request) + : (() => { + const result = (options.spawnSync ?? Bun.spawnSync)([helper.path], { + cwd: dirname(helper.path), + env: nativeHelperEnvironment(platform), + stdin: Buffer.from(JSON.stringify(request), "utf8"), + stdout: "pipe", + stderr: "ignore", + timeout: 8_000, + windowsHide: true, + }); + if (!result.success || result.stdout.byteLength > 4_096) { + throw new Error("remote workspace native helper probe failed"); + } + return parseNativeHelperJson(result.stdout); + })(); + parseNativeHelperProbeResponse(raw); + return true; + } catch { + return false; + } +} + +export function createPlatformRemoteWorkspaceCommandRunner(options: { + platform?: NodeJS.Platform; + linux?: LinuxRemoteWorkspaceCommandRunnerOptions; + native?: Omit; +} = {}): RemoteWorkspaceCommandRunner | undefined { + const platform = options.platform ?? process.platform; + if (platform === "linux" && linuxRemoteWorkspaceCommandRunnerAvailable(options.linux)) { + const linux = { + ...options.linux, + runtimeExecutablePath: options.linux?.runtimeExecutablePath ?? process.execPath, + }; + return createLinuxRemoteWorkspaceCommandRunner(linux); + } + if ((platform === "darwin" || platform === "win32") && options.native) { + const native = { ...options.native, platform }; + try { + return createNativeRemoteWorkspaceCommandRunner(native); + } catch { + return undefined; + } + } + return undefined; +} + +export function linuxRemoteWorkspaceCommandRunnerAvailable( + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): boolean { + let path: string; + try { + path = trustedBubblewrap(options.bubblewrapPath ?? "/usr/bin/bwrap", options.writableRoots ?? []); + if (options.writableRoots) assertCommandRootsSafe(options.writableRoots); + } catch { + return false; + } + const argv = [ + path, + "--die-with-parent", + "--new-session", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ...(options.networkAccess === true ? [] : ["--unshare-net"]), + "--proc", "/proc", + "--dev", "/dev", + ...bindArgs("--ro-bind", READABLE_SYSTEM_PATHS), + "--", + "/bin/true", + ]; + if (options.probe) return options.probe(argv); + const cacheKey = `${path}\0${options.networkAccess === true ? "network" : "isolated"}`; + const cached = availabilityCache.get(cacheKey); + if (cached !== undefined) return cached; + let available = false; + try { + available = Bun.spawnSync(argv, { + env: { PATH: DEFAULT_PATH, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + timeout: 2_000, + }).success; + } catch { + available = false; + } + availabilityCache.set(cacheKey, available); + return available; +} + diff --git a/src/remote-control/workspace-coordinator.ts b/src/remote-control/workspace-coordinator.ts new file mode 100644 index 0000000000..684746622a --- /dev/null +++ b/src/remote-control/workspace-coordinator.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation"; +import { + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + parseRemoteWorkspaceToolCall, + remoteWorkspaceCodexDeveloperInstructions, + remoteWorkspaceCapabilityForTool, + remoteWorkspaceDeveloperInstructions, + remoteWorkspaceToolsForCapabilities, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolCallParams, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; +import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor"; + +export interface RemoteWorkspaceSessionBinding { + sessionId: string; + threadId: string; + executorDeviceId: string; + executorName: string; + rootId: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; +} + +export interface RemoteWorkspaceTransport { + isOnline(deviceId: string): boolean; + invoke(request: RemoteWorkspaceExecutionRequest): Promise; +} + +export interface AppServerDynamicToolRequest { + method: "item/tool/call"; + id: string | number; + params: unknown; +} + +export interface AppServerDynamicToolResponse { + id: string | number; + result: { + contentItems: Array<{ type: "inputText"; text: string }>; + success: boolean; + }; +} + +function identifier(value: string, label: string): string { + if (value.length < 1 || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function resultText(result: RemoteWorkspaceToolResult): string { + const encoded = JSON.stringify(result); + if (Buffer.byteLength(encoded, "utf8") > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) { + return JSON.stringify({ ok: false, error: "remote workspace tool result exceeded the coordinator limit" }); + } + return encoded; +} + +export function remoteWorkspaceThreadStartParams(options: { + executorName: string; + coordinatorIsolationPath: string; + tools: readonly RemoteWorkspaceToolName[]; + platform?: NodeJS.Platform; + windowsSystemDirectory?: string; + mcp?: { + url: string; + bearerTokenEnvVar: string; + disabledServerNames?: readonly string[]; + disabledHookNames?: readonly string[]; + hubRuntimeReadPaths?: readonly string[]; + }; +}): Record { + if (!isAbsolute(options.coordinatorIsolationPath) || options.coordinatorIsolationPath.includes("\0")) { + throw new Error("remote workspace coordinator isolation path must be absolute"); + } + const platform = options.platform ?? process.platform; + const shellEnvironment = platform === "win32" + ? { + HOME: options.coordinatorIsolationPath, + USERPROFILE: options.coordinatorIsolationPath, + TEMP: options.coordinatorIsolationPath, + TMP: options.coordinatorIsolationPath, + PATH: options.windowsSystemDirectory ?? resolveTrustedWindowsSystemDirectory(), + } + : { + HOME: options.coordinatorIsolationPath, + PATH: platform === "darwin" ? "/usr/bin:/bin" : "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + }; + const config = options.mcp ? { + // A Remote Workspace thread may authenticate/model-call from the Hub, but every + // model-visible action must either be the one OCX MCP server or fail closed. + default_permissions: "ocx-remote-deny-local", + permissions: { + "ocx-remote-deny-local": { + description: "Deny Hub-local command filesystem and network access for Remote Workspace.", + filesystem: { + ":minimal": "read", + ":workspace_roots": { ".": "read" }, + ...Object.fromEntries((options.mcp.hubRuntimeReadPaths ?? []).map(path => [path, "read"])), + }, + network: { enabled: false }, + }, + }, + approval_policy: "never", + allow_login_shell: false, + shell_environment_policy: { + inherit: "none", + ignore_default_excludes: false, + set: shellEnvironment, + }, + web_search: "disabled", + tools: { view_image: false, web_search: false }, + agents: { enabled: false }, + apps: { _default: { enabled: false } }, + features: { + apps: false, + browser_use: false, + computer_use: false, + in_app_browser: false, + memories: false, + multi_agent: false, + plugins: false, + remote_plugin: false, + }, + memories: { use_memories: false, generate_memories: false }, + hooks: Object.fromEntries((options.mcp.disabledHookNames ?? []).map(name => [name, []])), + mcp_servers: { + ...Object.fromEntries((options.mcp.disabledServerNames ?? []) + .filter(name => name !== REMOTE_WORKSPACE_TOOL_NAMESPACE) + .map(name => [name, { enabled: false }])), + [REMOTE_WORKSPACE_TOOL_NAMESPACE]: { + enabled: true, + required: true, + url: options.mcp.url, + bearer_token_env_var: options.mcp.bearerTokenEnvVar, + enabled_tools: [...options.tools], + default_tools_approval_mode: "approve", + startup_timeout_sec: 5, + tool_timeout_sec: 65, + }, + }, + } : undefined; + return { + cwd: options.coordinatorIsolationPath, + runtimeWorkspaceRoots: [options.coordinatorIsolationPath], + approvalPolicy: "never", + ephemeral: false, + serviceName: "opencodex_remote_workspace", + developerInstructions: options.mcp + ? remoteWorkspaceCodexDeveloperInstructions(options.executorName, options.tools) + : remoteWorkspaceDeveloperInstructions(options.executorName, options.tools), + ...(config ? { config } : {}), + }; +} + +export class RemoteWorkspaceCoordinator { + private readonly sessions = new Map(); + + constructor(private readonly transport: RemoteWorkspaceTransport) {} + + register(binding: RemoteWorkspaceSessionBinding): () => void { + const capabilities = [...binding.capabilities]; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + if (tools.length < 1) throw new Error("remote workspace binding has no usable tools"); + const normalized: RemoteWorkspaceSessionBinding = { + sessionId: identifier(binding.sessionId, "session ID"), + threadId: identifier(binding.threadId, "thread ID"), + executorDeviceId: identifier(binding.executorDeviceId, "executor device ID"), + executorName: identifier(binding.executorName, "executor name"), + rootId: identifier(binding.rootId, "root ID"), + capabilities, + tools, + }; + if (this.sessions.has(normalized.threadId)) throw new Error("remote workspace thread is already bound"); + this.sessions.set(normalized.threadId, normalized); + return () => { + if (this.sessions.get(normalized.threadId)?.sessionId === normalized.sessionId) { + this.sessions.delete(normalized.threadId); + } + }; + } + + async handle(request: AppServerDynamicToolRequest): Promise { + if (request.method !== "item/tool/call") throw new Error("unsupported App Server request"); + let call: RemoteWorkspaceToolCallParams; + try { + call = parseRemoteWorkspaceToolCall(request.params); + } catch (error) { + return this.response(request.id, { ok: false, error: error instanceof Error ? error.message : "invalid remote tool call" }); + } + const binding = this.sessions.get(call.threadId); + if (!binding) return this.response(request.id, { ok: false, error: "remote workspace thread is not bound" }); + if (!binding.tools.includes(call.tool) + || !binding.capabilities.includes(remoteWorkspaceCapabilityForTool(call.tool))) { + return this.response(request.id, { ok: false, error: "remote workspace tool is not supported by this executor" }); + } + if (!this.transport.isOnline(binding.executorDeviceId)) { + return this.response(request.id, { ok: false, error: "remote executor is offline; local fallback is disabled" }); + } + let result: RemoteWorkspaceToolResult; + try { + result = await this.transport.invoke({ + requestId: randomUUID(), + sessionId: binding.sessionId, + executorDeviceId: binding.executorDeviceId, + rootId: binding.rootId, + tool: call.tool, + arguments: call.arguments, + }); + } catch { + result = { ok: false, error: "remote executor transport failed; local fallback is disabled" }; + } + return this.response(request.id, result); + } + + private response(id: string | number, result: RemoteWorkspaceToolResult): AppServerDynamicToolResponse { + return { + id, + result: { + contentItems: [{ type: "inputText", text: resultText(result) }], + success: result.ok, + }, + }; + } +} diff --git a/src/remote-control/workspace-device.ts b/src/remote-control/workspace-device.ts new file mode 100644 index 0000000000..40e42d817b --- /dev/null +++ b/src/remote-control/workspace-device.ts @@ -0,0 +1,585 @@ +import { createPrivateKey, createPublicKey, randomUUID, sign, verify } from "node:crypto"; +import { arch, hostname, platform } from "node:os"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteControlIdentityKeyPair, +} from "./crypto"; +import { RemoteWorkspaceExecutor } from "./workspace-executor"; +import { RemoteWorkspaceExecutorAgentConnection } from "./workspace-agent-connection"; +import { + createPlatformRemoteWorkspaceCommandRunner, + discoverRemoteWorkspaceNativeHelper, + parseRemoteWorkspaceNativeHelperDescriptor, + pinRemoteWorkspaceNativeHelper, + type RemoteWorkspaceNativeHelperDescriptor, +} from "./workspace-command-runner"; +import type { RemoteWorkspaceCommandRunner } from "./workspace-executor"; +import { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + serializeRemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_DEVICE_STATE_VERSION = 1 as const; +const DEVICE_TOKEN_PATTERN = /^ocxrw_[A-Za-z0-9_-]{43}$/; +const MAX_PAIR_RESPONSE_BYTES = 64 * 1024; +const MAX_DEVICE_STATE_BYTES = 1024 * 1024; +const PAIR_TIMEOUT_MS = 15_000; + +export interface RemoteWorkspaceDeviceRoot { + id: string; + label: string; + path: string; +} + +export interface RemoteWorkspaceDeviceState { + version: typeof REMOTE_WORKSPACE_DEVICE_STATE_VERSION; + hubUrl: string; + agentUrl: string; + deviceId: string; + deviceName: string; + devicePlatform: string; + capabilities: RemoteWorkspaceCapability[]; + deviceToken: string; + deviceIdentity: RemoteControlIdentityKeyPair; + hubPublicKey: string; + roots: RemoteWorkspaceDeviceRoot[]; + toolchainRoots: string[]; + nativeHelper?: RemoteWorkspaceNativeHelperDescriptor; +} + +export interface RemoteWorkspaceDeviceStateStore { + load(): RemoteWorkspaceDeviceState | null; + save(state: RemoteWorkspaceDeviceState): void; +} + +export interface PairRemoteWorkspaceDeviceOptions { + hubUrl: string; + pairingCode: string; + name?: string; + roots: Array<{ path: string; label?: string }>; + fetchImpl?: typeof fetch; + store?: RemoteWorkspaceDeviceStateStore; + devicePlatform?: string; + capabilities?: RemoteWorkspaceCapability[]; + toolchainRoots?: string[]; + nativeHelperPath?: string; +} + +export interface RemoteWorkspaceWebSocketLike { + readyState: number; + send(value: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: "open" | "close" | "error" | "message", listener: (event: Event | MessageEvent) => void): void; +} + +export type RemoteWorkspaceWebSocketFactory = ( + url: string, + headers: Record, +) => RemoteWorkspaceWebSocketLike; + +function boundedText(value: unknown, label: string, max: number): string { + if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function uuid(value: unknown, label: string): string { + const text = boundedText(value, label, 64); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) { + throw new Error(`invalid remote workspace ${label}`); + } + return text; +} + +function publicKey(value: unknown, label: string): string { + const encoded = boundedText(value, label, 1024); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error(`invalid remote workspace ${label}`); + const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error(`remote workspace ${label} must use Ed25519`); + return encoded; +} + +function identity(value: unknown): RemoteControlIdentityKeyPair { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device identity"); + const raw = value as Record; + const pub = publicKey(raw.publicKey, "device public key"); + const priv = boundedText(raw.privateKey, "device private key", 2048); + const privateKey = createPrivateKey({ key: Buffer.from(priv, "base64url"), type: "pkcs8", format: "der" }); + if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519"); + const challenge = Buffer.from("opencodex remote workspace device identity v1", "utf8"); + if (!verify( + null, + challenge, + createPublicKey({ key: Buffer.from(pub, "base64url"), type: "spki", format: "der" }), + sign(null, challenge, privateKey), + )) throw new Error("remote workspace device identity key pair does not match"); + return { publicKey: pub, privateKey: priv }; +} + +export function normalizeRemoteWorkspaceHubUrl(value: string): string { + const url = new URL(value); + const local = (url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.protocol === "http:"; + if (url.protocol !== "https:" && !local) throw new Error("remote workspace hub must use HTTPS"); + if (url.username || url.password || url.search || url.hash) throw new Error("remote workspace hub URL must not contain credentials or fragments"); + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + return url.toString().replace(/\/$/, ""); +} + +function agentUrlForHub(hubUrl: string): string { + const url = new URL("/remote-workspace/agent", `${hubUrl}/`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function validateRootInputs(values: Array<{ path: string; label?: string }>): RemoteWorkspaceDeviceRoot[] { + if (values.length < 1 || values.length > 32) throw new Error("remote workspace device needs one to 32 roots"); + const paths = new Set(); + const labels = new Set(); + return values.map(value => { + if (!isAbsolute(value.path) || value.path.includes("\0")) throw new Error("remote workspace root must be an absolute path"); + const metadata = lstatSync(value.path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); + const path = realpathSync(value.path); + const label = boundedText(value.label ?? basename(path), "root label", 80); + const folded = label.toLocaleLowerCase("en-US"); + if (paths.has(path) || labels.has(folded)) throw new Error("duplicate remote workspace root"); + paths.add(path); + labels.add(folded); + return { id: randomUUID(), label, path }; + }); +} + +function parseRoots(value: unknown): RemoteWorkspaceDeviceRoot[] { + if (!Array.isArray(value) || value.length < 1 || value.length > 32) throw new Error("invalid remote workspace device roots"); + const paths = new Set(); + const ids = new Set(); + return value.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device root"); + const raw = item as Record; + const id = uuid(raw.id, "root ID"); + const label = boundedText(raw.label, "root label", 80); + const path = boundedText(raw.path, "root path", 4096); + if (!isAbsolute(path) || ids.has(id) || paths.has(path)) throw new Error("invalid remote workspace device root"); + ids.add(id); + paths.add(path); + return { id, label, path }; + }); +} + +function validateToolchainRoots(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 16) throw new Error("invalid remote workspace toolchain roots"); + const paths = new Set(); + for (const candidate of value) { + if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) { + throw new Error("remote workspace toolchain root must be an absolute directory"); + } + const metadata = lstatSync(candidate); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace toolchain root must be a real directory"); + } + paths.add(realpathSync(candidate)); + } + return [...paths]; +} + +export function parseRemoteWorkspaceDeviceState(value: unknown): RemoteWorkspaceDeviceState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_DEVICE_STATE_VERSION) throw new Error("unsupported remote workspace device state"); + const hubUrl = normalizeRemoteWorkspaceHubUrl(boundedText(raw.hubUrl, "hub URL", 2048)); + const agentUrl = boundedText(raw.agentUrl, "agent URL", 2048); + if (agentUrl !== agentUrlForHub(hubUrl)) throw new Error("remote workspace agent URL does not match its hub"); + const deviceToken = boundedText(raw.deviceToken, "device token", 128); + if (!DEVICE_TOKEN_PATTERN.test(deviceToken)) throw new Error("invalid remote workspace device token"); + return { + version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + hubUrl, + agentUrl, + deviceId: uuid(raw.deviceId, "device ID"), + deviceName: boundedText(raw.deviceName, "device name", 80), + devicePlatform: boundedText(raw.devicePlatform, "device platform", 80), + capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities), + deviceToken, + deviceIdentity: identity(raw.deviceIdentity), + hubPublicKey: publicKey(raw.hubPublicKey, "hub public key"), + roots: parseRoots(raw.roots), + toolchainRoots: validateToolchainRoots(raw.toolchainRoots), + ...(raw.nativeHelper === undefined + ? {} + : { nativeHelper: parseRemoteWorkspaceNativeHelperDescriptor(raw.nativeHelper) }), + }; +} + +export class RemoteWorkspaceDeviceFileStore implements RemoteWorkspaceDeviceStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-device.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceDeviceState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_DEVICE_STATE_BYTES) { + throw new Error("remote workspace device state is too large"); + } + return parseRemoteWorkspaceDeviceState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceDeviceState): void { + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceDeviceState(state), null, 2)}\n`); + } +} + +async function boundedJson(response: Response): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > MAX_PAIR_RESPONSE_BYTES) throw new Error("remote workspace hub response is too large"); + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + if (reader) { + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_PAIR_RESPONSE_BYTES) { + await reader.cancel("remote workspace hub response is too large").catch(() => {}); + throw new Error("remote workspace hub response is too large"); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(body); + try { return text ? JSON.parse(text) : {}; } + catch { throw new Error(`remote workspace hub returned HTTP ${response.status}`); } +} + +export async function pairRemoteWorkspaceDevice(options: PairRemoteWorkspaceDeviceOptions): Promise { + const hubUrl = normalizeRemoteWorkspaceHubUrl(options.hubUrl); + const roots = validateRootInputs(options.roots); + const deviceName = boundedText(options.name ?? hostname(), "device name", 80); + const devicePlatform = boundedText(options.devicePlatform ?? `${platform()}-${arch()}`, "device platform", 80); + const toolchainRoots = validateToolchainRoots(options.toolchainRoots); + const nativeHelper = options.nativeHelperPath + ? pinRemoteWorkspaceNativeHelper(options.nativeHelperPath) + : discoverRemoteWorkspaceNativeHelper(); + const commandRunner = createPlatformRemoteWorkspaceCommandRunner({ + linux: { toolchainRoots, writableRoots: roots.map(root => root.path) }, + ...(nativeHelper ? { native: { + helper: nativeHelper, + toolchainRoots, + writableRoots: roots.map(root => root.path), + } } : {}), + }); + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, options.capabilities); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const response = await (options.fetchImpl ?? fetch)(new URL("/remote-workspace/pair", `${hubUrl}/`), { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(PAIR_TIMEOUT_MS), + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + code: options.pairingCode, + name: deviceName, + platform: devicePlatform, + publicKey: deviceIdentity.publicKey, + capabilities, + roots: roots.map(root => ({ id: root.id, label: root.label })), + }), + }); + const body = await boundedJson(response); + if (!response.ok || !body || typeof body !== "object" || Array.isArray(body)) { + const error = body && typeof body === "object" && "error" in body && typeof body.error === "string" + ? body.error + : `remote workspace pairing failed (${response.status})`; + throw new Error(error); + } + const raw = body as Record; + const device = raw.device && typeof raw.device === "object" && !Array.isArray(raw.device) + ? raw.device as Record + : null; + if (!device) throw new Error("remote workspace hub returned an invalid device"); + const state = parseRemoteWorkspaceDeviceState({ + version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + hubUrl, + agentUrl: agentUrlForHub(hubUrl), + deviceId: device.id, + deviceName, + devicePlatform, + capabilities, + deviceToken: raw.deviceToken, + deviceIdentity, + hubPublicKey: raw.hubPublicKey, + roots, + toolchainRoots, + ...(nativeHelper ? { nativeHelper } : {}), + }); + (options.store ?? new RemoteWorkspaceDeviceFileStore()).save(state); + return state; +} + +function defaultWebSocketFactory(url: string, headers: Record): RemoteWorkspaceWebSocketLike { + return new WebSocket(url, { headers } as unknown as string[]) as unknown as RemoteWorkspaceWebSocketLike; +} + +async function messageBytes(event: MessageEvent): Promise { + if (typeof event.data === "string") return event.data; + if (event.data instanceof ArrayBuffer) return new Uint8Array(event.data); + if (ArrayBuffer.isView(event.data)) return new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + if (event.data instanceof Blob) return new Uint8Array(await event.data.arrayBuffer()); + throw new Error("remote workspace agent received an unsupported frame"); +} + +export interface RemoteWorkspaceAgentHandle { + connected: Promise; + closed: Promise; + stop(): void; +} + +export interface RemoteWorkspaceAgentRunStatus { + state: "connecting" | "online" | "reconnecting" | "stopped"; + attempt: number; + message?: string; +} + +/** Never advertise more authority than both local support and the pairing-time grant allow. */ +export function remoteWorkspaceCapabilitiesForCommandRunner( + commandRunner: RemoteWorkspaceCommandRunner | undefined, + approved?: readonly RemoteWorkspaceCapability[], +): RemoteWorkspaceCapability[] { + const available = parseRemoteWorkspaceCapabilities([ + "workspace.read", + "workspace.write", + ...(commandRunner ? ["workspace.exec" as const] : []), + ]); + const requested = parseRemoteWorkspaceCapabilities(approved ?? available); + const allowed = new Set(available); + return parseRemoteWorkspaceCapabilities(requested.filter(capability => allowed.has(capability))); +} + +export function connectRemoteWorkspaceAgent(options: { + state: RemoteWorkspaceDeviceState; + webSocketFactory?: RemoteWorkspaceWebSocketFactory; + commandRunner?: RemoteWorkspaceCommandRunner | null; +}): RemoteWorkspaceAgentHandle { + const state = parseRemoteWorkspaceDeviceState(options.state); + const commandRunner = options.commandRunner === undefined + ? createPlatformRemoteWorkspaceCommandRunner({ + linux: { + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + }, + ...(state.nativeHelper ? { native: { + helper: state.nativeHelper, + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + } } : {}), + }) + : options.commandRunner ?? undefined; + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, state.capabilities); + const executor = new RemoteWorkspaceExecutor({ + deviceId: state.deviceId, + roots: state.roots.map(root => ({ id: root.id, path: root.path })), + commandRunner, + }); + const socket = (options.webSocketFactory ?? defaultWebSocketFactory)(state.agentUrl, { + authorization: `Bearer ${state.deviceToken}`, + }); + let agent: RemoteWorkspaceExecutorAgentConnection | null = null; + let opened = false; + let presenceAccepted = false; + let stopped = false; + let settleConnected!: () => void; + let rejectConnected!: (error: Error) => void; + let settleClosed!: () => void; + const connected = new Promise((resolve, reject) => { + settleConnected = resolve; + rejectConnected = reject; + }); + const closed = new Promise(resolve => { settleClosed = resolve; }); + let queue = Promise.resolve(); + let heartbeat: ReturnType | null = null; + let presenceTimer: ReturnType | null = null; + const acceptPresence = () => { + if (stopped) return; + if (presenceAccepted) return; + presenceAccepted = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + settleConnected(); + }; + + socket.addEventListener("open", () => { + if (stopped) { + try { socket.close(1000, "remote workspace agent stopped"); } catch { /* already closed */ } + return; + } + opened = true; + presenceTimer = setTimeout(() => { + rejectConnected(new Error("remote workspace Hub did not acknowledge executor capabilities")); + socket.close(1008, "remote workspace presence timed out"); + }, 10_000); + agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId: state.deviceId, + deviceIdentity: state.deviceIdentity, + hubPublicKey: state.hubPublicKey, + executor, + capabilities, + onPresenceAccepted: acceptPresence, + socket: { + send: value => socket.send(value), + close: (code, reason) => socket.close(code, reason), + }, + }); + socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities, + })); + heartbeat = setInterval(() => { + if (socket.readyState !== 1) return; + socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "heartbeat", + nonce: randomUUID(), + })); + }, 20_000); + }); + socket.addEventListener("message", event => { + if (stopped || !(event instanceof MessageEvent) || !agent) return; + queue = queue.then(async () => agent?.receive(await messageBytes(event))).catch(() => { + socket.close(1008, "remote workspace protocol error"); + }); + }); + socket.addEventListener("error", () => { + if (!stopped && !presenceAccepted) rejectConnected(new Error("remote workspace agent connection failed")); + }); + socket.addEventListener("close", () => { + stopped = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + const currentAgent = agent; + agent = null; + currentAgent?.close(); + if (!presenceAccepted) rejectConnected(new Error( + opened + ? "remote workspace agent connection closed before presence acknowledgement" + : "remote workspace agent connection closed before opening", + )); + settleClosed(); + }); + return { + connected, + closed, + stop() { + if (stopped) return; + stopped = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + const currentAgent = agent; + agent = null; + currentAgent?.close(); + if (!presenceAccepted) rejectConnected(new Error("remote workspace agent stopped")); + settleClosed(); + try { socket.close(1000, "remote workspace agent stopped"); } catch { /* CONNECTING sockets differ by runtime */ } + }, + }; +} + +function waitForReconnect(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise(resolve => { + const timer = setTimeout(finish, delayMs); + function finish() { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + +export async function runRemoteWorkspaceAgent(options: { + state: RemoteWorkspaceDeviceState; + signal: AbortSignal; + webSocketFactory?: RemoteWorkspaceWebSocketFactory; + commandRunner?: RemoteWorkspaceCommandRunner | null; + onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void; + minReconnectMs?: number; + maxReconnectMs?: number; + random?: () => number; +}): Promise { + const state = parseRemoteWorkspaceDeviceState(options.state); + const minimum = options.minReconnectMs ?? 500; + const maximum = options.maxReconnectMs ?? 15_000; + if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum < 10 || maximum < minimum) { + throw new Error("invalid remote workspace reconnect policy"); + } + let attempt = 0; + let delayMs = minimum; + while (!options.signal.aborted) { + attempt += 1; + options.onStatus?.({ state: "connecting", attempt }); + const handle = connectRemoteWorkspaceAgent({ + state, + ...(options.webSocketFactory ? { webSocketFactory: options.webSocketFactory } : {}), + ...(options.commandRunner !== undefined ? { commandRunner: options.commandRunner } : {}), + }); + const stop = () => handle.stop(); + options.signal.addEventListener("abort", stop, { once: true }); + try { + await handle.connected; + delayMs = minimum; + options.onStatus?.({ state: "online", attempt }); + await handle.closed; + } catch (error) { + handle.stop(); + if (!options.signal.aborted) { + options.onStatus?.({ + state: "reconnecting", + attempt, + message: error instanceof Error ? error.message : "remote workspace connection failed", + }); + } + } finally { + options.signal.removeEventListener("abort", stop); + } + if (options.signal.aborted) break; + options.onStatus?.({ state: "reconnecting", attempt }); + const random = Math.min(1, Math.max(0, (options.random ?? Math.random)())); + const jitteredDelay = Math.max(10, Math.round(delayMs * (0.8 + random * 0.4))); + await waitForReconnect(jitteredDelay, options.signal); + delayMs = Math.min(maximum, delayMs * 2); + } + options.onStatus?.({ state: "stopped", attempt }); +} diff --git a/src/remote-control/workspace-executable.ts b/src/remote-control/workspace-executable.ts new file mode 100644 index 0000000000..db6153303c --- /dev/null +++ b/src/remote-control/workspace-executable.ts @@ -0,0 +1,43 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; + +function executableCandidate(path: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Resolve only durable PATH entries; an empty/current-directory entry is never trusted. */ +export function findExecutableOnPath(name: string, options: { + path?: string; + pathExt?: string; + platform?: NodeJS.Platform; + /** Pure cross-platform test seam; production checks the real filesystem. */ + probe?: (candidate: string) => boolean; +} = {}): string | null { + const path = options.path ?? process.env.PATH; + const platform = options.platform ?? process.platform; + if (!path) return null; + const paths = platform === "win32" ? win32 : posix; + const spawnableWindowsExtensions = new Set([".com", ".exe", ".bat", ".cmd"]); + const suffixes = platform === "win32" + ? (options.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .map(value => value.trim()) + .filter(value => spawnableWindowsExtensions.has(value.toLowerCase())) + : [""]; + if (platform === "win32" && win32.extname(name)) suffixes.unshift(""); + const probe = options.probe ?? (candidate => executableCandidate(candidate, platform)); + for (const directory of path.split(paths.delimiter)) { + if (!directory) continue; + for (const suffix of suffixes) { + const candidate = paths.join(directory, `${name}${suffix.toLowerCase()}`); + if (probe(candidate)) return candidate; + } + } + return null; +} diff --git a/src/remote-control/workspace-executor.ts b/src/remote-control/workspace-executor.ts new file mode 100644 index 0000000000..3312e8348f --- /dev/null +++ b/src/remote-control/workspace-executor.ts @@ -0,0 +1,396 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + opendirSync, + readSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from "node:path"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; + +export interface RemoteWorkspaceRoot { + id: string; + path: string; +} + +export interface RemoteWorkspaceExecutionRequest { + requestId: string; + sessionId: string; + executorDeviceId: string; + rootId: string; + tool: RemoteWorkspaceToolName; + arguments: unknown; +} + +export interface RemoteWorkspaceExecutorOptions { + deviceId: string; + roots: readonly RemoteWorkspaceRoot[]; + maxOutputBytes?: number; + platform?: NodeJS.Platform; + /** Production must provide an OS-sandboxed runner. Omission disables command execution. */ + commandRunner?: RemoteWorkspaceCommandRunner; +} + +export interface RemoteWorkspaceCommandRequest { + command: string[]; + root: string; + cwd: string; + timeoutMs: number; + maxOutputBytes: number; + signal?: AbortSignal; +} + +export interface RemoteWorkspaceCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface RemoteWorkspaceCommandRunner { + run(request: RemoteWorkspaceCommandRequest): Promise; +} + +interface ApprovedRoot { + id: string; + path: string; + dev: number; + ino: number; + birthtimeMs: number; +} + +function objectArguments(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("remote workspace arguments must be an object"); + } + return value as Record; +} + +function noExtraKeys(value: Record, allowed: readonly string[]): void { + const set = new Set(allowed); + if (Object.keys(value).some(key => !set.has(key))) throw new Error("unknown remote workspace argument"); +} + +const WINDOWS_RESERVED_BASENAME = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i; + +export function validateRemoteWorkspaceRelativePath( + value: unknown, + fallback?: string, + platform: NodeJS.Platform = process.platform, +): string { + const path = value === undefined ? fallback : value; + if (typeof path !== "string" || path.length < 1 || path.length > 4096 || path.includes("\0")) { + throw new Error("invalid remote workspace path"); + } + const paths = platform === "win32" ? win32 : posix; + if (paths.isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\")) { + throw new Error("remote workspace path must be relative"); + } + if (platform === "win32") { + for (const segment of path.split(/[\\/]/)) { + if (!segment || segment === "." || segment === "..") continue; + if (/[\x01-\x1f<>:"|?*]/.test(segment) || /[ .]$/.test(segment) || WINDOWS_RESERVED_BASENAME.test(segment)) { + throw new Error("remote workspace path is not a safe Windows file path"); + } + } + } + return path; +} + +function inside(root: string, candidate: string): boolean { + const fromRoot = relative(root, candidate); + return fromRoot === "" || (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot)); +} + +function errorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object" || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function assertNoSymlinkComponents(root: string, candidate: string, includeLeaf: boolean): void { + const rel = relative(root, candidate); + const parts = rel === "" ? [] : rel.split(sep); + const limit = includeLeaf ? parts.length : Math.max(0, parts.length - 1); + let current = root; + for (let index = 0; index < limit; index += 1) { + current = resolve(current, parts[index]!); + if (lstatSync(current).isSymbolicLink()) throw new Error("remote workspace symlink traversal is not allowed"); + } +} + +function resolveExisting(root: string, value: unknown, platform = process.platform): string { + const candidate = resolve(root, validateRemoteWorkspaceRelativePath(value, ".", platform)); + if (!inside(root, candidate)) throw new Error("remote workspace path escapes the approved root"); + assertNoSymlinkComponents(root, candidate, true); + const canonical = realpathSync(candidate); + if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); + return canonical; +} + +function resolveWritable(root: string, value: unknown, platform = process.platform): string { + const candidate = resolve(root, validateRemoteWorkspaceRelativePath(value, undefined, platform)); + if (!inside(root, candidate) || candidate === root) throw new Error("remote workspace path escapes the approved root"); + const parent = dirname(candidate); + assertNoSymlinkComponents(root, parent, true); + const canonicalParent = realpathSync(parent); + if (!inside(root, canonicalParent)) throw new Error("remote workspace parent escapes the approved root"); + try { + assertNoSymlinkComponents(root, candidate, true); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + return resolve(canonicalParent, basename(candidate)); +} + +function sha256(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number { + const selected = value === undefined ? fallback : value; + if (typeof selected !== "number" || !Number.isSafeInteger(selected) || selected < minimum || selected > maximum) { + throw new Error("invalid remote workspace numeric argument"); + } + return selected; +} + +function decodeUtf8(value: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: false }).decode(value); +} + +function assertOpenedRegularFile(root: string, target: string, descriptor: number, maximum: number) { + const opened = fstatSync(descriptor); + const linked = lstatSync(target); + if (opened.isFile() && linked.isFile() && (opened.nlink !== 1 || linked.nlink !== 1)) { + throw new Error("remote workspace hard-linked files are not allowed"); + } + if (!opened.isFile() || !linked.isFile() || linked.isSymbolicLink() + || opened.dev !== linked.dev || opened.ino !== linked.ino + || opened.birthtimeMs !== linked.birthtimeMs) { + throw new Error("remote workspace file identity changed during access"); + } + const canonical = realpathSync(target); + if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); + if (opened.size > maximum) throw new Error("remote workspace file exceeds the read limit"); + return opened; +} + +function readBoundedRegularFile(root: string, target: string, maximum: number): { body: Buffer; mode: number } { + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const descriptor = openSync(target, constants.O_RDONLY | noFollow); + try { + const metadata = assertOpenedRegularFile(root, target, descriptor, maximum); + const body = Buffer.alloc(metadata.size); + let offset = 0; + while (offset < body.byteLength) { + const read = readSync(descriptor, body, offset, body.byteLength - offset, null); + if (read === 0) break; + offset += read; + } + assertOpenedRegularFile(root, target, descriptor, maximum); + return { body: offset === body.byteLength ? body : body.subarray(0, offset), mode: metadata.mode & 0o777 }; + } finally { + closeSync(descriptor); + } +} + +function assertStableWritableParent(root: string, target: string): void { + const parent = dirname(target); + assertNoSymlinkComponents(root, parent, true); + const canonical = realpathSync(parent); + if (!inside(root, canonical) || relative(parent, canonical) !== "") { + throw new Error("remote workspace write parent changed during access"); + } +} + +function assertApprovedRootIdentity(root: ApprovedRoot): void { + const linked = lstatSync(root.path); + const canonical = realpathSync(root.path); + if (!linked.isDirectory() || linked.isSymbolicLink() + || linked.dev !== root.dev || linked.ino !== root.ino + || linked.birthtimeMs !== root.birthtimeMs + || relative(root.path, canonical) !== "") { + throw new Error("remote workspace approved root identity changed; pair the folder again"); + } +} + +function assertWritePrecondition(root: string, target: string, expectedSha256: string | null): number { + try { + const current = readBoundedRegularFile(root, target, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES); + if (expectedSha256 === null || sha256(current.body) !== expectedSha256) { + throw new Error("remote workspace file changed before write"); + } + return current.mode; + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + if (expectedSha256 !== null) throw new Error("remote workspace file is missing"); + return 0o600; + } +} + +export class RemoteWorkspaceExecutor { + private readonly roots = new Map(); + private readonly maxOutputBytes: number; + private operationTail: Promise = Promise.resolve(); + + constructor(private readonly options: RemoteWorkspaceExecutorOptions) { + if (!options.deviceId || options.deviceId.length > 256) throw new Error("invalid remote workspace executor device ID"); + this.maxOutputBytes = options.maxOutputBytes ?? REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES; + if (!Number.isSafeInteger(this.maxOutputBytes) || this.maxOutputBytes < 1024) { + throw new Error("invalid remote workspace output limit"); + } + for (const root of options.roots) { + if (!root.id || root.id.length > 128 || this.roots.has(root.id)) throw new Error("invalid remote workspace root ID"); + const metadata = lstatSync(root.path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); + const canonical = realpathSync(root.path); + const identity = lstatSync(canonical); + this.roots.set(root.id, { + id: root.id, + path: canonical, + dev: identity.dev, + ino: identity.ino, + birthtimeMs: identity.birthtimeMs, + }); + } + if (this.roots.size === 0) throw new Error("remote workspace executor needs one approved root"); + } + + hasApprovedRoot(rootId: string): boolean { + return this.roots.has(rootId); + } + + async invoke(request: RemoteWorkspaceExecutionRequest, signal?: AbortSignal): Promise { + if (request.executorDeviceId !== this.options.deviceId) { + return { ok: false, error: "remote workspace executor identity mismatch" }; + } + const root = this.roots.get(request.rootId); + if (!root) return { ok: false, error: "remote workspace root is not approved" }; + if (!request.requestId || !request.sessionId) return { ok: false, error: "invalid remote workspace request identity" }; + const previous = this.operationTail; + let release!: () => void; + this.operationTail = new Promise(resolvePromise => { release = resolvePromise; }); + await previous; + try { + if (signal?.aborted) throw new Error("remote workspace operation was cancelled"); + assertApprovedRootIdentity(root); + switch (request.tool) { + case "list_directory": return { ok: true, value: this.listDirectory(root, request.arguments) }; + case "read_file": return { ok: true, value: this.readFile(root, request.arguments) }; + case "write_file": return { ok: true, value: this.writeFile(root, request.arguments) }; + case "exec": return { ok: true, value: await this.exec(root, request.arguments, signal) }; + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "remote workspace operation failed" }; + } finally { + release(); + } + } + + private listDirectory(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path"]); + const target = resolveExisting(root.path, args.path ?? ".", this.options.platform); + if (!statSync(target).isDirectory()) throw new Error("remote workspace list target is not a directory"); + const directory = opendirSync(target); + const entries: Array<{ name: string; type: "directory" | "file" | "symlink" | "other" }> = []; + try { + while (true) { + const entry = directory.readSync(); + if (!entry) break; + if (entries.length >= 4096) throw new Error("remote workspace directory has too many entries"); + entries.push({ + name: entry.name, + type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other", + }); + } + } finally { + directory.closeSync(); + } + return { + path: relative(root.path, target) || ".", + entries, + }; + } + + private readFile(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path", "maxBytes"]); + const target = resolveExisting(root.path, args.path, this.options.platform); + const maxBytes = boundedInteger(args.maxBytes, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, 1, this.maxOutputBytes); + const { body } = readBoundedRegularFile(root.path, target, maxBytes); + return { path: relative(root.path, target), content: decodeUtf8(body), sha256: sha256(body), bytes: body.byteLength }; + } + + private writeFile(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path", "content", "expectedSha256"]); + if (typeof args.content !== "string") throw new Error("remote workspace file content must be text"); + const body = Buffer.from(args.content, "utf8"); + if (body.byteLength > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) throw new Error("remote workspace file exceeds the write limit"); + const expectedSha256 = args.expectedSha256; + if (expectedSha256 !== null && (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256))) { + throw new Error("invalid remote workspace expected file hash"); + } + const target = resolveWritable(root.path, args.path, this.options.platform); + const mode = assertWritePrecondition(root.path, target, expectedSha256); + const temporary = resolve(dirname(target), `.${randomUUID()}.ocx-remote-write`); + try { + writeFileSync(temporary, body, { flag: "wx", mode }); + assertStableWritableParent(root.path, target); + assertWritePrecondition(root.path, target, expectedSha256); + renameAtomicFile(temporary, target, undefined, "remote-workspace"); + } finally { + try { unlinkSync(temporary); } catch { /* committed or already absent */ } + } + return { path: relative(root.path, target), sha256: sha256(body), bytes: body.byteLength }; + } + + private async exec(root: ApprovedRoot, input: unknown, signal?: AbortSignal): Promise { + if (!this.options.commandRunner) { + throw new Error("remote workspace command runner is disabled until an OS sandbox is configured"); + } + const args = objectArguments(input); + noExtraKeys(args, ["command", "cwd", "timeoutMs"]); + if (!Array.isArray(args.command) || args.command.length < 1 || args.command.length > 64) { + throw new Error("invalid remote workspace command vector"); + } + const command: string[] = []; + for (const value of args.command) { + if (typeof value !== "string" || value.length < 1 || value.length > 4096 || value.includes("\0")) { + throw new Error("invalid remote workspace command vector"); + } + command.push(value); + } + if (command.reduce((total, value) => total + value.length, 0) > 16 * 1024) { + throw new Error("remote workspace command vector is too large"); + } + const cwd = resolveExisting(root.path, args.cwd ?? ".", this.options.platform); + if (!statSync(cwd).isDirectory()) throw new Error("remote workspace command cwd is not a directory"); + const timeoutMs = boundedInteger(args.timeoutMs, 30_000, 1, 60_000); + const result = await this.options.commandRunner.run({ + command, + root: root.path, + cwd, + timeoutMs, + maxOutputBytes: this.maxOutputBytes, + signal, + }); + const outputBytes = Buffer.byteLength(result.stdout, "utf8") + Buffer.byteLength(result.stderr, "utf8"); + if (outputBytes > this.maxOutputBytes) { + throw new Error("remote workspace command runner exceeded its output contract"); + } + return { cwd: relative(root.path, cwd) || ".", ...result }; + } +} diff --git a/src/remote-control/workspace-hub.ts b/src/remote-control/workspace-hub.ts new file mode 100644 index 0000000000..b1c08e4c3b --- /dev/null +++ b/src/remote-control/workspace-hub.ts @@ -0,0 +1,519 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + randomBytes, + randomUUID, + sign, + timingSafeEqual, + verify, +} from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteControlIdentityKeyPair, +} from "./crypto"; +import type { RemoteWorkspaceHubAgentConnection } from "./workspace-agent-connection"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_HUB_STATE_VERSION = 1 as const; +export const REMOTE_WORKSPACE_MAX_DEVICES = 32; +export const REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE = 32; +const PAIRING_LIFETIME_MS = 10 * 60_000; +const MAX_PAIRING_GRANTS = 16; +const MAX_HUB_STATE_BYTES = 1024 * 1024; +const TOKEN_PREFIX = "ocxrw_"; +const PAIRING_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; +const PAIRING_SOURCE_WINDOW_MS = 10 * 60_000; +const PAIRING_SOURCE_FAILURE_LIMIT = 10; +const PAIRING_SOURCE_LIMIT = 1_024; + +export interface RemoteWorkspaceRootAdvertisement { + id: string; + label: string; +} + +export interface RemoteWorkspaceStoredDevice { + id: string; + name: string; + platform: string; + publicKey: string; + tokenHash: string; + capabilities: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; + createdAt: string; + lastSeenAt: string | null; +} + +export interface RemoteWorkspaceHubState { + version: typeof REMOTE_WORKSPACE_HUB_STATE_VERSION; + identity: RemoteControlIdentityKeyPair; + devices: RemoteWorkspaceStoredDevice[]; +} + +export interface RemoteWorkspaceHubStateStore { + load(): RemoteWorkspaceHubState | null; + save(state: RemoteWorkspaceHubState): void; +} + +export interface RemoteWorkspacePublicDevice { + id: string; + name: string; + platform: string; + capabilities: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; + online: boolean; + createdAt: string; + lastSeenAt: string | null; +} + +export interface RemoteWorkspacePairingGrant { + code: string; + expiresAt: string; +} + +export interface RemoteWorkspacePairDeviceInput { + code: string; + name: string; + platform: string; + publicKey: string; + capabilities?: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; +} + +export interface RemoteWorkspacePairDeviceResult { + device: RemoteWorkspacePublicDevice; + deviceToken: string; + hubPublicKey: string; +} + +interface PendingPairingGrant { + hash: Buffer; + expiresAt: number; +} + +interface PairingSourceFailureRecord { + failures: number; + windowStartedAt: number; +} + +export class RemoteWorkspacePairingRateLimitError extends Error { + constructor( + readonly retryAfterSeconds: number, + readonly reason: "source" | "capacity", + ) { + super("remote workspace pairing rate limit exceeded"); + this.name = "RemoteWorkspacePairingRateLimitError"; + } +} + +function sha256(value: string): Buffer { + return createHash("sha256").update(value, "utf8").digest(); +} + +function encodeHash(value: Buffer): string { + return value.toString("base64url"); +} + +function parseHash(value: unknown): Buffer { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) throw new Error("invalid remote workspace token hash"); + const decoded = Buffer.from(value, "base64url"); + if (decoded.byteLength !== 32) throw new Error("invalid remote workspace token hash"); + return decoded; +} + +function normalizeCode(value: string): string { + return value.replace(/[\s-]/g, "").toUpperCase(); +} + +function newPairingCode(): string { + const bytes = randomBytes(12); + let code = ""; + for (let index = 0; index < bytes.length; index += 1) { + code += PAIRING_ALPHABET[bytes[index]! % PAIRING_ALPHABET.length]; + } + return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`; +} + +function boundedText(value: unknown, label: string, max: number): string { + if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function objectRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid remote workspace device metadata"); + } + return value as Record; +} + +function exactPairingFields(value: Record): void { + const required = ["code", "name", "platform", "publicKey", "roots"] as const; + const allowed = new Set([...required, "capabilities"]); + if (required.some(key => !Object.hasOwn(value, key)) + || Object.keys(value).some(key => !allowed.has(key))) { + throw new Error("invalid remote workspace device metadata"); + } +} + +function validUuid(value: unknown, label: string): string { + const normalized = boundedText(value, label, 64); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function validatePublicKey(value: unknown): string { + const encoded = boundedText(value, "device public key", 1024); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error("invalid remote workspace device public key"); + const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519"); + return encoded; +} + +function validateIdentity(value: unknown): RemoteControlIdentityKeyPair { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub identity"); + const raw = value as Record; + const publicKey = validatePublicKey(raw.publicKey); + const privateKey = boundedText(raw.privateKey, "hub private key", 2048); + const privateDer = Buffer.from(privateKey, "base64url"); + const parsed = createPrivateKey({ key: privateDer, type: "pkcs8", format: "der" }); + if (parsed.asymmetricKeyType !== "ed25519") throw new Error("remote workspace hub key must use Ed25519"); + const challenge = Buffer.from("opencodex remote workspace hub identity v1", "utf8"); + const signature = sign(null, challenge, parsed); + const verifier = createPublicKey({ key: Buffer.from(publicKey, "base64url"), type: "spki", format: "der" }); + if (!verify(null, challenge, verifier, signature)) { + throw new Error("remote workspace hub identity key pair does not match"); + } + return { publicKey, privateKey }; +} + +function validateRoots(value: unknown): RemoteWorkspaceRootAdvertisement[] { + if (!Array.isArray(value) || value.length < 1 || value.length > REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE) { + throw new Error("remote workspace device needs one to 32 roots"); + } + const ids = new Set(); + const labels = new Set(); + return value.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace root"); + const raw = item as Record; + const id = validUuid(raw.id, "root ID"); + const label = boundedText(raw.label, "root label", 80); + const folded = label.toLocaleLowerCase("en-US"); + if (ids.has(id) || labels.has(folded)) throw new Error("duplicate remote workspace root"); + ids.add(id); + labels.add(folded); + return { id, label }; + }); +} + +function validateDate(value: unknown, nullable = false): string | null { + if (nullable && value === null) return null; + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error("invalid remote workspace timestamp"); + return value; +} + +export function parseRemoteWorkspaceHubState(value: unknown): RemoteWorkspaceHubState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_HUB_STATE_VERSION || !Array.isArray(raw.devices)) { + throw new Error("unsupported remote workspace hub state"); + } + if (raw.devices.length > REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit exceeded"); + const ids = new Set(); + const names = new Set(); + const devices = raw.devices.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device state"); + const device = item as Record; + const id = validUuid(device.id, "device ID"); + const name = boundedText(device.name, "device name", 80); + const folded = name.toLocaleLowerCase("en-US"); + if (ids.has(id) || names.has(folded)) throw new Error("duplicate remote workspace device identity"); + ids.add(id); + names.add(folded); + if (typeof device.tokenHash !== "string") throw new Error("invalid remote workspace token hash"); + parseHash(device.tokenHash); + const tokenHash = device.tokenHash; + return { + id, + name, + platform: boundedText(device.platform, "device platform", 80), + publicKey: validatePublicKey(device.publicKey), + tokenHash, + capabilities: parseRemoteWorkspaceCapabilities(device.capabilities), + roots: validateRoots(device.roots), + createdAt: validateDate(device.createdAt)!, + lastSeenAt: validateDate(device.lastSeenAt, true), + }; + }); + return { + version: REMOTE_WORKSPACE_HUB_STATE_VERSION, + identity: validateIdentity(raw.identity), + devices, + }; +} + +export class RemoteWorkspaceHubFileStore implements RemoteWorkspaceHubStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-hub.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceHubState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_HUB_STATE_BYTES) { + throw new Error("remote workspace hub state is too large"); + } + return parseRemoteWorkspaceHubState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceHubState): void { + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceHubState(state), null, 2)}\n`); + } +} + +export class RemoteWorkspaceHub { + private state: RemoteWorkspaceHubState; + private readonly grants = new Map(); + private readonly pairingSourceFailures = new Map(); + private readonly connections = new Map(); + + constructor( + private readonly store: RemoteWorkspaceHubStateStore, + private readonly now: () => number = Date.now, + ) { + const loaded = store.load(); + this.state = loaded ?? { + version: REMOTE_WORKSPACE_HUB_STATE_VERSION, + identity: generateRemoteControlIdentityKeyPair(), + devices: [], + }; + if (loaded === null) this.store.save(this.state); + } + + identity(): RemoteControlIdentityKeyPair { + return { ...this.state.identity }; + } + + createPairingGrant(): RemoteWorkspacePairingGrant { + this.pruneGrants(); + if (this.grants.size >= MAX_PAIRING_GRANTS) throw new Error("remote workspace pairing capacity reached"); + let code: string; + let digest: Buffer; + do { + code = newPairingCode(); + digest = sha256(normalizeCode(code)); + } while (this.grants.has(encodeHash(digest))); + const expiresAt = this.now() + PAIRING_LIFETIME_MS; + this.grants.set(encodeHash(digest), { hash: digest, expiresAt }); + return { code, expiresAt: new Date(expiresAt).toISOString() }; + } + + private pairingSourceKey(source: string): string { + return encodeHash(sha256(`remote-workspace-pairing-source\0${source}`)); + } + + private prunePairingSourceFailures(now: number): void { + // Records never extend their original fixed window, so insertion order is expiry order. Stop + // at the first live entry instead of making every unauthenticated request scan the full cap. + for (const [key, record] of this.pairingSourceFailures) { + if (record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS > now) break; + this.pairingSourceFailures.delete(key); + } + } + + private pairingSourceRecord(source: string, now: number): [string, PairingSourceFailureRecord | undefined] { + this.prunePairingSourceFailures(now); + const key = this.pairingSourceKey(source); + return [key, this.pairingSourceFailures.get(key)]; + } + + private admitPairingSource(source: string, now: number): string { + const [key, record] = this.pairingSourceRecord(source, now); + if (record && record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) { + const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now); + throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source"); + } + return key; + } + + assertPairingSourceAllowed(source = "anonymous"): void { + this.admitPairingSource(source, this.now()); + } + + private recordPairingSourceFailure(key: string, now: number): void { + let record = this.pairingSourceFailures.get(key); + if (!record) { + if (this.pairingSourceFailures.size >= PAIRING_SOURCE_LIMIT) { + throw new RemoteWorkspacePairingRateLimitError(1, "capacity"); + } + record = { failures: 0, windowStartedAt: now }; + this.pairingSourceFailures.set(key, record); + } + record.failures += 1; + if (record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) { + const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now); + throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source"); + } + } + + pairDevice(input: unknown, source = "anonymous"): RemoteWorkspacePairDeviceResult { + this.pruneGrants(); + const nowMs = this.now(); + const sourceKey = this.admitPairingSource(source, nowMs); + const raw = objectRecord(input); + const normalizedCode = normalizeCode(typeof raw.code === "string" ? raw.code : ""); + if (normalizedCode.length !== 12 || ![...normalizedCode].every(character => PAIRING_ALPHABET.includes(character))) { + this.recordPairingSourceFailure(sourceKey, nowMs); + throw new Error("invalid or expired remote workspace pairing code"); + } + const digest = sha256(normalizedCode); + const key = encodeHash(digest); + const grant = this.grants.get(key); + if (!grant || grant.expiresAt <= nowMs || !timingSafeEqual(grant.hash, digest)) { + this.recordPairingSourceFailure(sourceKey, nowMs); + throw new Error("invalid or expired remote workspace pairing code"); + } + this.pairingSourceFailures.delete(sourceKey); + // A valid grant is one-shot even when the submitted device metadata is rejected. Keeping it + // alive after a conflict would let the same copied secret authorize repeated enrollment tries. + this.grants.delete(key); + exactPairingFields(raw); + if (this.state.devices.length >= REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit reached"); + const name = boundedText(raw.name, "device name", 80); + const folded = name.toLocaleLowerCase("en-US"); + if (this.state.devices.some(device => device.name.toLocaleLowerCase("en-US") === folded)) { + throw new Error("remote workspace device name is already in use"); + } + const now = new Date(nowMs).toISOString(); + const token = `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`; + const device: RemoteWorkspaceStoredDevice = { + id: randomUUID(), + name, + platform: boundedText(raw.platform, "device platform", 80), + publicKey: validatePublicKey(raw.publicKey), + tokenHash: encodeHash(sha256(token)), + capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities), + roots: validateRoots(raw.roots), + createdAt: now, + lastSeenAt: null, + }; + this.state = { ...this.state, devices: [...this.state.devices, device] }; + this.store.save(this.state); + return { + device: this.publicDevice(device), + deviceToken: token, + hubPublicKey: this.state.identity.publicKey, + }; + } + + authenticateDeviceToken(token: string): RemoteWorkspaceStoredDevice | null { + if (!token.startsWith(TOKEN_PREFIX) || token.length !== TOKEN_PREFIX.length + 43) return null; + const digest = sha256(token); + for (const device of this.state.devices) { + const stored = parseHash(device.tokenHash); + if (timingSafeEqual(stored, digest)) { + return { ...device, capabilities: [...device.capabilities], roots: device.roots.map(root => ({ ...root })) }; + } + } + return null; + } + + attachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void { + const index = this.state.devices.findIndex(device => device.id === deviceId); + if (index < 0) throw new Error("unknown remote workspace device"); + const previous = this.connections.get(deviceId); + if (previous && previous !== connection) previous.close("remote workspace executor reconnected"); + this.connections.set(deviceId, connection); + const seen = new Date(this.now()).toISOString(); + this.state = { + ...this.state, + devices: this.state.devices.map((device, deviceIndex) => ( + deviceIndex === index ? { ...device, lastSeenAt: seen } : device + )), + }; + this.store.save(this.state); + } + + updateDeviceCapabilities(deviceId: string, capabilities: readonly RemoteWorkspaceCapability[]): void { + const device = this.state.devices.find(candidate => candidate.id === deviceId); + if (!device) throw new Error("unknown remote workspace device"); + const normalized = parseRemoteWorkspaceCapabilities(capabilities); + if (normalized.some(capability => !device.capabilities.includes(capability))) { + throw new Error("remote workspace presence exceeds enrollment grant"); + } + // Connection availability is transient; the persisted enrollment grant is unchanged. + } + + detachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void { + if (this.connections.get(deviceId) !== connection) return; + this.connections.delete(deviceId); + connection.close(); + } + + connection(deviceId: string): RemoteWorkspaceHubAgentConnection | null { + const connection = this.connections.get(deviceId); + return connection?.isOnline() ? connection : null; + } + + listDevices(): RemoteWorkspacePublicDevice[] { + return this.state.devices.map(device => this.publicDevice(device)); + } + + revokeDevice(deviceId: string): boolean { + const before = this.state.devices.length; + this.state = { ...this.state, devices: this.state.devices.filter(device => device.id !== deviceId) }; + if (this.state.devices.length === before) return false; + const connection = this.connections.get(deviceId); + this.connections.delete(deviceId); + connection?.close("remote workspace device was revoked"); + this.store.save(this.state); + return true; + } + + closeAllConnections(reason = "remote workspace hub stopped"): void { + const connections = [...this.connections.values()]; + this.connections.clear(); + for (const connection of connections) connection.close(reason); + } + + private publicDevice(device: RemoteWorkspaceStoredDevice): RemoteWorkspacePublicDevice { + return { + id: device.id, + name: device.name, + platform: device.platform, + capabilities: device.capabilities.filter(capability => { + const connection = this.connections.get(device.id); + return !connection || connection.capabilities().includes(capability); + }), + roots: device.roots.map(root => ({ ...root })), + online: this.connections.get(device.id)?.isOnline() ?? false, + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + }; + } + + private pruneGrants(): void { + const now = this.now(); + for (const [key, grant] of this.grants) { + if (grant.expiresAt <= now) this.grants.delete(key); + } + } +} diff --git a/src/remote-control/workspace-pi-runtime.ts b/src/remote-control/workspace-pi-runtime.ts new file mode 100644 index 0000000000..a5987a5fe1 --- /dev/null +++ b/src/remote-control/workspace-pi-runtime.ts @@ -0,0 +1,382 @@ +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + waitForRemoteWorkspaceProcessExit, +} from "./workspace-process"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import { REMOTE_WORKSPACE_DYNAMIC_TOOLS } from "./workspace-tools"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, +} from "./workspace-sessions"; + +const MAX_JSON_LINE_BYTES = 2 * 1024 * 1024; + +interface PendingResponse { + resolve(value: Record): void; + reject(error: Error): void; + timer: ReturnType; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function safeError(value: unknown, fallback: string): string { + return (value instanceof Error ? value.message : typeof value === "string" ? value : fallback) + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function messageText(value: unknown): string | null { + const message = record(value); + if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return null; + const text = message.content.flatMap(raw => { + const part = record(raw); + return part?.type === "text" && typeof part.text === "string" ? [part.text] : []; + }).join(""); + return text || null; +} + +function remotePiInstructions(deviceName: string, tools: readonly string[]): string { + const name = deviceName.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120) || "remote executor"; + const remoteTools = tools.map(tool => `remote_${tool}`).join(", "); + return [ + `You operate only on the OpenCodex remote executor named ${JSON.stringify(name)}.`, + `Use only these tools for filesystem and command work: ${remoteTools}.`, + "The Hub working directory is an empty isolation boundary, not the user's project.", + "If a remote tool fails or the executor is offline, stop and report it. Never substitute local operations.", + ].join(" "); +} + +function extensionSource(tools: readonly string[]): string { + const allowed = new Set(tools); + const definitions = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => allowed.has(tool.name)).map(tool => ({ + remoteName: `remote_${tool.name}`, + tool: tool.name, + description: tool.description, + parameters: tool.inputSchema, + })); + return `const definitions = ${JSON.stringify(definitions)}; +const endpoint = process.env.OCX_REMOTE_WORKSPACE_BRIDGE_URL; +const token = process.env.OCX_REMOTE_WORKSPACE_BRIDGE_TOKEN; + +export default function registerRemoteWorkspace(pi) { + if (!endpoint || !token) throw new Error("Remote Workspace bridge is unavailable"); + for (const definition of definitions) { + pi.registerTool({ + name: definition.remoteName, + label: definition.remoteName, + description: definition.description, + parameters: definition.parameters, + async execute(_toolCallId, parameters, signal) { + const response = await fetch(endpoint + "/invoke", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer " + token }, + body: JSON.stringify({ tool: definition.tool, arguments: parameters }), + signal, + }); + const result = await response.json(); + if (!response.ok || !result || result.success !== true) { + throw new Error(result && typeof result.text === "string" ? result.text : "Remote Workspace tool failed"); + } + return { content: [{ type: "text", text: result.text }], details: { remote: true } }; + }, + }); + } +} +`; +} + +class PiRpcProcess { + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + private activeSettle: { resolve(): void; reject(error: Error): void } | null = null; + + onEvent: ((event: Record) => void) | null = null; + + constructor(private readonly child: Bun.Subprocess<"pipe", "pipe", "pipe">) { + void this.read(); + void this.drainStderr(); + void child.exited.then(code => this.fail(new Error(`Pi RPC exited with code ${code}`))); + } + + async command(type: string, fields: Record = {}, timeoutMs = 15_000): Promise> { + if (this.closed) throw new Error("Pi RPC is closed"); + const id = `ocx-${++this.nextId}`; + const result = new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${type} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + }); + try { + this.send({ id, type, ...fields }); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error instanceof Error ? error : new Error("Pi RPC write failed")); + } + } + return result; + } + + async prompt(message: string): Promise { + if (this.activeSettle) throw new Error("Pi Remote Workspace turn is already active"); + const settled = new Promise((resolve, reject) => { this.activeSettle = { resolve, reject }; }); + try { + const accepted = await this.command("prompt", { message }); + if (accepted.success !== true) throw new Error(safeError(accepted.error, "Pi rejected the prompt")); + await settled; + } catch (error) { + this.activeSettle = null; + throw error; + } + } + + async abort(): Promise { + if (!this.activeSettle) return; + await this.command("abort", {}, 3_000).catch(() => {}); + } + + async close(): Promise { + try { + if (!this.closed) { + try { this.child.stdin.end(); } catch { /* already closed */ } + } + const graceful = await waitForRemoteWorkspaceProcessExit(this.child, 1_500); + if (!graceful) { + await stopRemoteWorkspaceProcess(this.child); + } + } finally { + // Active and pending RPC waiters cannot survive a failed process teardown. + this.fail(new Error("Pi Remote Workspace session closed")); + } + } + + private send(value: Record): void { + const line = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Pi RPC message is too large"); + this.child.stdin.write(line); + this.child.stdin.flush(); + } + + private async read(): Promise { + const reader = this.child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_JSON_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Pi RPC output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Pi RPC output line is too large"); + if (line) { + const event = record(JSON.parse(line)); + if (!event) throw new Error("invalid Pi RPC event"); + this.receive(event); + } + newline = buffer.indexOf("\n"); + } + } + } catch (error) { + void stopRemoteWorkspaceProcess(this.child).catch(() => {}); + this.fail(new Error(safeError(error, "Pi RPC output failed"))); + } finally { + reader.releaseLock(); + } + } + + private async drainStderr(): Promise { + const reader = this.child.stderr.getReader(); + try { while (!(await reader.read()).done) { /* drain without retaining secrets */ } } + catch { /* stdout/exit code owns the failure */ } + finally { reader.releaseLock(); } + } + + private receive(event: Record): void { + if (event.type === "response" && typeof event.id === "string") { + const pending = this.pending.get(event.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(event.id); + pending.resolve(event); + return; + } + if (event.type === "agent_settled") { + const active = this.activeSettle; + this.activeSettle = null; + active?.resolve(); + } + if (event.type === "extension_error") { + const active = this.activeSettle; + this.activeSettle = null; + active?.reject(new Error(safeError(event.error, "Pi Remote Workspace extension failed"))); + } + this.onEvent?.(event); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + const active = this.activeSettle; + this.activeSettle = null; + active?.reject(error); + } +} + +export interface PiRemoteWorkspaceRuntimeOptions { + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class PiRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "pi" as const; + + constructor(private readonly options: PiRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + const command = this.options.command && this.options.command.length > 0 + ? this.options.command[0] + : findExecutableOnPath("pi"); + return command + ? { available: true, ...(this.options.version ? { version: this.options.version } : {}) } + : { available: false, reason: "Pi is not installed on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const configuredCommand = this.options.command && this.options.command.length > 0 + ? [...this.options.command] + : null; + const executable = configuredCommand?.[0] ?? findExecutableOnPath("pi"); + if (!executable) throw new Error("Pi is not installed on this Hub"); + const commandPrefix = configuredCommand ?? [executable]; + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-pi-")); + try { + chmodSync(isolation, 0o700); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const extensionPath = join(isolation, "remote-workspace-extension.js"); + try { + writeFileSync(extensionPath, extensionSource(options.tools), { mode: 0o600 }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const threadId = options.resumeThreadId ?? randomUUID(); + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const childEnv = { + ...process.env, + ...this.options.env, + OCX_REMOTE_WORKSPACE_BRIDGE_URL: bridge.url, + OCX_REMOTE_WORKSPACE_BRIDGE_TOKEN: bridge.token, + }; + const invocation = remoteWorkspaceProcessInvocation([ + ...commandPrefix, + "--mode", "rpc", + "--session-id", threadId, + "--name", `OCX Remote: ${options.deviceName}`, + "--no-builtin-tools", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-themes", + "--no-context-files", + "--no-approve", + "--extension", extensionPath, + "--tools", options.tools.map(tool => `remote_${tool}`).join(","), + "--system-prompt", remotePiInstructions(options.deviceName, options.tools), + ], { env: childEnv }); + let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const rpc = new PiRpcProcess(child); + rpc.onEvent = event => { + if (event.type === "message_end") { + const text = messageText(event.message); + if (text) options.emit("assistant", text); + } + if (event.type === "tool_execution_start" && typeof event.toolName === "string") { + options.emit("tool", `Pi requested ${event.toolName}`); + } + }; + try { + const state = await rpc.command("get_state"); + if (state.success !== true) throw new Error(safeError(state.error, "Pi RPC failed to initialize")); + } catch (error) { + await rpc.close().catch(() => {}); + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + let stopped = false; + let stopOperation: Promise | null = null; + return { + threadId, + prompt: text => rpc.prompt(text), + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + stopOperation = runRemoteWorkspaceCleanupSteps([ + () => rpc.abort(), + () => rpc.close(), + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } +} diff --git a/src/remote-control/workspace-process.ts b/src/remote-control/workspace-process.ts new file mode 100644 index 0000000000..cc177e5ebc --- /dev/null +++ b/src/remote-control/workspace-process.ts @@ -0,0 +1,129 @@ +import { execFileSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { commandInvocation, type SpawnInvocation } from "../lib/win-exec"; +import { resolveTrustedWindowsTaskkillExe } from "../lib/windows-elevation"; + +export interface RemoteWorkspaceProcessInvocationOptions { + platform?: NodeJS.Platform; + env?: Record; +} + +/** + * Preserve argv boundaries on Unix and route Windows npm `.cmd`/`.bat` shims through the + * repository's audited ComSpec escaping. `shell: true` is deliberately never used. + */ +export function remoteWorkspaceProcessInvocation( + command: readonly string[], + options: RemoteWorkspaceProcessInvocationOptions = {}, +): SpawnInvocation { + if (command.length < 1 || !command[0]) throw new Error("remote workspace process command is empty"); + return commandInvocation( + command[0], + command.slice(1), + options.platform ?? process.platform, + { env: options.env ?? process.env }, + ); +} + +export interface RemoteWorkspaceOwnedProcess { + pid: number; + exitCode: number | null; + exited: Promise; + kill(signal?: number | NodeJS.Signals): void; +} + +export interface StopRemoteWorkspaceProcessOptions { + platform?: NodeJS.Platform; + taskkillPath?: string; + execFile?: (file: string, args: readonly string[]) => void; + waitMs?: number; +} + +export async function waitForRemoteWorkspaceProcessExit( + child: RemoteWorkspaceOwnedProcess, + waitMs: number, +): Promise { + if (!Number.isSafeInteger(waitMs) || waitMs < 1) throw new Error("invalid remote workspace process wait"); + let timer: ReturnType | null = null; + try { + return await Promise.race([ + child.exited.then(() => true, () => true), + new Promise(resolve => { timer = setTimeout(() => resolve(false), waitMs); }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** Run every owned-resource cleanup step and report the first failure only after all were attempted. */ +export async function runRemoteWorkspaceCleanupSteps( + steps: readonly (() => void | Promise)[], +): Promise { + let failed = false; + let firstFailure: unknown; + for (const step of steps) { + try { + await step(); + } catch (error) { + if (!failed) firstFailure = error; + failed = true; + } + } + if (failed) { + throw firstFailure instanceof Error + ? firstFailure + : new Error("remote workspace cleanup failed"); + } +} + +/** Stop only the process OCX spawned; Windows must include its `.cmd` descendant tree. */ +export async function stopRemoteWorkspaceProcess( + child: RemoteWorkspaceOwnedProcess, + options: StopRemoteWorkspaceProcessOptions = {}, +): Promise { + if (child.exitCode !== null) return; + const platform = options.platform ?? process.platform; + if (platform === "win32") { + const exec = options.execFile ?? ((file: string, args: readonly string[]) => { + execFileSync(file, [...args], { stdio: "ignore", timeout: 5_000, windowsHide: true }); + }); + try { + exec(options.taskkillPath ?? resolveTrustedWindowsTaskkillExe(), ["/PID", String(child.pid), "/T", "/F"]); + } catch { + try { child.kill(); } catch { /* child already exited */ } + } + if (!await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500)) { + throw new Error("remote workspace Windows process tree did not exit"); + } + } else { + try { child.kill("SIGTERM"); } catch { /* child already exited */ } + const exited = await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500); + if (!exited) { + try { child.kill("SIGKILL"); } catch { /* child already exited */ } + if (!await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500)) { + throw new Error("remote workspace process did not exit after SIGKILL"); + } + } + } +} + +/** Windows AV/indexers can retain just-exited CLI files briefly; use Node's bounded retry. */ +export function removeRemoteWorkspaceIsolation(path: string): void { + rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); +} + +/** + * [Decision Log] + * - 목적과 의도: Make Hub-owned Codex, Claude Code, and Pi processes start and stop identically + * across Linux, macOS, and Windows without leaving npm-shim descendants behind. + * - 기존 구현 및 제약 조건: Unix can spawn executable scripts directly. Windows npm exposes + * `.cmd` files that Bun cannot safely launch shell-less, and killing cmd.exe alone can orphan Node. + * - 검토한 주요 대안: `shell: true`, three runtime-specific wrappers, direct `.cmd` spawn, or the + * repository's existing escaped ComSpec invocation plus trusted System32 taskkill. + * - 선택한 방식: Share one launcher and one owned-process stop helper across all three runtimes. + * - 다른 대안 대신 이 방식을 선택한 이유: It preserves exact argv boundaries, avoids a PATH- + * resolved shell/taskkill hijack, and matches already-tested OpenCodex Windows behavior. + * - 장점, 단점 및 영향: Windows npm installs work and stop cleanly. Windows stop is necessarily + * forceful because its normal process kill is already forceful; Unix gets a graceful SIGTERM + * window and then a bounded SIGKILL fallback so an ignoring child cannot outlive the session. + */ diff --git a/src/remote-control/workspace-rpc.ts b/src/remote-control/workspace-rpc.ts new file mode 100644 index 0000000000..15f8c7a381 --- /dev/null +++ b/src/remote-control/workspace-rpc.ts @@ -0,0 +1,304 @@ +import type { RemoteControlCipher } from "./crypto"; +import type { + RemoteWorkspaceExecutionRequest, + RemoteWorkspaceExecutor, +} from "./workspace-executor"; +import { + isRemoteWorkspaceToolName, + remoteWorkspaceCapabilityForTool, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; +import type { RemoteWorkspaceTransport } from "./workspace-coordinator"; +import { + REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, + RemoteWorkspaceRpcReassembler, + frameRemoteWorkspaceRpcMessage, +} from "./workspace-rpc-framing"; + +const REMOTE_WORKSPACE_RPC_VERSION = 1 as const; +const REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS = 30_000; +const REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS = 8; +interface RemoteWorkspaceRpcRequest { + version: typeof REMOTE_WORKSPACE_RPC_VERSION; + kind: "request"; + request: RemoteWorkspaceExecutionRequest; +} + +interface RemoteWorkspaceRpcResponse { + version: typeof REMOTE_WORKSPACE_RPC_VERSION; + kind: "response"; + requestId: string; + result: RemoteWorkspaceToolResult; +} + +type RemoteWorkspaceRpcMessage = RemoteWorkspaceRpcRequest | RemoteWorkspaceRpcResponse; + +interface PendingRequest { + resolve(value: RemoteWorkspaceToolResult): void; + reject(error: Error): void; + timer: ReturnType; +} + +function boundedIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value); +} + +function encodeMessage(value: RemoteWorkspaceRpcMessage): Uint8Array { + const encoded = new TextEncoder().encode(JSON.stringify(value)); + if (encoded.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("remote workspace RPC message exceeds the bounded message limit"); + } + return encoded; +} + +function parseResult(value: unknown): RemoteWorkspaceToolResult { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace RPC result"); + const raw = value as Record; + if (Object.keys(raw).some(key => key !== "ok" && key !== "value" && key !== "error")) { + throw new Error("invalid remote workspace RPC result fields"); + } + if (raw.ok === true && raw.error === undefined) { + return raw.value === undefined ? { ok: true } : { ok: true, value: raw.value }; + } + if (raw.ok === false && raw.value === undefined + && typeof raw.error === "string" && raw.error.length >= 1 && raw.error.length <= 4096) { + return { ok: false, error: raw.error }; + } + throw new Error("invalid remote workspace RPC result status"); +} + +function parseRequest(value: unknown): RemoteWorkspaceExecutionRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace RPC request"); + const raw = value as Record; + if ( + !boundedIdentifier(raw.requestId) + || !boundedIdentifier(raw.sessionId) + || !boundedIdentifier(raw.executorDeviceId) + || !boundedIdentifier(raw.rootId) + || !isRemoteWorkspaceToolName(raw.tool) + ) throw new Error("invalid remote workspace RPC request identity"); + return { + requestId: raw.requestId, + sessionId: raw.sessionId, + executorDeviceId: raw.executorDeviceId, + rootId: raw.rootId, + tool: raw.tool, + arguments: raw.arguments, + }; +} + +function parseMessage(value: Uint8Array): RemoteWorkspaceRpcMessage { + if (!(value instanceof Uint8Array) || value.byteLength < 1 || value.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("invalid remote workspace RPC message length"); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error("invalid remote workspace RPC JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid remote workspace RPC message"); + const raw = parsed as Record; + if (raw.version !== REMOTE_WORKSPACE_RPC_VERSION) throw new Error("unsupported remote workspace RPC version"); + if (raw.kind === "request") { + return { version: REMOTE_WORKSPACE_RPC_VERSION, kind: "request", request: parseRequest(raw.request) }; + } + if (raw.kind === "response" && boundedIdentifier(raw.requestId)) { + return { + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: raw.requestId, + result: parseResult(raw.result), + }; + } + throw new Error("invalid remote workspace RPC message kind"); +} + +export interface EncryptedRemoteWorkspaceTransportOptions { + executorDeviceId: string; + cipher: RemoteControlCipher; + sendCiphertext(value: Uint8Array): void | Promise; + timeoutMs?: number; +} + +/** Coordinator-side transport. The WebSocket/relay adapter only has to carry ciphertext. */ +export class EncryptedRemoteWorkspaceTransport implements RemoteWorkspaceTransport { + private readonly pending = new Map(); + private readonly reassembler = new RemoteWorkspaceRpcReassembler(); + private readonly timeoutMs: number; + private sendTail: Promise = Promise.resolve(); + private online = true; + + constructor(private readonly options: EncryptedRemoteWorkspaceTransportOptions) { + this.timeoutMs = options.timeoutMs ?? REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS; + if (!boundedIdentifier(options.executorDeviceId) || !Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) { + throw new Error("invalid encrypted remote workspace transport options"); + } + } + + isOnline(deviceId: string): boolean { + return this.online && deviceId === this.options.executorDeviceId; + } + + async invoke(request: RemoteWorkspaceExecutionRequest): Promise { + if (!this.isOnline(request.executorDeviceId)) throw new Error("remote workspace executor is offline"); + if (this.pending.has(request.requestId)) throw new Error("duplicate remote workspace request ID"); + if (this.pending.size >= REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS) { + throw new Error("remote workspace request limit reached"); + } + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(request.requestId); + reject(new Error("remote workspace request timed out")); + }, this.timeoutMs); + this.pending.set(request.requestId, { resolve, reject, timer }); + }); + try { + await this.sendMessage(encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "request", + request, + })); + } catch { + // A failed encrypted write consumes a directional counter. Continuing would make every + // later frame undecryptable, so fail every pending operation instead of waiting for timeout. + this.close("remote workspace send failed"); + } + return await response; + } + + receiveCiphertext(value: Uint8Array): void { + if (!this.online) throw new Error("remote workspace transport is closed"); + const responsePlaintext = this.reassembler.accept(this.options.cipher.decrypt(value)); + if (!responsePlaintext) return; + const message = parseMessage(responsePlaintext); + if (message.kind !== "response") throw new Error("coordinator received a remote workspace request"); + const pending = this.pending.get(message.requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.requestId); + pending.resolve(message.result); + } + + close(reason = "remote workspace transport closed"): void { + if (!this.online) return; + this.online = false; + this.reassembler.clear(); + this.options.cipher.destroy(); + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + this.pending.clear(); + } + + private sendMessage(message: Uint8Array): Promise { + const operation = this.sendTail.then(async () => { + if (!this.online) throw new Error("remote workspace transport is closed"); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await this.options.sendCiphertext(this.options.cipher.encrypt(frame)); + } + }); + this.sendTail = operation.catch(() => {}); + return operation; + } +} + +export interface EncryptedRemoteWorkspaceExecutorEndpointOptions { + executorDeviceId: string; + sessionId: string; + rootId: string; + capabilities: readonly RemoteWorkspaceCapability[]; + cipher: RemoteControlCipher; + executor: Pick; + sendCiphertext(value: Uint8Array): void | Promise; +} + +/** Executor-side endpoint. It accepts only authenticated, ordered E2EE session frames. */ +export class EncryptedRemoteWorkspaceExecutorEndpoint { + private closed = false; + private readonly active = new Map(); + private readonly reassembler = new RemoteWorkspaceRpcReassembler(); + private sendTail: Promise = Promise.resolve(); + + private readonly grantedCapabilities: ReadonlySet; + private readonly sessionId: string; + private readonly rootId: string; + + constructor(private readonly options: EncryptedRemoteWorkspaceExecutorEndpointOptions) { + if (!boundedIdentifier(options.executorDeviceId) || !boundedIdentifier(options.sessionId) + || !boundedIdentifier(options.rootId)) throw new Error("invalid remote workspace executor endpoint"); + this.options = { ...options }; + this.sessionId = options.sessionId; + this.rootId = options.rootId; + this.grantedCapabilities = new Set(options.capabilities); + } + + async receiveCiphertext(value: Uint8Array): Promise { + if (this.closed) throw new Error("remote workspace executor endpoint is closed"); + const requestPlaintext = this.reassembler.accept(this.options.cipher.decrypt(value)); + if (!requestPlaintext) return; + const message = parseMessage(requestPlaintext); + if (message.kind !== "request") throw new Error("executor received a remote workspace response"); + if (message.request.executorDeviceId !== this.options.executorDeviceId) { + throw new Error("remote workspace encrypted request targeted another executor"); + } + if (message.request.sessionId !== this.sessionId || message.request.rootId !== this.rootId) { + throw new Error("remote workspace encrypted request does not match its session binding"); + } + if (!this.grantedCapabilities.has(remoteWorkspaceCapabilityForTool(message.request.tool))) { + throw new Error("remote workspace tool capability was not granted to this session"); + } + if (this.active.has(message.request.requestId)) throw new Error("duplicate remote workspace executor request ID"); + if (this.active.size >= REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS) { + throw new Error("remote workspace executor request limit reached"); + } + const controller = new AbortController(); + this.active.set(message.request.requestId, controller); + let result: RemoteWorkspaceToolResult; + try { + result = await this.options.executor.invoke(message.request, controller.signal); + } finally { + this.active.delete(message.request.requestId); + } + if (this.closed) return; + let responsePlaintext: Uint8Array; + try { + responsePlaintext = encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: message.request.requestId, + result, + }); + } catch { + responsePlaintext = encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: message.request.requestId, + result: { ok: false, error: "remote workspace result exceeded the encrypted frame limit" }, + }); + } + await this.sendMessage(responsePlaintext); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.reassembler.clear(); + for (const controller of this.active.values()) controller.abort(); + this.active.clear(); + this.options.cipher.destroy(); + } + + private sendMessage(message: Uint8Array): Promise { + const operation = this.sendTail.then(async () => { + if (this.closed) throw new Error("remote workspace executor endpoint is closed"); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await this.options.sendCiphertext(this.options.cipher.encrypt(frame)); + } + }); + this.sendTail = operation.catch(() => {}); + return operation; + } +} diff --git a/src/remote-control/workspace-runtime.ts b/src/remote-control/workspace-runtime.ts new file mode 100644 index 0000000000..01596753c2 --- /dev/null +++ b/src/remote-control/workspace-runtime.ts @@ -0,0 +1,60 @@ +import type { OcxConfig } from "../types"; +import { + RemoteWorkspaceHub, + RemoteWorkspaceHubFileStore, + type RemoteWorkspaceHubStateStore, +} from "./workspace-hub"; +import { CodexRemoteWorkspaceRuntimeFactory } from "./workspace-codex-runtime"; +import { ClaudeRemoteWorkspaceRuntimeFactory } from "./workspace-claude-runtime"; +import { PiRemoteWorkspaceRuntimeFactory } from "./workspace-pi-runtime"; +import { + RemoteWorkspaceSessionFileStore, + RemoteWorkspaceSessionService, +} from "./workspace-sessions"; + +const hubs = new WeakMap(); +const sessionServices = new WeakMap(); + +export function remoteWorkspaceHubForConfig( + config: Readonly, + store?: RemoteWorkspaceHubStateStore, +): RemoteWorkspaceHub { + if (config.runtimeRole !== "hub") throw new Error("remote workspace requires runtimeRole=hub"); + const existing = hubs.get(config); + if (existing) return existing; + const hub = new RemoteWorkspaceHub(store ?? new RemoteWorkspaceHubFileStore()); + hubs.set(config, hub); + return hub; +} + +export function remoteWorkspaceSessionsForConfig( + config: Readonly, +): RemoteWorkspaceSessionService { + if (config.runtimeRole !== "hub") throw new Error("remote workspace requires runtimeRole=hub"); + const existing = sessionServices.get(config); + if (existing) return existing; + const service = new RemoteWorkspaceSessionService( + remoteWorkspaceHubForConfig(config), + [ + new CodexRemoteWorkspaceRuntimeFactory(), + new ClaudeRemoteWorkspaceRuntimeFactory(), + new PiRemoteWorkspaceRuntimeFactory(), + ], + Date.now, + new RemoteWorkspaceSessionFileStore(), + ); + sessionServices.set(config, service); + return service; +} + +export function initializedRemoteWorkspaceHubForConfig( + config: Readonly, +): RemoteWorkspaceHub | null { + return hubs.get(config) ?? null; +} + +export function initializedRemoteWorkspaceSessionsForConfig( + config: Readonly, +): RemoteWorkspaceSessionService | null { + return sessionServices.get(config) ?? null; +} diff --git a/src/remote-control/workspace-secret-store.ts b/src/remote-control/workspace-secret-store.ts new file mode 100644 index 0000000000..9ff1c29249 --- /dev/null +++ b/src/remote-control/workspace-secret-store.ts @@ -0,0 +1,39 @@ +import { chmodSync, lstatSync, mkdirSync } from "node:fs"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; + +export interface WorkspaceSecretPermissions { + prepareDirectory(path: string): void; + hardenFile(path: string): void; +} + +/** Only ENOENT means first-run absence; permission failures must not reset identity. */ +export function workspaceSecretFileExists(path: string): boolean { + try { lstatSync(path); return true; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export const workspaceSecretPermissions: WorkspaceSecretPermissions = { + prepareDirectory(path) { + assertNotRealHomeUnderTest(path); + mkdirSync(path, { recursive: true, mode: 0o700 }); + const metadata = lstatSync(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace secret directory must be a real directory"); + } + if (process.platform === "win32") hardenSecretDir(path, { required: true }); + else chmodSync(path, 0o700); + }, + hardenFile(path) { + assertNotRealHomeUnderTest(path); + const metadata = lstatSync(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) { + throw new Error("remote workspace secret must be a private regular file"); + } + if (process.platform === "win32") hardenSecretPath(path, { required: true }); + else chmodSync(path, 0o600); + }, +}; diff --git a/src/remote-control/workspace-sessions.ts b/src/remote-control/workspace-sessions.ts new file mode 100644 index 0000000000..775756200e --- /dev/null +++ b/src/remote-control/workspace-sessions.ts @@ -0,0 +1,730 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { RemoteWorkspaceCoordinator, type RemoteWorkspaceTransport } from "./workspace-coordinator"; +import type { RemoteWorkspaceHub } from "./workspace-hub"; +import { isRemoteWorkspaceAgentProfile, type RemoteWorkspaceAgentProfile } from "./workspace-agent-protocol"; +import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor"; +import { runRemoteWorkspaceCleanupSteps } from "./workspace-process"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; +import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol"; +import { + parseRemoteWorkspaceCapabilities, + remoteWorkspaceToolsForCapabilities, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_SESSION_STATE_VERSION = 1 as const; + +export type RemoteWorkspaceSessionStatus = + | "starting" + | "ready" + | "running" + | "waiting_for_executor" + | "failed" + | "stopped"; + +export type RemoteWorkspaceAccessMode = "read-only" | "workspace"; + +export interface RemoteWorkspaceSessionEvent { + sequence: number; + at: string; + type: "status" | "assistant" | "tool" | "error"; + text: string; +} + +export interface RemoteWorkspaceSessionSummary { + id: string; + profile: RemoteWorkspaceAgentProfile; + accessMode: RemoteWorkspaceAccessMode; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; + threadId: string | null; + /** True only after the runtime has created durable history that can be resumed. */ + resumable: boolean; + status: RemoteWorkspaceSessionStatus; + createdAt: string; + updatedAt: string; + events: RemoteWorkspaceSessionEvent[]; +} + +export interface RemoteWorkspaceRuntimeHandle { + threadId: string; + canResume?(): boolean; + prompt(text: string): Promise; + stop(): Promise; +} + +export interface RemoteWorkspaceRuntimeFactory { + profile: RemoteWorkspaceAgentProfile; + available(): Promise<{ available: boolean; version?: string; reason?: string }>; + start(options: { + sessionId: string; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; + resumeThreadId?: string; + coordinator: RemoteWorkspaceCoordinator; + emit(type: RemoteWorkspaceSessionEvent["type"], text: string): void; + }): Promise; +} + +export interface RemoteWorkspaceSessionState { + version: typeof REMOTE_WORKSPACE_SESSION_STATE_VERSION; + sessions: RemoteWorkspaceSessionSummary[]; +} + +export interface RemoteWorkspaceSessionStateStore { + load(): RemoteWorkspaceSessionState | null; + save(state: RemoteWorkspaceSessionState): void; +} + +interface LiveSession extends RemoteWorkspaceSessionSummary { + handle: RemoteWorkspaceRuntimeHandle | null; + unregister: (() => void) | null; + closeTransport: (() => Promise) | null; + operation: Promise; + stopOperation: Promise | null; + remoteTransport: SwitchableRemoteWorkspaceTransport | null; + turnActive: boolean; +} + +const MAX_EVENTS_PER_SESSION = 100; +const MAX_EVENT_TEXT_BYTES = 8 * 1024; +const MAX_PROMPT_BYTES = 256 * 1024; +const MAX_LIVE_SESSIONS = 8; +const MAX_RETAINED_SESSIONS = 64; +const MAX_LIST_EVENTS_PER_SESSION = 20; +const MAX_PERSISTED_EVENTS_PER_SESSION = 40; +const MAX_PERSISTED_EVENT_TEXT_BYTES = 4 * 1024; +const MAX_SESSION_STATE_BYTES = 16 * 1024 * 1024; +const AVAILABILITY_CACHE_MS = 30_000; +type RuntimeAvailability = Record; + +class SwitchableRemoteWorkspaceTransport implements RemoteWorkspaceTransport { + constructor(private current: RemoteWorkspaceTransport) {} + + replace(next: RemoteWorkspaceTransport): void { + this.current = next; + } + + isOnline(deviceId: string): boolean { + return this.current.isOnline(deviceId); + } + + invoke(request: RemoteWorkspaceExecutionRequest): Promise { + return this.current.invoke(request); + } +} + +function boundedPrompt(value: unknown): string { + if (typeof value !== "string" || value.trim().length < 1 || Buffer.byteLength(value, "utf8") > MAX_PROMPT_BYTES) { + throw new Error("remote workspace prompt must contain 1 to 262144 UTF-8 bytes"); + } + return value; +} + +function boundedEventText(value: string): string { + if (Buffer.byteLength(value, "utf8") <= MAX_EVENT_TEXT_BYTES) return value; + const marker = "\n[truncated]"; + return `${truncateRemoteWorkspaceUtf8(value, MAX_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"))}${marker}`; +} + +function boundedPersistedEventText(value: string): string { + if (Buffer.byteLength(value, "utf8") <= MAX_PERSISTED_EVENT_TEXT_BYTES) return value; + const marker = "\n[truncated for restart snapshot]"; + const maximum = MAX_PERSISTED_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"); + return `${truncateRemoteWorkspaceUtf8(value, maximum)}${marker}`; +} + +function boundedString(value: unknown, label: string, maximum = 256): string { + if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function timestamp(value: unknown): string { + const result = boundedString(value, "timestamp", 64); + if (!Number.isFinite(Date.parse(result))) throw new Error("invalid remote workspace timestamp"); + return result; +} + +function parseStatus(value: unknown): RemoteWorkspaceSessionStatus { + if (value === "starting" || value === "ready" || value === "running" + || value === "waiting_for_executor" || value === "failed" || value === "stopped") return value; + throw new Error("invalid remote workspace session status"); +} + +function parseAccessMode(value: unknown): RemoteWorkspaceAccessMode { + if (value === undefined || value === "workspace") return "workspace"; + if (value === "read-only") return value; + throw new Error("invalid remote workspace access mode"); +} + +function parseEvent(value: unknown): RemoteWorkspaceSessionEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session event"); + const raw = value as Record; + if (typeof raw.sequence !== "number" || !Number.isSafeInteger(raw.sequence) || raw.sequence < 1) { + throw new Error("invalid remote workspace event sequence"); + } + if (raw.type !== "status" && raw.type !== "assistant" && raw.type !== "tool" && raw.type !== "error") { + throw new Error("invalid remote workspace event type"); + } + return { + sequence: raw.sequence, + at: timestamp(raw.at), + type: raw.type, + text: boundedString(raw.text, "event text", MAX_EVENT_TEXT_BYTES), + }; +} + +function parseSession(value: unknown): RemoteWorkspaceSessionSummary { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session"); + const raw = value as Record; + if (!isRemoteWorkspaceAgentProfile(raw.profile)) throw new Error("invalid remote workspace session profile"); + const accessMode = parseAccessMode(raw.accessMode); + const capabilities = parseRemoteWorkspaceCapabilities(raw.capabilities); + if (accessMode === "read-only" + && (capabilities.length !== 1 || capabilities[0] !== "workspace.read")) { + throw new Error("read-only remote workspace state contains write capabilities"); + } + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + if (!Array.isArray(raw.events) || raw.events.length > MAX_EVENTS_PER_SESSION) { + throw new Error("invalid remote workspace session events"); + } + if (raw.threadId !== null && typeof raw.threadId !== "string") throw new Error("invalid remote workspace thread ID"); + const resumable = raw.resumable === undefined + ? raw.threadId !== null + : raw.resumable === true; + if (raw.resumable !== undefined && typeof raw.resumable !== "boolean") { + throw new Error("invalid remote workspace resumable state"); + } + if (resumable && raw.threadId === null) throw new Error("resumable remote workspace session has no thread ID"); + return { + id: boundedString(raw.id, "session ID"), + profile: raw.profile, + accessMode, + deviceId: boundedString(raw.deviceId, "device ID"), + deviceName: boundedString(raw.deviceName, "device name", 80), + rootId: boundedString(raw.rootId, "root ID"), + rootLabel: boundedString(raw.rootLabel, "root label", 80), + capabilities, + tools, + threadId: raw.threadId === null ? null : boundedString(raw.threadId, "thread ID"), + resumable, + status: parseStatus(raw.status), + createdAt: timestamp(raw.createdAt), + updatedAt: timestamp(raw.updatedAt), + events: raw.events.map(parseEvent), + }; +} + +export function parseRemoteWorkspaceSessionState(value: unknown): RemoteWorkspaceSessionState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_SESSION_STATE_VERSION || !Array.isArray(raw.sessions)) { + throw new Error("unsupported remote workspace session state"); + } + if (raw.sessions.length > MAX_RETAINED_SESSIONS) throw new Error("remote workspace retained session limit exceeded"); + const ids = new Set(); + const sessions = raw.sessions.map(item => { + const session = parseSession(item); + if (ids.has(session.id)) throw new Error("duplicate remote workspace session ID"); + ids.add(session.id); + return session; + }); + return { version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions }; +} + +export class RemoteWorkspaceSessionFileStore implements RemoteWorkspaceSessionStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-sessions.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceSessionState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_SESSION_STATE_BYTES) { + throw new Error("remote workspace session state is too large"); + } + return parseRemoteWorkspaceSessionState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceSessionState): void { + const parsed = parseRemoteWorkspaceSessionState(state); + const body = `${JSON.stringify(parsed, null, 2)}\n`; + if (Buffer.byteLength(body, "utf8") > MAX_SESSION_STATE_BYTES) { + throw new Error("remote workspace session state is too large"); + } + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, body); + } +} + +export class RemoteWorkspaceSessionService { + private readonly sessions = new Map(); + private readonly runtimes = new Map(); + private sequence = 0; + private availabilityCache: { at: number; value: RuntimeAvailability } | null = null; + private availabilityFlight: Promise | null = null; + + constructor( + private readonly hub: RemoteWorkspaceHub, + factories: readonly RemoteWorkspaceRuntimeFactory[], + private readonly now: () => number = Date.now, + private readonly store?: RemoteWorkspaceSessionStateStore, + ) { + for (const factory of factories) { + if (this.runtimes.has(factory.profile)) throw new Error("duplicate remote workspace runtime profile"); + this.runtimes.set(factory.profile, factory); + } + for (const summary of this.store?.load()?.sessions ?? []) { + const restoredStatus = summary.status === "stopped" + ? "stopped" + : summary.threadId && summary.resumable + ? "waiting_for_executor" + : "failed"; + this.sessions.set(summary.id, { + ...summary, + status: restoredStatus, + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }); + for (const event of summary.events) this.sequence = Math.max(this.sequence, event.sequence); + } + } + + async availability(): Promise { + if (this.availabilityCache && this.now() - this.availabilityCache.at < AVAILABILITY_CACHE_MS) { + return structuredClone(this.availabilityCache.value); + } + if (this.availabilityFlight) return structuredClone(await this.availabilityFlight); + this.availabilityFlight = (async () => { + const probe = async (profile: RemoteWorkspaceAgentProfile) => { + const factory = this.runtimes.get(profile); + if (!factory) return { available: false, reason: "runtime adapter is not installed" }; + try { return await factory.available(); } + catch { return { available: false, reason: "runtime availability probe failed" }; } + }; + const [codex, claude, pi] = await Promise.all([ + probe("codex"), + probe("claude"), + probe("pi"), + ]); + const value: RuntimeAvailability = { codex, claude, pi }; + this.availabilityCache = { at: this.now(), value }; + return value; + })(); + try { return structuredClone(await this.availabilityFlight); } + finally { this.availabilityFlight = null; } + } + + list(): RemoteWorkspaceSessionSummary[] { + this.refreshOfflineStates(); + return [...this.sessions.values()].map(session => this.publicSession(session, MAX_LIST_EVENTS_PER_SESSION)); + } + + get(sessionId: string): RemoteWorkspaceSessionSummary | null { + this.refreshOfflineStates(); + const session = this.sessions.get(sessionId); + return session ? this.publicSession(session) : null; + } + + async create(input: { + profile: RemoteWorkspaceAgentProfile; + deviceId: string; + rootId: string; + accessMode?: RemoteWorkspaceAccessMode; + }): Promise { + this.pruneRetainedSessions(); + const liveCount = [...this.sessions.values()].filter(session => session.handle !== null).length; + if (liveCount >= MAX_LIVE_SESSIONS) throw new Error("remote workspace active session limit reached"); + const deviceLiveCount = [...this.sessions.values()].filter(session => ( + session.deviceId === input.deviceId && session.handle !== null + )).length; + if (deviceLiveCount >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + const factory = this.runtimes.get(input.profile); + if (!factory) throw new Error(`remote workspace ${input.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${input.profile} runtime is unavailable`); + const device = this.hub.listDevices().find(candidate => candidate.id === input.deviceId); + if (!device) throw new Error("remote workspace device not found"); + const root = device.roots.find(candidate => candidate.id === input.rootId); + if (!root) throw new Error("remote workspace root not found on the selected device"); + const connection = this.hub.connection(device.id); + if (!connection) throw new Error("remote workspace executor is offline"); + const id = randomUUID(); + const accessMode = parseAccessMode(input.accessMode ?? "read-only"); + const deviceCapabilities = parseRemoteWorkspaceCapabilities(device.capabilities); + const capabilities = accessMode === "read-only" + ? parseRemoteWorkspaceCapabilities(["workspace.read"]) + : deviceCapabilities; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + const connectionCapabilities = connection.capabilities(); + if (capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capability advertisement is stale; refresh and try again"); + } + const timestamp = new Date(this.now()).toISOString(); + const session: LiveSession = { + id, + profile: input.profile, + accessMode, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + threadId: null, + resumable: false, + status: "starting", + createdAt: timestamp, + updatedAt: timestamp, + events: [], + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }; + this.sessions.set(id, session); + this.emit(session, "status", `Starting ${input.profile} on ${device.name}/${root.label}`); + try { + this.persist(); + } catch (error) { + this.sessions.delete(id); + throw error; + } + try { + session.closeTransport = () => connection.closeSession(id); + const transport = await connection.openSession({ sessionId: id, rootId: root.id, profile: input.profile, capabilities }); + if (session.stopOperation) { + await session.closeTransport().catch(() => {}); + session.closeTransport = null; + throw new Error("remote workspace session was stopped while starting"); + } + const remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.remoteTransport = remoteTransport; + const coordinator = new RemoteWorkspaceCoordinator(remoteTransport); + const handle = await factory.start({ + sessionId: id, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + if (session.stopOperation) { + await handle.stop().catch(() => {}); + throw new Error("remote workspace session was stopped while starting"); + } + session.threadId = handle.threadId; + session.resumable = handle.canResume?.() ?? true; + session.handle = handle; + session.unregister = coordinator.register({ + sessionId: id, + threadId: handle.threadId, + executorDeviceId: device.id, + executorName: device.name, + rootId: root.id, + capabilities, + tools, + }); + this.status(session, "ready", `${input.profile} is ready on ${device.name}/${root.label}`); + return this.publicSession(session); + } catch (error) { + let reported = error; + if (session.status !== "stopped") { + try { + this.status(session, "failed", error instanceof Error ? error.message : "remote workspace session failed to start"); + } catch (persistenceError) { + reported = persistenceError; + } + } + session.unregister?.(); + session.unregister = null; + await session.handle?.stop().catch(() => {}); + session.handle = null; + await session.closeTransport?.().catch(() => {}); + session.closeTransport = null; + session.remoteTransport = null; + throw reported; + } + } + + async prompt(sessionId: string, value: unknown): Promise { + const prompt = boundedPrompt(value); + const session = this.sessions.get(sessionId); + if (!session || session.status === "stopped") throw new Error("remote workspace session is not ready"); + if (!session.handle && (!session.threadId || !session.resumable)) { + throw new Error("remote workspace session cannot be resumed"); + } + if (session.turnActive) throw new Error("remote workspace session already has an active turn"); + if (session.stopOperation) throw new Error("remote workspace session is stopping"); + session.turnActive = true; + const run = async () => { + try { + await this.ensureRemoteTransport(session); + await this.ensureRuntime(session); + if (session.stopOperation) throw new Error("remote workspace session is stopping"); + this.status(session, "running", "Turn started"); + await session.handle!.prompt(prompt); + session.resumable = session.handle!.canResume?.() ?? true; + if (!this.hub.connection(session.deviceId) + || !session.remoteTransport?.isOnline(session.deviceId)) { + this.status(session, "waiting_for_executor", "Turn completed; reconnect the remote executor before continuing."); + } else { + this.status(session, "ready", "Turn completed"); + } + } catch (error) { + const message = error instanceof Error ? error.message : "remote workspace turn failed"; + this.status(session, this.hub.connection(session.deviceId) ? "failed" : "waiting_for_executor", message); + throw error; + } finally { + session.turnActive = false; + } + }; + session.operation = run(); + await session.operation; + return this.publicSession(session); + } + + async stop(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + if (session.stopOperation) return session.stopOperation; + session.stopOperation = (async () => { + const handle = session.handle; + const activeOperation = session.operation; + // Cancellation has to run before waiting for the active turn. Waiting first makes + // Stop unable to interrupt a model request or remote command that never completes. + try { + await runRemoteWorkspaceCleanupSteps([ + async () => { if (handle) await handle.stop(); }, + () => activeOperation.catch(() => {}), + () => { session.unregister?.(); session.unregister = null; }, + async () => { if (session.closeTransport) await session.closeTransport(); }, + () => { + session.closeTransport = null; + session.handle = null; + session.remoteTransport = null; + }, + ]); + } catch (error) { + this.status(session, "failed", "Session cleanup failed; one or more owned resources did not close."); + throw error; + } + this.status(session, "stopped", "Session stopped"); + return true; + })(); + return session.stopOperation; + } + + async stopAll(): Promise { + const active = [...this.sessions.values()].filter(session => session.status !== "stopped"); + await Promise.all(active.map(session => this.stop(session.id).then(() => undefined))); + this.persist(); + } + + async shutdown(): Promise { + const active = [...this.sessions.values()].filter(session => session.status !== "stopped"); + await Promise.all(active.map(async session => { + if (session.stopOperation) { + await session.stopOperation; + return; + } + session.stopOperation = (async () => { + const handle = session.handle; + const activeOperation = session.operation; + try { + await runRemoteWorkspaceCleanupSteps([ + async () => { if (handle) await handle.stop(); }, + () => activeOperation.catch(() => {}), + () => { session.unregister?.(); session.unregister = null; }, + async () => { if (session.closeTransport) await session.closeTransport(); }, + () => { + session.closeTransport = null; + session.handle = null; + session.remoteTransport = null; + }, + ]); + } catch (error) { + this.status(session, "failed", "Hub shutdown could not close every Remote Workspace resource."); + throw error; + } + this.status( + session, + session.threadId && session.resumable ? "waiting_for_executor" : "failed", + session.threadId && session.resumable + ? "Hub stopped; reconnect the executor to resume this session." + : "Hub stopped before the model session was created.", + ); + return true; + })(); + await session.stopOperation; + })); + this.persist(); + } + + private status(session: LiveSession, status: RemoteWorkspaceSessionStatus, text: string): void { + session.status = status; + this.emit(session, status === "failed" ? "error" : "status", text); + this.persist(); + } + + private emit(session: LiveSession, type: RemoteWorkspaceSessionEvent["type"], text: string): void { + const at = new Date(this.now()).toISOString(); + session.updatedAt = at; + session.events.push({ sequence: ++this.sequence, at, type, text: boundedEventText(text) }); + if (session.events.length > MAX_EVENTS_PER_SESSION) { + session.events.splice(0, session.events.length - MAX_EVENTS_PER_SESSION); + } + } + + private publicSession(session: LiveSession, eventLimit = MAX_EVENTS_PER_SESSION): RemoteWorkspaceSessionSummary { + const { + handle: _handle, + unregister: _unregister, + closeTransport: _close, + operation: _operation, + stopOperation: _stopOperation, + remoteTransport: _remoteTransport, + turnActive: _turnActive, + ...publicState + } = session; + return structuredClone({ ...publicState, events: publicState.events.slice(-eventLimit) }); + } + + private async ensureRemoteTransport(session: LiveSession): Promise { + if (session.remoteTransport?.isOnline(session.deviceId)) return; + const connection = this.hub.connection(session.deviceId); + if (!connection) { + this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled."); + throw new Error("remote workspace executor is offline"); + } + const connectionCapabilities = connection.capabilities(); + if (session.capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capabilities changed; start a new session for this computer"); + } + this.status(session, "starting", `Reconnecting ${session.deviceName}/${session.rootLabel}`); + const transport = await connection.openSession({ + sessionId: session.id, + rootId: session.rootId, + profile: session.profile, + capabilities: session.capabilities, + }); + if (session.stopOperation) { + await connection.closeSession(session.id).catch(() => {}); + throw new Error("remote workspace session is stopping"); + } + await session.closeTransport?.().catch(() => {}); + if (session.remoteTransport) session.remoteTransport.replace(transport); + else session.remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.closeTransport = () => connection.closeSession(session.id); + this.status(session, "ready", `${session.profile} reconnected to ${session.deviceName}/${session.rootLabel}`); + } + + private async ensureRuntime(session: LiveSession): Promise { + if (session.handle) return; + if (!session.threadId || !session.resumable || !session.remoteTransport) { + throw new Error("remote workspace session cannot be resumed"); + } + const factory = this.runtimes.get(session.profile); + if (!factory) throw new Error(`remote workspace ${session.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${session.profile} runtime is unavailable`); + const coordinator = new RemoteWorkspaceCoordinator(session.remoteTransport); + const handle = await factory.start({ + sessionId: session.id, + deviceId: session.deviceId, + deviceName: session.deviceName, + rootId: session.rootId, + rootLabel: session.rootLabel, + capabilities: [...session.capabilities], + tools: [...session.tools], + resumeThreadId: session.threadId, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + try { + session.unregister = coordinator.register({ + sessionId: session.id, + threadId: handle.threadId, + executorDeviceId: session.deviceId, + executorName: session.deviceName, + rootId: session.rootId, + capabilities: [...session.capabilities], + tools: [...session.tools], + }); + } catch (error) { + await handle.stop().catch(() => {}); + throw error; + } + session.threadId = handle.threadId; + session.handle = handle; + this.status(session, "ready", `${session.profile} resumed on ${session.deviceName}/${session.rootLabel}`); + } + + private refreshOfflineStates(): void { + for (const session of this.sessions.values()) { + if (session.status !== "ready" || this.hub.connection(session.deviceId)) continue; + this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled."); + } + } + + private pruneRetainedSessions(): void { + if (this.sessions.size < MAX_RETAINED_SESSIONS) return; + for (const [id, session] of this.sessions) { + if (session.status !== "stopped" && !(session.status === "failed" && session.handle === null)) continue; + this.sessions.delete(id); + if (this.sessions.size < MAX_RETAINED_SESSIONS) return; + } + if (this.sessions.size >= MAX_RETAINED_SESSIONS) { + throw new Error("remote workspace retained session limit reached; stop an active session first"); + } + } + + private persist(): void { + if (!this.store) return; + const sessions = [...this.sessions.values()].map(session => { + const summary = this.publicSession(session); + return { + ...summary, + events: summary.events.slice(-MAX_PERSISTED_EVENTS_PER_SESSION).map(event => ({ + ...event, + text: boundedPersistedEventText(event.text), + })), + }; + }); + this.store.save({ version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions }); + } +} diff --git a/src/remote-control/workspace-tool-bridge.ts b/src/remote-control/workspace-tool-bridge.ts new file mode 100644 index 0000000000..7a3b6306a3 --- /dev/null +++ b/src/remote-control/workspace-tool-bridge.ts @@ -0,0 +1,192 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import type { RemoteWorkspaceCoordinator } from "./workspace-coordinator"; +import { + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + isRemoteWorkspaceToolName, + type RemoteWorkspaceToolName, +} from "./workspace-tools"; + +const MAX_BRIDGE_BODY_BYTES = 512 * 1024; +const MAX_BRIDGE_ACTIVE_REQUESTS = 8; +function json(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "cache-control": "no-store" } }); +} + +function errorText(value: unknown): string { + return (value instanceof Error ? value.message : "Remote Workspace tool failed") + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +async function readBoundedJson(req: Request): Promise { + if (!req.body) throw new Error("invalid JSON"); + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_BRIDGE_BODY_BYTES) { + await reader.cancel("request too large").catch(() => {}); + throw new Error("request too large"); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); +} + +export interface RemoteWorkspaceToolBridge { + url: string; + token: string; + stop(): Promise; +} + +/** + * Loopback-only bridge used by Hub-owned CLIs whose extension boundary is HTTP. + * The random bearer is passed only to the child process. The model sees tool schemas, + * never this endpoint or token, and every invocation still goes through the E2EE coordinator. + */ +export function startRemoteWorkspaceToolBridge(options: { + coordinator: RemoteWorkspaceCoordinator; + threadId: string | (() => string); + tools: readonly RemoteWorkspaceToolName[]; + onTool?: (tool: RemoteWorkspaceToolName) => void; +}): RemoteWorkspaceToolBridge { + const token = randomBytes(32).toString("base64url"); + const toolNames = new Set(options.tools); + const definitions = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => toolNames.has(tool.name)); + if (definitions.length < 1) throw new Error("Remote Workspace bridge needs at least one tool"); + const invoke = async (tool: unknown, args: unknown): Promise<{ success: boolean; text: string }> => { + if (!isRemoteWorkspaceToolName(tool) || !toolNames.has(tool)) { + return { success: false, text: JSON.stringify({ ok: false, error: "unknown Remote Workspace tool" }) }; + } + options.onTool?.(tool); + const threadId = typeof options.threadId === "function" ? options.threadId() : options.threadId; + if (!threadId) return { success: false, text: JSON.stringify({ ok: false, error: "remote workspace thread is not ready" }) }; + const result = await options.coordinator.handle({ + method: "item/tool/call", + id: randomUUID(), + params: { + threadId, + turnId: randomUUID(), + callId: randomUUID(), + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool, + arguments: args, + }, + }); + return { success: result.result.success, text: result.result.contentItems[0]!.text }; + }; + let activeRequests = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (req.headers.get("origin")) return json({ error: "browser origins are not allowed" }, 403); + if (req.headers.get("authorization") !== `Bearer ${token}`) return json({ error: "unauthorized" }, 401); + if (req.method !== "POST" || (url.pathname !== "/invoke" && url.pathname !== "/mcp")) { + return json({ error: "not found" }, 404); + } + if (activeRequests >= MAX_BRIDGE_ACTIVE_REQUESTS) return json({ error: "Remote Workspace bridge is busy" }, 429); + activeRequests += 1; + try { + const length = Number(req.headers.get("content-length") ?? "0"); + if (!Number.isFinite(length) || length > MAX_BRIDGE_BODY_BYTES) return json({ error: "request too large" }, 413); + let parsed: unknown; + try { parsed = await readBoundedJson(req); } + catch (error) { + return json({ error: error instanceof Error && error.message === "request too large" ? error.message : "invalid JSON" }, + error instanceof Error && error.message === "request too large" ? 413 : 400); + } + const body = record(parsed); + if (!body) return json({ error: "invalid request" }, 400); + + if (url.pathname === "/invoke") { + try { + return json(await invoke(body.tool, body.arguments)); + } catch (error) { + return json({ success: false, text: JSON.stringify({ ok: false, error: errorText(error) }) }, 502); + } + } + + const id = body.id; + const method = body.method; + const params = record(body.params) ?? {}; + if (typeof method !== "string") return json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32_600, message: "invalid MCP request" } }); + if (method === "notifications/initialized") return new Response(null, { status: 202 }); + if (method === "initialize") { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { + protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2025-06-18", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "opencodex-remote-workspace", version: "1" }, + }, + }); + } + if (method === "ping") return json({ jsonrpc: "2.0", id: id ?? null, result: {} }); + if (method === "tools/list") { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { + tools: definitions.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }, + }); + } + if (method === "tools/call") { + try { + const called = await invoke(params.name, params.arguments); + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { content: [{ type: "text", text: called.text }], isError: !called.success }, + }); + } catch (error) { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { content: [{ type: "text", text: errorText(error) }], isError: true }, + }); + } + } + return json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32_601, message: "MCP method not found" } }); + } finally { + activeRequests -= 1; + } + }, + }); + let stopping: Promise | null = null; + return { + url: new URL("/", server.url).toString().replace(/\/$/, ""), + token, + stop() { + stopping ??= server.stop(true); + return stopping; + }, + }; +} diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..643eb77ee6 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,5 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +The unregistered executor CLI module stores Remote Workspace state separately from client configuration; see [Remote Workspace](../remote-workspace.md). diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index ae71389ad7..9c9f2bd786 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -168,3 +168,5 @@ pin one legacy root owner before changing it. Sibling stores remain independent. precede coordinated writes under one scoped flight, and actual file state/refusals remain separate. Restore reconciles target intent from validated snapshot ownership without changing sibling policy. Profile journal views retain source-store provenance for older legacy entries. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..dfcb13f15e 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The unregistered executor CLI module stores Remote Workspace state separately from client configuration; see [Remote Workspace](remote-workspace.md). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..fb638ee220 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -511,3 +511,5 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..b408487d18 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..be1af80293 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 534c649471..cb18d70589 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -1,11 +1,17 @@ -# Remote Workspace protocol +# Remote Workspace -`src/remote-control/` is an inactive protocol library. Importing it registers no HTTP route, opens no connection and starts no process or timer. Existing Remote Hub provider routing remains in `src/remote/` and is a separate capability. +`src/remote-control/` owns Remote Workspace contracts, explicit executor construction and Hub session adapters. No module is registered with server startup in this layer. Existing Remote Hub provider routing remains in `src/remote/` and is a separate capability. -`src/remote-control/protocol.ts` owns versioned frame, identity and capability contracts. `src/remote-control/crypto.ts` uses Ed25519 signatures, P-256 ephemeral agreement and directional AES-GCM counters. `src/remote-control/workspace-agent-protocol.ts` bounds and parses control envelopes. `src/remote-control/workspace-tools.ts` describes the remote tool namespace and capability mapping. +`src/remote-control/protocol.ts` owns frame and identity contracts. `src/remote-control/crypto.ts` implements signed handshakes and directional encryption. `src/remote-control/workspace-agent-protocol.ts` parses bounded control messages; `src/remote-control/workspace-rpc-framing.ts` bounds reassembly allocation, count and expiry. Importing these modules starts no process or timer; incomplete reassembly owns expiry timers after an explicit call. -`src/remote-control/workspace-rpc-framing.ts` fragments logical messages and bounds reassembly size, count and expiry. Expiry timers exist only after explicit incomplete-fragment acceptance. `src/remote-control/workspace-utf8.ts` bounds text without splitting surrogate pairs. +`src/remote-control/workspace-agent-connection.ts` intersects presence with enrollment authority and negotiates explicit session grants. `src/remote-control/workspace-rpc.ts` snapshots session/device/root/capabilities and rejects mismatches before invoking the executor. The paired Hub is trusted to select an approved root over authenticated WSS; workspace control traffic is not an untrusted opaque relay protocol. -`src/remote-control/host.ts` accepts an explicitly supplied terminal factory. Authenticated application traffic can invoke that factory; no production factory is supplied here. `src/remote-control/relay.ts` forwards opaque envelopes after its caller authorizes the peer. Neither adapter is wired into server startup. +`src/remote-control/workspace-executor.ts` checks approved root identity, relative paths, file size and write preconditions. Its optional command runner lives in `src/remote-control/workspace-command-runner.ts`. Linux uses bubblewrap outside writable workspace roots and checks executable/parent permissions before invocation. The official Windows and macOS native helpers refuse commands; file tools remain independent of command availability. -The public exports in `src/remote-control/index.ts` expose only this foundation. Device enrollment, executor operations and UI activation are not part of this layer. Tests in `tests/clients/remote-control-prototype.test.ts`, `tests/clients/remote-workspace-rpc-framing.test.ts` and `tests/clients/remote-workspace-protocol.test.ts` cover the protocol contracts; they do not prove platform command confinement. +`src/remote-control/workspace-hub.ts`, `src/remote-control/workspace-device.ts` and `src/remote-control/workspace-sessions.ts` own separate persisted state. `src/remote-control/workspace-secret-store.ts` requires private permissions and rejects access failures rather than treating them as first-run absence. Publication reuses `src/config/atomic-write.ts`; workspace file publication uses the remote-workspace publisher in `src/lib/windows-atomic-replace.ts`. + +`src/remote-control/workspace-runtime.ts` is the lazy composition owner for Hub services. Codex, Claude and Pi adapters keep model processes on the Hub and expose selected remote tools. Their source configuration is not evidence of live CLI confinement. `src/cli/remote-workspace.ts` contains explicit executor pair/agent/status handling; it is not yet registered by this layer. + +The optional terminal prototype in `src/remote-control/host.ts` invokes only a caller-supplied factory after authenticated traffic. `src/remote-control/relay.ts` routes opaque prototype envelopes after caller authorization. Neither is a production terminal service. + +Regression coverage lives in `tests/clients/remote-workspace-session-binding.test.ts`, `tests/clients/remote-workspace-secret-store.test.ts` and the adjacent protocol, agent-wire, device, hub, sessions and command-runner tests. Real CLI and native confinement tests require their explicit environments; generic suite success does not certify those paths. Windows command support remains unavailable pending a verified lifecycle owner. diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..e71dad34f0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -188,3 +188,5 @@ not an authentication or entitlement decision. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..839cd4a82a 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,5 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..850b276cda 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,5 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/tests/clients/remote-workspace-agent-wire.test.ts b/tests/clients/remote-workspace-agent-wire.test.ts new file mode 100644 index 0000000000..0b4f2c9f95 --- /dev/null +++ b/tests/clients/remote-workspace-agent-wire.test.ts @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RemoteWorkspaceExecutor, + RemoteWorkspaceExecutorAgentConnection, + RemoteWorkspaceHubAgentConnection, + RemoteControlClientHandshake, + generateRemoteControlIdentityKeyPair, + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + serializeRemoteWorkspaceAgentMessage, + serializeRemoteWorkspaceHubMessage, + type RemoteWorkspaceControlSocket, + type RemoteWorkspaceCommandRunner, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture(commandRunner?: RemoteWorkspaceCommandRunner) { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-agent-wire-")); + roots.push(root); + const workspace = join(root, "computer-2"); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(workspace, "marker.txt"), "computer-2-only"); + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "workspace", path: workspace }], + commandRunner, + }); + let hub: RemoteWorkspaceHubAgentConnection; + let agent: RemoteWorkspaceExecutorAgentConnection; + const hubSocket: RemoteWorkspaceControlSocket = { + send(value) { void agent.receive(value); }, + close: () => agent.close(), + }; + const agentSocket: RemoteWorkspaceControlSocket = { + send: value => hub.receive(value), + close: () => hub.close(), + }; + hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + socket: hubSocket, + sessionOpenTimeoutMs: 1_000, + }); + agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId, + deviceIdentity, + hubPublicKey: hubIdentity.publicKey, + executor, + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + socket: agentSocket, + }); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + })); + return { hub, agent, workspace, deviceId }; +} + +describe("remote workspace agent wire", () => { + test("does not become online or accept session traffic before capability presence", async () => { + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + socket: { send: () => {}, close: () => {} }, + }); + expect(hub.isOnline()).toBe(false); + await expect(hub.openSession({ sessionId: randomUUID(), rootId: "workspace", profile: "codex", capabilities: hub.capabilities() })) + .rejects.toThrow("offline"); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + expect(hub.isOnline()).toBe(true); + expect(() => hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + }))).toThrow("duplicate presence"); + hub.close(); + }); + + test("cancels a session handshake immediately instead of waiting for its timeout", async () => { + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const sent: string[] = []; + const hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + socket: { send: value => { sent.push(value); }, close: () => {} }, + sessionOpenTimeoutMs: 30_000, + }); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + const sessionId = randomUUID(); + const opening = hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: hub.capabilities() }); + await hub.closeSession(sessionId, "cancelled by user"); + await expect(opening).rejects.toThrow("cancelled by user"); + expect(sent.map(message => parseRemoteWorkspaceHubMessage(message).type)) + .toEqual(["presence_ack", "session_open", "session_close"]); + hub.close(); + }); + + test("opens an authenticated encrypted session and executes on the OCX-only device", async () => { + const state = fixture(); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ + sessionId, + rootId: "workspace", + profile: "codex", capabilities: state.hub.capabilities() }); + const result = await transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "read_file", + arguments: { path: "marker.txt" }, + }); + expect(result).toMatchObject({ ok: true, value: { content: "computer-2-only" } }); + await state.hub.closeSession(sessionId); + expect(transport.isOnline(state.deviceId)).toBe(false); + }); + + test("discards an endpoint when sending session acceptance fails", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-agent-accept-failure-")); + roots.push(root); + const deviceId = randomUUID(); + const sessionId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "workspace", path: root }], + }); + const handshake = RemoteControlClientHandshake.create({ + sessionId, + deviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write"], + accountPrivateKey: hubIdentity.privateKey, + }); + const sent: string[] = []; + let failAcceptance = true; + const agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId, + deviceIdentity, + hubPublicKey: hubIdentity.publicKey, + executor, + capabilities: ["workspace.read", "workspace.write"], + socket: { + send(value) { + const message = parseRemoteWorkspaceAgentMessage(value); + if (message.type === "session_accept" && failAcceptance) { + failAcceptance = false; + throw new Error("socket send failed"); + } + sent.push(value); + }, + close() {}, + }, + }); + const open = serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: "workspace", + clientHello: handshake.hello, + }); + await agent.receive(open); + await agent.receive(open); + expect(sent.map(value => parseRemoteWorkspaceAgentMessage(value).type)) + .toEqual(["session_reject", "session_accept"]); + agent.close(); + }); + + test("fails pending and active work closed when the executor disconnects", async () => { + const state = fixture(); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "pi", capabilities: state.hub.capabilities() }); + state.hub.close("executor disconnected"); + expect(transport.isOnline(state.deviceId)).toBe(false); + await expect(transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "read_file", + arguments: { path: "marker.txt" }, + })).rejects.toThrow("offline"); + }); + + test("session close aborts an active command on the executor", async () => { + let started!: () => void; + const active = new Promise(resolve => { started = resolve; }); + let cancelled = false; + const state = fixture({ + async run(request) { + started(); + return await new Promise((_resolve, reject) => { + const abort = () => { + cancelled = true; + reject(new Error("cancelled")); + }; + request.signal?.addEventListener("abort", abort, { once: true }); + if (request.signal?.aborted) abort(); + }); + }, + }); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: state.hub.capabilities() }); + const invocation = transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["sleep", "60"] }, + }); + await active; + await state.hub.closeSession(sessionId); + await expect(invocation).rejects.toThrow("closed"); + await Bun.sleep(5); + expect(cancelled).toBe(true); + }); + + test("bounds concurrent Hub requests while serializing operations on one executor", async () => { + let started = 0; + const state = fixture({ + async run(request) { + started += 1; + return await new Promise((_resolve, reject) => { + const abort = () => reject(new Error("cancelled")); + request.signal?.addEventListener("abort", abort, { once: true }); + if (request.signal?.aborted) abort(); + }); + }, + }); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: state.hub.capabilities() }); + const pending = Array.from({ length: 8 }, () => transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["wait"] }, + }).catch(error => error)); + for (let count = 0; count < 100 && started < 1; count += 1) await Bun.sleep(1); + expect(started).toBe(1); + await expect(transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["overflow"] }, + })).rejects.toThrow("request limit"); + await state.hub.closeSession(sessionId); + await Promise.all(pending); + }); + + test("rejects malformed, oversized, and non-workspace control messages", () => { + expect(() => parseRemoteWorkspaceHubMessage("{}")) + .toThrow("unsupported remote workspace agent protocol"); + expect(() => parseRemoteWorkspaceAgentMessage(JSON.stringify({ + version: 1, + type: "heartbeat", + nonce: "ok", + extra: true, + }))).toThrow("fields"); + expect(() => parseRemoteWorkspaceAgentMessage("x".repeat(100 * 1024))) + .toThrow("length"); + }); +}); + +test("a read-only negotiated session rejects writes before touching its approved root", async () => { + const state = fixture(); + const sessionId = randomUUID(); + try { + const transport = await state.hub.openSession({ + sessionId, rootId: "workspace", profile: "codex", capabilities: ["workspace.read"], + }); + const result = await transport.invoke({ + requestId: randomUUID(), sessionId, executorDeviceId: state.deviceId, + rootId: "workspace", tool: "read_file", arguments: { path: "marker.txt" }, + }); + expect(result.ok).toBe(true); + await expect(transport.invoke({ + requestId: randomUUID(), sessionId, executorDeviceId: state.deviceId, + rootId: "workspace", tool: "write_file", arguments: { path: "new.txt", content: "denied", expectedSha256: null }, + })).rejects.toThrow(); + } finally { state.hub.close(); state.agent.close(); } +}); diff --git a/tests/clients/remote-workspace-app-server.integration.test.ts b/tests/clients/remote-workspace-app-server.integration.test.ts new file mode 100644 index 0000000000..54f7115452 --- /dev/null +++ b/tests/clients/remote-workspace-app-server.integration.test.ts @@ -0,0 +1,426 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + REMOTE_WORKSPACE_TOOL_NAMESPACE, + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, + RemoteControlClientHandshake, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, + acceptRemoteControlClientHello, + generateRemoteControlIdentityKeyPair, + remoteWorkspaceThreadStartParams, + startRemoteWorkspaceToolBridge, + type RemoteWorkspaceTransport, + type RemoteWorkspaceCommandRunner, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +interface JsonMessage { + id?: string | number; + method?: string; + params?: Record; + result?: Record; + error?: Record; +} + +interface CapturedResponsesRequest { + input?: Array>; + tools?: Array>; +} + +function sse(events: unknown[]): string { + return events.map(event => { + const type = (event as { type: string }).type; + return `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`; + }).join(""); +} + +function completed(id: string): unknown { + return { + type: "response.completed", + response: { + id, + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }; +} + +function responseCreated(id: string): unknown { + return { type: "response.created", response: { id } }; +} + +class JsonLinePeer { + private readonly reader: ReadableStreamDefaultReader; + private buffer = ""; + + constructor( + stdout: ReadableStream, + private readonly stdin: FileSink, + ) { + this.reader = stdout.getReader(); + } + + send(message: unknown): void { + this.stdin.write(`${JSON.stringify(message)}\n`); + this.stdin.flush(); + } + + async next(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + const newline = this.buffer.indexOf("\n"); + if (newline >= 0) { + const line = this.buffer.slice(0, newline).replace(/\r$/, ""); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + return JSON.parse(line) as JsonMessage; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("timed out waiting for Codex App Server JSON-RPC"); + const next = await Promise.race([ + this.reader.read(), + new Promise((_, reject) => setTimeout( + () => reject(new Error("timed out waiting for Codex App Server output")), + remaining, + )), + ]); + if (next.done) throw new Error("Codex App Server closed its output"); + this.buffer += new TextDecoder().decode(next.value, { stream: true }); + } + } + + async waitFor(predicate: (message: JsonMessage) => boolean): Promise { + for (let count = 0; count < 200; count += 1) { + const message = await this.next(); + if (predicate(message)) return message; + } + throw new Error("Codex App Server did not emit the expected message"); + } +} + +const codexBin = process.env.OCX_CODEX_BIN; +const appServerTest = codexBin ? test : test.skip; + +const localIntegrationCommandRunner: RemoteWorkspaceCommandRunner = { + async run(request) { + const child = Bun.spawn(request.command, { + cwd: request.cwd, + env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: request.cwd }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, request.timeoutMs); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (timedOut) throw new Error("local integration command timed out"); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > request.maxOutputBytes) { + throw new Error("local integration command output limit exceeded"); + } + return { stdout, stderr, exitCode }; + } finally { + clearTimeout(timer); + } + }, +}; + +appServerTest("real Codex App Server delegates a dynamic workspace tool to Computer 2", async () => { + if (!codexBin || !existsSync(codexBin)) throw new Error("OCX_CODEX_BIN must identify a real Codex executable"); + const root = mkdtempSync(join(tmpdir(), "ocx-remote-app-server-")); + const mainHome = join(root, "main-home"); + const mainCodexHome = join(root, "main-codex"); + const mainOcxHome = join(root, "main-ocx"); + const sandboxBin = join(root, "sandbox-bin"); + const coordinatorIsolation = join(root, "coordinator-isolation"); + const executorRoot = join(root, "computer-2-workspace"); + const hubSecret = join(root, "hub-secret.txt"); + for (const path of [mainHome, mainCodexHome, mainOcxHome, coordinatorIsolation, executorRoot, sandboxBin]) { + mkdirSync(path, { recursive: true }); + } + linkSync(codexBin, join(sandboxBin, "codex-linux-sandbox")); + writeFileSync(join(coordinatorIsolation, "integration-marker.txt"), "main-unchanged"); + writeFileSync(hubSecret, "HUB-SECRET-MUST-NOT-LEAK"); + + const requestBodies: unknown[] = []; + let responseIndex = 0; + const modelServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (request.method !== "POST" || !url.pathname.endsWith("/responses")) { + return Response.json({ error: "not_found" }, { status: 404 }); + } + const requestBody = await request.json() as CapturedResponsesRequest; + requestBodies.push(requestBody); + responseIndex += 1; + if (responseIndex === 1) { + const hasCodeMode = JSON.stringify(requestBody.input).includes('"name":"functions"') + && JSON.stringify(requestBody.input).includes('"name":"exec"'); + if (!hasCodeMode) { + return new Response(sse([ + responseCreated("resp-remote-no-tool"), + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-no-remote-tool", + content: [{ type: "output_text", text: "Remote tool unavailable" }], + }, + }, + completed("resp-remote-no-tool"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + return new Response(sse([ + responseCreated("resp-remote-1"), + { + type: "response.output_item.done", + item: { + type: "custom_tool_call", + call_id: "remote-exec-call", + namespace: "functions", + name: "exec", + input: [ + "const result = await tools.mcp__ocx_remote_workspace__exec({", + " command: ['/bin/sh', '-lc', \"printf 'computer-2' > integration-marker.txt; printf 'executor-cwd:'; pwd\"],", + " cwd: '.',", + " timeoutMs: 5000,", + "});", + "let localProbe;", + `try { localProbe = await tools.exec_command({ cmd: ${JSON.stringify(`cat -- ${JSON.stringify(hubSecret)}`)} }); }`, + "catch (error) { localProbe = String(error); }", + "text(JSON.stringify({ result, localProbe }));", + ].join("\n"), + }, + }, + completed("resp-remote-1"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + if (responseIndex === 2) { + return new Response(sse([ + responseCreated("resp-remote-2"), + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-remote-done", + content: [{ type: "output_text", text: "Remote workspace complete" }], + }, + }, + completed("resp-remote-2"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ error: "unexpected_request" }, { status: 500 }); + }, + }); + + const config = [ + 'model = "gpt-5.6-sol"', + 'model_provider = "ocx_remote_spike"', + 'approval_policy = "never"', + '', + '[model_providers.ocx_remote_spike]', + 'name = "OCX Remote Spike"', + `base_url = "${new URL("/v1", modelServer.url).toString().replace(/\/$/, "")}"`, + 'env_key = "OCX_REMOTE_SPIKE_API_KEY"', + 'wire_api = "responses"', + 'supports_websockets = false', + '', + ].join("\n"); + writeFileSync(join(mainCodexHome, "config.toml"), config, { mode: 0o600 }); + + const deviceId = randomUUID(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "selected-folder", path: executorRoot }], + commandRunner: localIntegrationCommandRunner, + }); + const accountIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const transportSessionId = randomUUID(); + const handshake = RemoteControlClientHandshake.create({ + sessionId: transportSessionId, + deviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + accountPrivateKey: accountIdentity.privateKey, + }); + const accepted = acceptRemoteControlClientHello(handshake.hello, { + expectedSessionId: transportSessionId, + expectedDeviceId: deviceId, + accountPublicKey: accountIdentity.publicKey, + devicePrivateKey: deviceIdentity.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write", "workspace.exec"], + }); + let encryptedTransport: EncryptedRemoteWorkspaceTransport; + let executorEndpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + encryptedTransport = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: deviceId, + cipher: handshake.complete(accepted.hello, deviceIdentity.publicKey), + sendCiphertext: value => executorEndpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + executorEndpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: deviceId, + sessionId: transportSessionId, + rootId: "selected-folder", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + cipher: accepted.cipher, + executor, + sendCiphertext: value => encryptedTransport.receiveCiphertext(value), + }); + const transport: RemoteWorkspaceTransport = encryptedTransport; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const threadRef = { id: "" }; + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: () => threadRef.id, + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const mcpTokenEnv = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; + const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; + + const appServer = Bun.spawn([ + codexBin, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(mcpTokenEnv)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=["list_directory","read_file","write_file","exec"]`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { + cwd: coordinatorIsolation, + env: { + PATH: `${sandboxBin}:${process.env.PATH ?? "/usr/bin:/bin"}`, + HOME: mainHome, + CODEX_HOME: mainCodexHome, + OPENCODEX_HOME: mainOcxHome, + OCX_REMOTE_SPIKE_API_KEY: "test-only-not-a-real-key", + [mcpTokenEnv]: bridge.token, + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const stderrPromise = new Response(appServer.stderr).text(); + const peer = new JsonLinePeer(appServer.stdout, appServer.stdin); + + try { + peer.send({ + method: "initialize", + id: 0, + params: { + clientInfo: { name: "ocx_remote_workspace_test", title: "OCX Remote Workspace Test", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }, + }); + const initialized = await peer.waitFor(message => message.id === 0); + expect(initialized.error).toBeUndefined(); + peer.send({ method: "initialized", params: {} }); + + peer.send({ + method: "thread/start", + id: 1, + params: { + ...remoteWorkspaceThreadStartParams({ + executorName: "Computer 2", + coordinatorIsolationPath: coordinatorIsolation, + tools: ["list_directory", "read_file", "write_file", "exec"], + mcp: { + url: `${bridge.url}/mcp`, + bearerTokenEnvVar: mcpTokenEnv, + hubRuntimeReadPaths: [dirname(realpathSync(codexBin)), sandboxBin], + }, + }), + model: "gpt-5.6-sol", + modelProvider: "ocx_remote_spike", + ephemeral: true, + }, + }); + const threadResponse = await peer.waitFor(message => message.id === 1); + expect(threadResponse.error).toBeUndefined(); + const thread = threadResponse.result?.thread as { id?: string } | undefined; + if (!thread?.id) throw new Error("Codex App Server did not return a thread ID"); + threadRef.id = thread.id; + coordinator.register({ + sessionId: transportSessionId, + threadId: thread.id, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "selected-folder", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + + peer.send({ + method: "turn/start", + id: 2, + params: { + threadId: thread.id, + input: [{ type: "text", text: "Create the marker in the selected remote workspace." }], + approvalPolicy: "never", + }, + }); + + let turnCompleted = false; + for (let count = 0; count < 200 && !turnCompleted; count += 1) { + const message = await peer.next(); + if (message.method === "item/tool/call" && message.id !== undefined) { + const response = await coordinator.handle({ + method: "item/tool/call", + id: message.id, + params: message.params, + }); + peer.send(response); + } + if (message.method === "turn/completed") turnCompleted = true; + if (message.id === 2 && message.error) throw new Error(`turn/start failed: ${JSON.stringify(message.error)}`); + } + + expect(turnCompleted).toBe(true); + expect(existsSync(join(executorRoot, "integration-marker.txt"))).toBe(true); + expect(readFileSync(join(executorRoot, "integration-marker.txt"), "utf8")).toBe("computer-2"); + expect(readFileSync(join(coordinatorIsolation, "integration-marker.txt"), "utf8")).toBe("main-unchanged"); + expect(requestBodies).toHaveLength(2); + expect(JSON.stringify(requestBodies[0])).toContain(REMOTE_WORKSPACE_TOOL_NAMESPACE); + // Current Codex consolidates MCP into the sandboxed functions.exec code-mode tool. + // Executing the nested remote helper above proves the registered MCP server is callable. + expect(JSON.stringify((requestBodies[0] as CapturedResponsesRequest).input)).toContain('"name":"functions"'); + const followUp = requestBodies[1] as CapturedResponsesRequest; + const toolOutput = followUp.input?.find(item => item.type === "custom_tool_call_output"); + expect(toolOutput).toBeDefined(); + const serializedToolOutput = JSON.stringify(toolOutput); + expect(serializedToolOutput).toContain("executor-cwd:"); + expect(serializedToolOutput).toContain(executorRoot); + expect(serializedToolOutput).not.toContain(coordinatorIsolation); + expect(serializedToolOutput).not.toContain("HUB-SECRET-MUST-NOT-LEAK"); + } finally { + encryptedTransport.close(); + appServer.kill(); + await appServer.exited; + await stderrPromise; + await modelServer.stop(true); + await bridge.stop(); + removeTreeWithRetry(root); + } +}, 30_000); diff --git a/tests/clients/remote-workspace-claude.integration.test.ts b/tests/clients/remote-workspace-claude.integration.test.ts new file mode 100644 index 0000000000..c29cda1e20 --- /dev/null +++ b/tests/clients/remote-workspace-claude.integration.test.ts @@ -0,0 +1,166 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClaudeRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function sse(events: Array<{ event: string; data: unknown }>): Response { + return new Response(events.map(item => `event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream", "cache-control": "no-store" }, + }); +} + +function messageStart(id: string): { event: string; data: unknown } { + return { + event: "message_start", + data: { + type: "message_start", + message: { + id, + type: "message", + role: "assistant", + model: "claude-test", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 8, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 1 }, + }, + }, + }; +} + +const claudePath = process.env.OCX_CLAUDE_BIN; +const claudeTest = claudePath ? test : test.skip; + +claudeTest("real Claude Code uses only the selected remote executor MCP tools", async () => { + if (!claudePath) return; + const root = mkdtempSync(join(tmpdir(), "ocx-remote-claude-real-")); + roots.push(root); + const workspace = join(root, "executor"); + const home = join(root, "home"); + mkdirSync(workspace); + mkdirSync(home); + writeFileSync(join(workspace, "marker.txt"), "only-on-computer-2"); + const requestBodies: Array> = []; + const model = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/count_tokens")) return Response.json({ input_tokens: 8 }); + if (!url.pathname.endsWith("/messages")) return Response.json({ error: { message: "not found" } }, { status: 404 }); + const body = await req.json() as Record; + requestBodies.push(body); + if (requestBodies.length === 1) { + return sse([ + messageStart("msg_remote_tool"), + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_remote_read", name: "mcp__ocx_remote_workspace__read_file", input: {} } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{\"path\":\"marker.txt\"}" } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 8 } } }, + { event: "message_stop", data: { type: "message_stop" } }, + ]); + } + return sse([ + messageStart("msg_remote_answer"), + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Read only-on-computer-2 from the executor." } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 12 } } }, + { event: "message_stop", data: { type: "message_stop" } }, + ]); + }, + }); + const deviceId = crypto.randomUUID(); + const executor = new RemoteWorkspaceExecutor({ deviceId, roots: [{ id: "root", path: workspace }] }); + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: candidate => candidate === deviceId, + invoke: request => executor.invoke(request), + }); + const events: string[] = []; + const factory = new ClaudeRemoteWorkspaceRuntimeFactory({ + command: [claudePath], + version: "real-smoke", + env: { + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), + ANTHROPIC_BASE_URL: model.url.toString().replace(/\/$/, ""), + ANTHROPIC_AUTH_TOKEN: "test-only-token", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }, + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId, + deviceName: "Computer 2", + rootId: "root", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + coordinator, + emit: (type, text) => events.push(`${type}:${text}`), + }); + const unregister = coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "root", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + }); + try { + await handle.prompt("Read marker.txt from the remote workspace."); + expect(events.some(event => event.includes("Read only-on-computer-2 from the executor."))).toBe(true); + expect(JSON.stringify(requestBodies.at(-1))).toContain("only-on-computer-2"); + expect(JSON.stringify(requestBodies)).not.toContain("remote_exec"); + const persistedThreadId = handle.threadId; + unregister(); + await handle.stop(); + const resumed = await factory.start({ + sessionId: "session-1", + deviceId, + deviceName: "Computer 2", + rootId: "root", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + resumeThreadId: persistedThreadId, + coordinator, + emit: (type, text) => events.push(`${type}:${text}`), + }); + const unregisterResumed = coordinator.register({ + sessionId: "session-1", + threadId: resumed.threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "root", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + }); + try { + await resumed.prompt("Continue the same remote session."); + expect(resumed.threadId).toBe(persistedThreadId); + expect(JSON.stringify(requestBodies.at(-1))).toContain("Continue the same remote session."); + } finally { + unregisterResumed(); + await resumed.stop(); + } + } finally { + unregister(); + await handle.stop(); + await model.stop(true); + } +}, 30_000); diff --git a/tests/clients/remote-workspace-cli-runtimes.test.ts b/tests/clients/remote-workspace-cli-runtimes.test.ts new file mode 100644 index 0000000000..33785b64e7 --- /dev/null +++ b/tests/clients/remote-workspace-cli-runtimes.test.ts @@ -0,0 +1,67 @@ +import { fixturePath } from "../helpers/repo-root"; +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + ClaudeRemoteWorkspaceRuntimeFactory, + PiRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + type RemoteWorkspaceSessionEvent, +} from "../../src/remote-control"; + +function coordinator(): RemoteWorkspaceCoordinator { + return new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true, value: null }; }, + }); +} + +test("Claude runtime keeps the CLI on the Hub and emits its answer", async () => { + const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; + const factory = new ClaudeRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, fixturePath("fake-claude-stream.ts")], + version: "test", + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator: coordinator(), + emit: (type, text) => events.push({ type, text }), + }); + try { + await handle.prompt("hello remote"); + expect(events).toEqual([{ type: "assistant", text: "Hub answer: hello remote" }]); + } finally { + await handle.stop(); + } +}); + +const piPath = process.env.OCX_PI_BIN; +const piTest = piPath ? test : test.skip; + +piTest("real Pi RPC starts with only the explicit Remote Workspace extension", async () => { + if (!piPath) return; + const factory = new PiRemoteWorkspaceRuntimeFactory({ command: [piPath], version: "test" }); + const startOptions = { + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator: coordinator(), + emit: () => {}, + } as const; + const handle = await factory.start(startOptions); + expect(handle.threadId).toMatch(/^[0-9a-f-]{36}$/); + const threadId = handle.threadId; + await handle.stop(); + const resumed = await factory.start({ ...startOptions, resumeThreadId: threadId }); + expect(resumed.threadId).toBe(threadId); + await resumed.stop(); +}); diff --git a/tests/clients/remote-workspace-cli.test.ts b/tests/clients/remote-workspace-cli.test.ts new file mode 100644 index 0000000000..2358d59133 --- /dev/null +++ b/tests/clients/remote-workspace-cli.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { Readable } from "node:stream"; +import { runRemoteWorkspaceCommand } from "../../src/cli/remote-workspace"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceStateStore, +} from "../../src/remote-control"; + +class MemoryStore implements RemoteWorkspaceDeviceStateStore { + constructor(public state: RemoteWorkspaceDeviceState | null = null) {} + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceDeviceState) { this.state = structuredClone(state); } +} + +function state(): RemoteWorkspaceDeviceState { + return { + version: 1, + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), + deviceName: "Computer 2", + devicePlatform: "linux-x64", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: generateRemoteControlIdentityKeyPair(), + hubPublicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project", path: "/work/project" }], + toolchainRoots: [], + }; +} + +describe("ocx remote-workspace", () => { + test("reads the one-time pairing code from stdin and never requires it in argv", async () => { + const store = new MemoryStore(); + const expected = state(); + let received: Record | null = null; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const code = await runRemoteWorkspaceCommand([ + "pair", + "https://hub.example.test", + "--root", "/work/project", + "--root", "/work/other", + "--executor-helper", "/opt/opencodex/remote-workspace-helper", + "--name", "Computer 2", + "--pairing-code-stdin", + "--json", + ], { + store, + stdinImpl: Readable.from(["ABCD-EFGH-JKLM\n"]), + pair: async options => { + received = options as unknown as Record; + return expected; + }, + }); + expect(code).toBe(0); + expect(received).toMatchObject({ + hubUrl: "https://hub.example.test", + pairingCode: "ABCD-EFGH-JKLM", + name: "Computer 2", + roots: [{ path: "/work/project" }, { path: "/work/other" }], + nativeHelperPath: "/opt/opencodex/remote-workspace-helper", + }); + expect(JSON.stringify(log.mock.calls)).not.toContain(expected.deviceToken); + expect(JSON.stringify(log.mock.calls)).not.toContain(expected.deviceIdentity.privateKey); + } finally { + log.mockRestore(); + } + }); + + test("status reports local executor identity without secret material", async () => { + const saved = state(); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runRemoteWorkspaceCommand(["status", "--json"], { store: new MemoryStore(saved) })).toBe(0); + const output = JSON.stringify(log.mock.calls); + expect(output).toContain("Computer 2"); + expect(output).toContain("/work/project"); + expect(output).not.toContain(saved.deviceToken); + expect(output).not.toContain(saved.deviceIdentity.privateKey); + } finally { + log.mockRestore(); + } + }); + + test("agent hands the paired state to the reconnecting runner", async () => { + const saved = state(); + const controller = new AbortController(); + let received: RemoteWorkspaceDeviceState | null = null; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const code = await runRemoteWorkspaceCommand(["agent"], { + store: new MemoryStore(saved), + signal: controller.signal, + runAgent: async options => { received = options.state; }, + }); + expect(code).toBe(0); + expect(received?.deviceId).toBe(saved.deviceId); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/tests/clients/remote-workspace-codex-runtime.test.ts b/tests/clients/remote-workspace-codex-runtime.test.ts new file mode 100644 index 0000000000..b9d259c2eb --- /dev/null +++ b/tests/clients/remote-workspace-codex-runtime.test.ts @@ -0,0 +1,120 @@ +import { repoPath } from "../helpers/repo-root"; +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + CodexRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + type RemoteWorkspaceSessionEvent, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; + +test("Codex Remote Workspace runtime owns the model process on the Hub", async () => { + const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; + const transport: RemoteWorkspaceTransport = { + isOnline: () => true, + async invoke() { return { ok: true, value: null }; }, + }; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + version: "0.146.0-test", + env: { + FAKE_CODEX_SCRIPT: JSON.stringify({ + turns: [{ + notifications: [{ + method: "item/completed", + params: { item: { id: "answer-1", type: "agentMessage", text: "Done from Computer 1" } }, + }], + }], + }), + }, + }); + + expect(await factory.available()).toEqual({ available: true, version: "0.146.0-test" }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator, + emit: (type, text) => events.push({ type, text }), + }); + const unregister = coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + try { + await handle.prompt("Inspect the remote project"); + expect(events).toContainEqual({ type: "assistant", text: "Done from Computer 1" }); + } finally { + unregister(); + await handle.stop(); + } +}); + +test("Codex Remote Workspace stop interrupts a held turn", async () => { + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { FAKE_CODEX_SCRIPT: JSON.stringify({ turns: [{ heldUntilInterrupt: true }] }) }, + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator, + emit: () => {}, + }); + coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const turn = handle.prompt("Hold this turn").then(() => "resolved", () => "rejected"); + await new Promise(resolvePromise => setTimeout(resolvePromise, 30)); + await handle.stop(); + expect(await turn).toBe("rejected"); +}); + +test("Codex Remote Workspace resumes the persisted App Server thread ID", async () => { + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + }); + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }); + const handle = await factory.start({ + sessionId: "session-resume", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + resumeThreadId: "thread-persisted", + coordinator, + emit: () => {}, + }); + expect(handle.threadId).toBe("thread-persisted"); + await handle.stop(); +}); diff --git a/tests/clients/remote-workspace-command-runner.test.ts b/tests/clients/remote-workspace-command-runner.test.ts new file mode 100644 index 0000000000..9595b2b4f3 --- /dev/null +++ b/tests/clients/remote-workspace-command-runner.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + RemoteWorkspaceExecutor, + createLinuxRemoteWorkspaceCommandRunner, + createNativeRemoteWorkspaceCommandRunner, + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandArgv, + linuxRemoteWorkspaceCommandRunnerAvailable, + nativeRemoteWorkspaceCommandRunnerAvailable, + pinRemoteWorkspaceNativeHelper, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-bwrap-")); + roots.push(root); + const workspace = join(root, "workspace"); + const outside = join(root, "outside-secret.txt"); + mkdirSync(join(workspace, "project"), { recursive: true }); + writeFileSync(outside, "must-not-be-visible"); + return { root, workspace, outside }; +} + +function fakeNativeHelper(root: string, response: Record, requestPath?: string) { + const path = join(root, "ocx-remote-helper-test"); + const encodedResponse = JSON.stringify(response).replaceAll("'", "'\\''"); + const requestCapture = requestPath + ? `input=$(cat); printf '%s' "$input" > '${requestPath.replaceAll("'", "'\\''")}'` + : "cat >/dev/null"; + writeFileSync(path, `#!/bin/sh\nset -eu\n${requestCapture}\nprintf '%s\\n' '${encodedResponse}'\n`, { mode: 0o700 }); + chmodSync(path, 0o700); + return pinRemoteWorkspaceNativeHelper(path); +} + +describe("remote workspace Linux command sandbox", () => { + test("rejects a sandbox executable inside a writable workspace before probing", () => { + const state = fixture(); + const path = join(state.workspace, "bwrap"); + writeFileSync(path, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + let probes = 0; + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: path, writableRoots: [state.workspace], + probe() { probes += 1; return true; }, + })).toBe(false); + expect(probes).toBe(0); + expect(() => linuxRemoteWorkspaceCommandArgv({ + command: ["true"], root: state.workspace, cwd: state.workspace, + timeoutMs: 1_000, maxOutputBytes: 4096, + }, { bubblewrapPath: path })).toThrow("outside every writable"); + }); + + test("Windows command capability stays unavailable even with a positive probe seam", () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { version: 1, ok: true, probe: true }); + let probes = 0; + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + platform: "win32", helper, + writableRoots: [state.workspace], probe() { probes += 1; return { version: 1, ok: true, probe: true }; }, + })).toBe(false); + expect(probes).toBe(0); + }); + + test("builds a minimal bubblewrap argv with one writable workspace", () => { + const state = fixture(); + const argv = linuxRemoteWorkspaceCommandArgv({ + command: ["/bin/sh", "-lc", "pwd"], + root: state.workspace, + cwd: join(state.workspace, "project"), + timeoutMs: 1_000, + maxOutputBytes: 4_096, + }, { bubblewrapPath: process.execPath }); + expect(argv[0]).toBe(process.execPath); + expect(argv).toContain("--unshare-net"); + expect(argv).toContain("--clearenv"); + expect(argv).toContain("--bind"); + expect(argv).toContain(state.workspace); + expect(argv).toContain("/workspace/project"); + expect(argv).not.toContain(state.outside); + }); + + test("runs inside the selected root and cannot see an adjacent host file", async () => { + if (!linuxRemoteWorkspaceCommandRunnerAvailable()) return; + const state = fixture(); + const deviceId = randomUUID(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "root", path: state.workspace }], + commandRunner: createLinuxRemoteWorkspaceCommandRunner(), + }); + const result = await executor.invoke({ + requestId: randomUUID(), + sessionId: randomUUID(), + executorDeviceId: deviceId, + rootId: "root", + tool: "exec", + arguments: { + command: [ + "/bin/sh", + "-lc", + `test ! -e ${JSON.stringify(state.outside)} && printf sandboxed > marker.txt && pwd`, + ], + cwd: "project", + timeoutMs: 5_000, + }, + }); + expect(result.ok).toBe(true); + expect(result.value).toMatchObject({ exitCode: 0, cwd: "project" }); + expect(JSON.stringify(result.value)).toContain("/workspace/project"); + expect(readFileSync(join(state.workspace, "project", "marker.txt"), "utf8")).toBe("sandboxed"); + }); + + test("keeps exec disabled where an equivalent platform sandbox is unavailable", () => { + expect(createPlatformRemoteWorkspaceCommandRunner({ platform: "win32" })).toBeUndefined(); + expect(createPlatformRemoteWorkspaceCommandRunner({ platform: "darwin" })).toBeUndefined(); + }); + + test("advertises native exec only after a digest-pinned confinement probe", () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { version: 1, ok: true, probe: true }); + let probeRequest: unknown; + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe(request) { + probeRequest = request; + return { version: 1, ok: true, probe: true }; + }, + })).toBe(true); + expect(probeRequest).toEqual({ version: 1, operation: "probe" }); + expect(createPlatformRemoteWorkspaceCommandRunner({ + platform: "win32", + native: { + helper, + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: false, error: "not confined" }), + }, + })).toBeUndefined(); + writeFileSync(helper.path, "replaced", { mode: 0o700 }); + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + })).toBe(false); + }); + + test("sends native command authority over bounded stdin and decodes one strict result", async () => { + const state = fixture(); + const requestPath = join(state.root, "request.json"); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 7, + stdoutBase64: Buffer.from("native stdout").toString("base64"), + stderrBase64: Buffer.from("native stderr").toString("base64"), + }, requestPath); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + const result = await runner.run({ + command: ["/usr/bin/printf", "hello world"], + root: state.workspace, + cwd: join(state.workspace, "project"), + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }); + expect(result).toEqual({ exitCode: 7, stdout: "native stdout", stderr: "native stderr" }); + const request = JSON.parse(readFileSync(requestPath, "utf8")); + expect(request).toEqual({ + version: 1, + operation: "run", + root: state.workspace, + cwd: join(state.workspace, "project"), + command: ["/usr/bin/printf", "hello world"], + toolchainRoots: [], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + networkAccess: false, + }); + expect(JSON.stringify(request)).not.toContain(process.env.OPENAI_API_KEY ?? "__no_api_key__"); + }); + + test("rejects widened or malformed native helper responses", async () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "@@not-base64@@", + stderrBase64: "", + }); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + await expect(runner.run({ + command: ["cmd.exe"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("invalid stdout"); + }); + + test("never advertises or invokes a native helper from inside a writable workspace", async () => { + const state = fixture(); + const helper = fakeNativeHelper(state.workspace, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "", + stderrBase64: "", + }); + expect(createPlatformRemoteWorkspaceCommandRunner({ + platform: "darwin", + native: { + helper, + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }, + })).toBeUndefined(); + + }); + + test("binds every native command to the runner's construction-time writable roots", async () => { + const state = fixture(); + const other = join(state.root, "other-workspace"); + mkdirSync(other); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "", + stderrBase64: "", + }); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + await expect(runner.run({ + command: ["/usr/bin/true"], + root: other, + cwd: other, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("outside the native runner grant"); + }); + + test("revalidates approved toolchain roots and rejects a later symlink substitution", () => { + const state = fixture(); + const realToolchain = join(state.root, "real-toolchain"); + const substituted = join(state.root, "toolchain"); + mkdirSync(realToolchain); + symlinkSync(realToolchain, substituted, process.platform === "win32" ? "junction" : "dir"); + expect(() => linuxRemoteWorkspaceCommandArgv({ + command: ["true"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 1_000, + maxOutputBytes: 4_096, + }, { + bubblewrapPath: process.execPath, + toolchainRoots: [substituted], + })).toThrow("remain a real directory"); + }); + + test("rejects a pre-existing hardlink before starting a workspace command", async () => { + const state = fixture(); + linkSync(state.outside, join(state.workspace, "outside-alias")); + const runner = createLinuxRemoteWorkspaceCommandRunner({ + bubblewrapPath: process.execPath, + spawn: (() => { throw new Error("sandbox spawn must not be reached"); }) as typeof Bun.spawn, + }); + await expect(runner.run({ + command: ["/bin/true"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 1_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("hard-linked file"); + expect(readFileSync(state.outside, "utf8")).toBe("must-not-be-visible"); + }); + + test("the production Linux runner exposes only the current OCX Bun file, not its host directory", async () => { + if (process.platform !== "linux" || !linuxRemoteWorkspaceCommandRunnerAvailable()) return; + const state = fixture(); + const argv = linuxRemoteWorkspaceCommandArgv({ + command: ["bun", "--version"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }, { runtimeExecutablePath: process.execPath }); + expect(argv).toContain("/ocx-runtime/bin/bun"); + expect(argv).toContain(realpathSync(process.execPath)); + expect(argv).not.toContain(dirname(realpathSync(process.execPath))); + expect(argv).not.toContain(process.env.HOME ?? "__missing_home__"); + const runner = createPlatformRemoteWorkspaceCommandRunner(); + if (!runner) throw new Error("Linux Remote Workspace runner was not detected"); + const result = await runner.run({ + command: ["bun", "--version"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(Bun.version); + }); +}); diff --git a/tests/clients/remote-workspace-device.test.ts b/tests/clients/remote-workspace-device.test.ts new file mode 100644 index 0000000000..386f2ddfbd --- /dev/null +++ b/tests/clients/remote-workspace-device.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RemoteWorkspaceHub, + connectRemoteWorkspaceAgent, + generateRemoteControlIdentityKeyPair, + pairRemoteWorkspaceDevice, + parseRemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceStateStore, + type RemoteWorkspaceHubState, + type RemoteWorkspaceHubStateStore, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +class HubStore implements RemoteWorkspaceHubStateStore { + state: RemoteWorkspaceHubState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceHubState) { this.state = structuredClone(state); } +} + +class DeviceStore implements RemoteWorkspaceDeviceStateStore { + state: RemoteWorkspaceDeviceState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceDeviceState) { this.state = structuredClone(state); } +} + +describe("remote workspace device enrollment", () => { + test("pairs through one HTTPS request while keeping the real root path on Computer 2", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-")); + roots.push(root); + const workspace = join(root, "private-project"); + const toolchain = join(root, "private-toolchain"); + const nativeHelper = join(root, "private-native-helper"); + mkdirSync(workspace); + mkdirSync(toolchain); + writeFileSync(nativeHelper, "test helper", { mode: 0o700 }); + chmodSync(nativeHelper, 0o700); + const hubStore = new HubStore(); + const hub = new RemoteWorkspaceHub(hubStore); + const grant = hub.createPairingGrant(); + const deviceStore = new DeviceStore(); + let requestBody = ""; + const state = await pairRemoteWorkspaceDevice({ + hubUrl: "https://hub.example.test", + pairingCode: grant.code, + name: "Computer 2", + devicePlatform: "linux-x64", + roots: [{ path: workspace, label: "Main project" }], + toolchainRoots: [toolchain], + nativeHelperPath: nativeHelper, + store: deviceStore, + fetchImpl: async (input, init) => { + expect(String(input)).toBe("https://hub.example.test/remote-workspace/pair"); + requestBody = String(init?.body); + const paired = hub.pairDevice(JSON.parse(requestBody)); + return Response.json(paired, { status: 201 }); + }, + }); + expect(requestBody).not.toContain(workspace); + expect(requestBody).not.toContain(toolchain); + expect(requestBody).not.toContain(nativeHelper); + expect(requestBody).not.toContain(state.deviceIdentity.privateKey); + expect(state).toMatchObject({ + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceName: "Computer 2", + devicePlatform: "linux-x64", + roots: [{ label: "Main project", path: realpathSync(workspace) }], + toolchainRoots: [realpathSync(toolchain)], + }); + expect(state.nativeHelper?.path).toBe(realpathSync(nativeHelper)); + expect(state.nativeHelper?.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(deviceStore.state).toEqual(state); + expect(hub.authenticateDeviceToken(state.deviceToken)?.id).toBe(state.deviceId); + expect(JSON.stringify(hubStore.state)).not.toContain(state.deviceToken); + expect(JSON.stringify(hubStore.state)).not.toContain(workspace); + }); + + test("requires HTTPS except for explicit loopback development", async () => { + expect(() => parseRemoteWorkspaceDeviceState({ version: 1, hubUrl: "http://example.test" })) + .toThrow("must use HTTPS"); + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-local-")); + roots.push(root); + const store = new DeviceStore(); + await expect(pairRemoteWorkspaceDevice({ + hubUrl: "http://127.0.0.1:7075", + pairingCode: "AAAA-BBBB-CCCC", + roots: [{ path: root }], + store, + fetchImpl: async () => Response.json({ error: "invalid or expired" }, { status: 401 }), + })).rejects.toThrow("invalid or expired"); + expect(store.state).toBeNull(); + }); + + test("cancels a chunked Hub response before it can grow beyond the pairing limit", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-bounded-response-")); + roots.push(root); + const store = new DeviceStore(); + let cancelled = false; + await expect(pairRemoteWorkspaceDevice({ + hubUrl: "https://hub.example.test", + pairingCode: "ABCD-EFGH-JKLM", + roots: [{ path: root }], + store, + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024)); + controller.enqueue(new Uint8Array([1])); + }, + cancel() { cancelled = true; }, + }), { status: 200 }), + })).rejects.toThrow("response is too large"); + expect(cancelled).toBe(true); + expect(store.state).toBeNull(); + }); + + test("stops cleanly even when the platform WebSocket rejects close while connecting", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-stop-")); + roots.push(root); + const state: RemoteWorkspaceDeviceState = { + version: 1, + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), + deviceName: "Computer 2", + devicePlatform: "darwin-arm64", + capabilities: ["workspace.read", "workspace.write"], + deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: generateRemoteControlIdentityKeyPair(), + hubPublicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project", path: root }], + toolchainRoots: [], + }; + const handle = connectRemoteWorkspaceAgent({ + state, + commandRunner: null, + webSocketFactory: () => ({ + readyState: 0, + send() {}, + close() { throw new Error("CONNECTING close is not supported"); }, + addEventListener() {}, + }), + }); + handle.stop(); + await expect(handle.connected).rejects.toThrow("stopped"); + await handle.closed; + }); +}); diff --git a/tests/clients/remote-workspace-hub.test.ts b/tests/clients/remote-workspace-hub.test.ts new file mode 100644 index 0000000000..b6ce600238 --- /dev/null +++ b/tests/clients/remote-workspace-hub.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + RemoteWorkspaceHub, + RemoteWorkspaceHubAgentConnection, + RemoteWorkspacePairingRateLimitError, + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + generateRemoteControlIdentityKeyPair, + parseRemoteWorkspaceHubState, + serializeRemoteWorkspaceAgentMessage, + type RemoteWorkspaceHubState, + type RemoteWorkspaceHubStateStore, +} from "../../src/remote-control"; + +class MemoryStore implements RemoteWorkspaceHubStateStore { + state: RemoteWorkspaceHubState | null = null; + writes = 0; + + load(): RemoteWorkspaceHubState | null { + return this.state ? structuredClone(this.state) : null; + } + + save(state: RemoteWorkspaceHubState): void { + this.state = structuredClone(state); + this.writes += 1; + } +} + +function pairedHub(now = Date.parse("2026-09-03T12:00:00.000Z")) { + const store = new MemoryStore(); + const hub = new RemoteWorkspaceHub(store, () => now); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const grant = hub.createPairingGrant(); + const paired = hub.pairDevice({ + code: grant.code.replaceAll("-", " ").toLowerCase(), + name: "Computer 2", + platform: "linux-x64", + publicKey: deviceIdentity.publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + }); + return { hub, store, deviceIdentity, paired, now }; +} + +describe("remote workspace hub registry", () => { + test("pairs one named OCX-only device without persisting its bearer token", () => { + const state = pairedHub(); + expect(state.paired.device).toMatchObject({ + name: "Computer 2", + platform: "linux-x64", + online: false, + roots: [{ label: "Project" }], + }); + expect(state.paired.deviceToken).toStartWith("ocxrw_"); + expect(state.paired.hubPublicKey).toBe(state.hub.identity().publicKey); + expect(state.hub.authenticateDeviceToken(state.paired.deviceToken)?.id).toBe(state.paired.device.id); + expect(JSON.stringify(state.store.state)).not.toContain(state.paired.deviceToken); + expect(JSON.stringify(state.hub.listDevices())).not.toContain("publicKey"); + expect(JSON.stringify(state.hub.listDevices())).not.toContain("tokenHash"); + }); + + test("consumes pairing codes once and enforces unique device names", () => { + const state = pairedHub(); + expect(() => state.hub.pairDevice({ + code: "not-a-code", + name: "Computer 3", + platform: "linux-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + })).toThrow("invalid or expired"); + + const grant = state.hub.createPairingGrant(); + expect(() => state.hub.pairDevice({ + code: grant.code, + name: "computer 2", + platform: "windows-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + })).toThrow("already in use"); + expect(() => state.hub.pairDevice({ + code: grant.code, + name: "Computer 3", + platform: "windows-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + })).toThrow("invalid or expired"); + }); + + test("bounds invalid pairing attempts by hashed source, expiry, and map capacity", () => { + let now = Date.parse("2026-09-03T12:00:00.000Z"); + const hub = new RemoteWorkspaceHub(new MemoryStore(), () => now); + const invalid = (source: string) => hub.pairDevice({ code: "AAAA-BBBB-CCCC" }, source); + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(() => invalid("peer:192.0.2.10")).toThrow("invalid or expired"); + } + let limited: unknown; + try { invalid("peer:192.0.2.10"); } catch (error) { limited = error; } + expect(limited).toBeInstanceOf(RemoteWorkspacePairingRateLimitError); + expect(limited).toMatchObject({ reason: "source", retryAfterSeconds: 600 }); + + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(() => invalid("peer:192.0.2.11")).toThrow("invalid or expired"); + } + const identity = generateRemoteControlIdentityKeyPair(); + const grant = hub.createPairingGrant(); + expect(hub.pairDevice({ + code: grant.code, + name: "Computer 2", + platform: "linux-x64", + publicKey: identity.publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + }, "peer:192.0.2.11").device.name).toBe("Computer 2"); + expect(() => invalid("peer:192.0.2.11")).toThrow("invalid or expired"); + + now += 10 * 60_000 + 1; + const afterExpiry = hub.createPairingGrant(); + expect(hub.pairDevice({ + code: afterExpiry.code, + name: "Computer 3", + platform: "linux-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + }, "peer:192.0.2.10").device.name).toBe("Computer 3"); + + const capped = new RemoteWorkspaceHub(new MemoryStore(), () => now); + for (let source = 0; source < 1_024; source += 1) { + expect(() => capped.pairDevice({ code: "AAAA-BBBB-CCCC" }, `peer:${source}`)) + .toThrow("invalid or expired"); + } + let capacity: unknown; + try { capped.pairDevice({ code: "AAAA-BBBB-CCCC" }, "peer:overflow"); } + catch (error) { capacity = error; } + expect(capacity).toBeInstanceOf(RemoteWorkspacePairingRateLimitError); + expect(capacity).toMatchObject({ reason: "capacity", retryAfterSeconds: 1 }); + }); + + test("tracks online presence, replaces reconnects, and revokes the device", () => { + const state = pairedHub(); + const closes: string[] = []; + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + socket: { + send: () => {}, + close: (_code, reason) => closes.push(reason), + }, + }); + state.hub.attachConnection(state.paired.device.id, connection); + expect(state.hub.listDevices()[0]).toMatchObject({ online: false }); + connection.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + expect(state.hub.listDevices()[0]).toMatchObject({ online: true, lastSeenAt: "2026-09-03T12:00:00.000Z" }); + expect(state.hub.connection(state.paired.device.id)).toBe(connection); + expect(state.hub.revokeDevice(state.paired.device.id)).toBe(true); + expect(state.hub.listDevices()).toEqual([]); + expect(connection.isOnline()).toBe(false); + expect(state.store.state?.devices).toEqual([]); + expect(state.hub.authenticateDeviceToken(state.paired.deviceToken)).toBeNull(); + expect(closes).toEqual(["remote workspace device was revoked"]); + }); + + test("refuses mismatched persisted hub identity keys", () => { + const first = generateRemoteControlIdentityKeyPair(); + const second = generateRemoteControlIdentityKeyPair(); + expect(() => parseRemoteWorkspaceHubState({ + version: 1, + identity: { publicKey: first.publicKey, privateKey: second.privateKey }, + devices: [], + })).toThrow("does not match"); + }); +}); + +test("presence reduces availability without changing the durable enrollment grant", () => { + const state = pairedHub(); + const advertised: unknown[] = []; + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + capabilities: ["workspace.read", "workspace.write"], + onCapabilities: capabilities => state.hub.updateDeviceCapabilities(state.paired.device.id, capabilities), + socket: { send: value => { advertised.push(JSON.parse(value)); }, close() {} }, + }); + state.hub.attachConnection(state.paired.device.id, connection); + connection.receive(serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read"], + })); + expect(state.hub.listDevices()[0]?.capabilities).toEqual(["workspace.read"]); + expect(state.store.state?.devices[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + expect(advertised[0]).toMatchObject({ capabilities: ["workspace.read"] }); + state.hub.detachConnection(state.paired.device.id, connection); + + const reconnect = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + capabilities: ["workspace.read", "workspace.write"], + socket: { send() {}, close() {} }, + }); + state.hub.attachConnection(state.paired.device.id, reconnect); + reconnect.receive(serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + })); + expect(reconnect.capabilities()).toEqual(["workspace.read", "workspace.write"]); + expect(state.hub.listDevices()[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + expect(state.store.state?.devices[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + state.hub.closeAllConnections(); +}); diff --git a/tests/clients/remote-workspace-linux-confinement.test.ts b/tests/clients/remote-workspace-linux-confinement.test.ts new file mode 100644 index 0000000000..4d88d29096 --- /dev/null +++ b/tests/clients/remote-workspace-linux-confinement.test.ts @@ -0,0 +1,114 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandRunnerAvailable, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +test("hosted Linux proves workspace write and denies adjacent access, loopback, and detached survival", async () => { + const required = process.env.OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT === "1"; + const available = process.platform === "linux" + && existsSync("/usr/bin/bwrap") + && linuxRemoteWorkspaceCommandRunnerAvailable(); + if (!required && !available) return; + expect(process.platform).toBe("linux"); + expect(existsSync("/usr/bin/bwrap")).toBe(true); + expect(available).toBe(true); + + const parent = mkdtempSync(join(tmpdir(), "ocx-remote-linux-confinement-")); + roots.push(parent); + const workspace = join(parent, "workspace"); + const marker = join(workspace, "probe-marker"); + const outsideRead = join(parent, "outside-secret"); + const outsideWrite = join(parent, "outside-write"); + mkdirSync(workspace); + writeFileSync(join(workspace, ".keep"), "workspace"); + writeFileSync(outsideRead, "must-not-be-visible"); + + let acceptedConnections = 0; + const listener = createServer(socket => { + acceptedConnections += 1; + socket.destroy(); + }); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", resolve); + }); + const address = listener.address(); + if (!address || typeof address === "string") throw new Error("loopback probe did not bind TCP"); + const runner = createPlatformRemoteWorkspaceCommandRunner({ + linux: { writableRoots: [workspace] }, + }); + if (!runner) throw new Error("production Linux Remote Workspace runner was not created"); + + try { + const result = await runner.run({ + root: workspace, + cwd: workspace, + command: [ + "bun", + "-e", + [ + 'import { readFileSync, writeFileSync } from "node:fs";', + 'const [outsideRead, outsideWrite, port] = process.argv.slice(1);', + 'if (!outsideRead || !outsideWrite || !port) process.exit(31);', + 'if (process.execPath !== "/ocx-runtime/bin/bun") process.exit(29);', + 'writeFileSync("probe-marker", "sandboxed");', + 'try { readFileSync(outsideRead); process.exit(26); } catch (error) { void error; }', + 'try { writeFileSync(outsideWrite, "escaped"); process.exit(27); } catch (error) { void error; }', + 'try { await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) }); process.exit(28); } catch (error) { void error; }', + ].join("\n"), + "--", + outsideRead, + outsideWrite, + String(address.port), + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }); + expect(result.exitCode).toBe(0); + expect(readFileSync(marker, "utf8")).toBe("sandboxed"); + expect(existsSync(outsideWrite)).toBe(false); + expect(acceptedConnections).toBe(0); + } finally { + await new Promise(resolve => listener.close(() => resolve())); + } + + const lateMarker = join(workspace, "late-marker"); + const controller = new AbortController(); + const pending = runner.run({ + root: workspace, + cwd: workspace, + command: [ + "/bin/bash", + "-c", + "setsid /bin/bash -c 'sleep 0.5; printf escaped > late-marker' >/dev/null 2>&1 & sleep 30", + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + signal: controller.signal, + }); + await Bun.sleep(100); + controller.abort(); + await expect(pending).rejects.toThrow("cancelled"); + await Bun.sleep(750); + expect(existsSync(lateMarker)).toBe(false); + + const unsafeWorkspace = join(parent, "unsafe-workspace"); + mkdirSync(unsafeWorkspace); + linkSync(outsideRead, join(unsafeWorkspace, "outside-alias")); + expect(createPlatformRemoteWorkspaceCommandRunner({ + linux: { writableRoots: [unsafeWorkspace] }, + })).toBeUndefined(); + expect(readFileSync(outsideRead, "utf8")).toBe("must-not-be-visible"); +}); diff --git a/tests/clients/remote-workspace-platform.test.ts b/tests/clients/remote-workspace-platform.test.ts new file mode 100644 index 0000000000..ec48362055 --- /dev/null +++ b/tests/clients/remote-workspace-platform.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { + findExecutableOnPath, +} from "../../src/remote-control/workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + remoteWorkspaceThreadStartParams, + linuxRemoteWorkspaceCommandRunnerAvailable, + remoteWorkspaceCapabilitiesForCommandRunner, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + truncateRemoteWorkspaceUtf8, + validateRemoteWorkspaceRelativePath, +} from "../../src/remote-control"; + +describe("Remote Workspace cross-platform boundaries", () => { + test("resolves Windows PATH and PATHEXT with Windows grammar on every test host", () => { + const visited: string[] = []; + const resolved = findExecutableOnPath("claude", { + platform: "win32", + path: "C:\\first;D:\\npm", + pathExt: ".PS1;.EXE;.CMD", + probe(candidate) { + visited.push(candidate); + return candidate.toLowerCase() === "d:\\npm\\claude.cmd"; + }, + }); + expect(resolved).toBe("D:\\npm\\claude.cmd"); + expect(visited).toEqual([ + "C:\\first\\claude.exe", + "C:\\first\\claude.cmd", + "D:\\npm\\claude.exe", + "D:\\npm\\claude.cmd", + ]); + }); + + test("launches Windows npm shims through escaped ComSpec and leaves Unix argv direct", () => { + const windows = remoteWorkspaceProcessInvocation( + ["C:\\Users\\u\\AppData\\Roaming\\npm\\claude.cmd", "--system-prompt", "a&b"], + { platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" } }, + ); + expect(windows.file).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(windows.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(windows.args[3]).toContain("a^&b"); + expect(windows.options.windowsVerbatimArguments).toBe(true); + + expect(remoteWorkspaceProcessInvocation(["/usr/bin/claude", "--version"], { platform: "linux" })) + .toEqual({ file: "/usr/bin/claude", args: ["--version"], options: {} }); + expect(remoteWorkspaceProcessInvocation(["/opt/homebrew/bin/pi", "--version"], { platform: "darwin" })) + .toEqual({ file: "/opt/homebrew/bin/pi", args: ["--version"], options: {} }); + }); + + test("stops the exact Windows wrapper tree through trusted taskkill semantics", async () => { + let settle!: (code: number) => void; + const exited = new Promise(resolve => { settle = resolve; }); + const calls: Array<{ file: string; args: readonly string[] }> = []; + let fallbackKills = 0; + await stopRemoteWorkspaceProcess({ + pid: 4242, + exitCode: null, + exited, + kill() { fallbackKills += 1; settle(0); }, + }, { + platform: "win32", + taskkillPath: "C:\\Windows\\System32\\taskkill.exe", + execFile(file, args) { calls.push({ file, args }); settle(0); }, + waitMs: 10, + }); + expect(calls).toEqual([{ + file: "C:\\Windows\\System32\\taskkill.exe", + args: ["/PID", "4242", "/T", "/F"], + }]); + expect(fallbackKills).toBe(0); + }); + + test("escalates a Unix child that ignores SIGTERM without killing unrelated processes", async () => { + let settle!: (code: number) => void; + const exited = new Promise(resolve => { settle = resolve; }); + const signals: Array = []; + await stopRemoteWorkspaceProcess({ + pid: 4243, + exitCode: null, + exited, + kill(signal) { + signals.push(signal); + if (signal === "SIGKILL") settle(137); + }, + }, { platform: "darwin", waitMs: 1 }); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + }); + + test("runs every cleanup owner even when an earlier resource fails", async () => { + const completed: string[] = []; + await expect(runRemoteWorkspaceCleanupSteps([ + () => { completed.push("process"); throw new Error("process cleanup failed"); }, + async () => { completed.push("bridge"); }, + () => { completed.push("isolation"); }, + ])).rejects.toThrow("process cleanup failed"); + expect(completed).toEqual(["process", "bridge", "isolation"]); + }); + + test("reports an owned child that remains alive after forced termination", async () => { + const exited = new Promise(() => {}); + await expect(stopRemoteWorkspaceProcess({ + pid: 4244, + exitCode: null, + exited, + kill() {}, + }, { platform: "linux", waitMs: 1 })).rejects.toThrow("did not exit after SIGKILL"); + }); + + test("reconnection cannot widen the capability grant recorded at pairing", () => { + const runner = { async run() { return { exitCode: 0, stdout: "", stderr: "" }; } }; + expect(remoteWorkspaceCapabilitiesForCommandRunner(runner, ["workspace.read"])) + .toEqual(["workspace.read"]); + expect(remoteWorkspaceCapabilitiesForCommandRunner(undefined, [ + "workspace.read", "workspace.write", "workspace.exec", + ])).toEqual(["workspace.read", "workspace.write"]); + }); + + test("bounds large UTF-8 text without quadratic trimming or split surrogate pairs", () => { + const value = `${"가".repeat(100_000)}😀tail`; + const truncated = truncateRemoteWorkspaceUtf8(value, 8_192); + expect(Buffer.byteLength(truncated, "utf8")).toBeLessThanOrEqual(8_192); + expect(truncated.endsWith("\ud83d")).toBe(false); + expect(truncated.includes("tail")).toBe(false); + }); + + test("uses platform-native deny-local shell environments", () => { + const windows = remoteWorkspaceThreadStartParams({ + executorName: "Windows executor", + coordinatorIsolationPath: "/test/coordinator", + tools: ["read_file"], + platform: "win32", + windowsSystemDirectory: "C:\\Windows\\System32", + mcp: { url: "http://127.0.0.1:1/mcp", bearerTokenEnvVar: "TOKEN" }, + }) as { config: { shell_environment_policy: { set: Record } } }; + expect(windows.config.shell_environment_policy.set).toMatchObject({ + USERPROFILE: "/test/coordinator", + TEMP: "/test/coordinator", + PATH: "C:\\Windows\\System32", + }); + expect(windows.config.shell_environment_policy.set.PATH).not.toContain("/usr/"); + + const mac = remoteWorkspaceThreadStartParams({ + executorName: "Mac executor", + coordinatorIsolationPath: "/test/coordinator", + tools: ["read_file"], + platform: "darwin", + mcp: { url: "http://127.0.0.1:1/mcp", bearerTokenEnvVar: "TOKEN" }, + }) as { config: { shell_environment_policy: { set: Record } } }; + expect(mac.config.shell_environment_policy.set.PATH).toBe("/usr/bin:/bin"); + }); + + test("advertises Linux exec only after the namespace probe succeeds", () => { + if (!existsSync("/usr/bin/bwrap")) return; + let sawNetworkIsolation = false; + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: "/usr/bin/bwrap", + probe(argv) { + sawNetworkIsolation = argv.includes("--unshare-net"); + return false; + }, + })).toBe(false); + expect(sawNetworkIsolation).toBe(true); + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: "/usr/bin/bwrap", + probe: () => true, + })).toBe(true); + }); + + test("rejects Windows device names, ADS, and normalized aliases without blocking POSIX names", () => { + for (const path of ["NUL", "con.txt", "CONIN$", "CLOCK$.txt", "logs\\COM1.json", "file.txt:token", "name.", "name ", "bad\u0001name"]) { + expect(() => validateRemoteWorkspaceRelativePath(path, undefined, "win32")).toThrow("safe Windows"); + } + expect(validateRemoteWorkspaceRelativePath("normal\\file.txt", undefined, "win32")) + .toBe("normal\\file.txt"); + expect(validateRemoteWorkspaceRelativePath("NUL:valid-on-posix", undefined, "linux")) + .toBe("NUL:valid-on-posix"); + }); +}); diff --git a/tests/clients/remote-workspace-secret-store.test.ts b/tests/clients/remote-workspace-secret-store.test.ts new file mode 100644 index 0000000000..6913e11bf6 --- /dev/null +++ b/tests/clients/remote-workspace-secret-store.test.ts @@ -0,0 +1,105 @@ +import { afterEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateRemoteControlIdentityKeyPair } from "../../src/remote-control/crypto"; +import { RemoteWorkspaceHubFileStore } from "../../src/remote-control/workspace-hub"; +import { RemoteWorkspaceDeviceFileStore } from "../../src/remote-control/workspace-device"; +import { RemoteWorkspaceSessionFileStore } from "../../src/remote-control/workspace-sessions"; +import { workspaceSecretPermissions, type WorkspaceSecretPermissions } from "../../src/remote-control/workspace-secret-store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousHome = process.env.OPENCODEX_HOME; +const roots: string[] = []; +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixtures() { + const root = mkdtempSync(join(tmpdir(), "ocx-workspace-secret-")); + roots.push(root); + process.env.OPENCODEX_HOME = root; + const identity = generateRemoteControlIdentityKeyPair(); + const hubState = { version: 1 as const, identity, devices: [] }; + const sessionState = { version: 1 as const, sessions: [] }; + const deviceState = { + version: 1 as const, hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), deviceName: "Executor", devicePlatform: "test", + capabilities: ["workspace.read" as const], deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: identity, hubPublicKey: identity.publicKey, + roots: [{ id: randomUUID(), label: "Project", path: root }], toolchainRoots: [], + }; + return [ + { path: join(root, "hub.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceHubFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(hubState) }; + } }, + { path: join(root, "device.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceDeviceFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(deviceState) }; + } }, + { path: join(root, "sessions.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceSessionFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(sessionState) }; + } }, + ]; +} + +test("all workspace stores distinguish absent state from permission failure", () => { + for (const fixture of fixtures()) { + const store = fixture.create(fixture.path); + expect(store.load()).toBeNull(); + store.save(); + expect(store.load()).not.toBeNull(); + if (process.platform !== "win32") expect(statSync(fixture.path).mode & 0o777).toBe(0o600); + } +}); + +test("all stores propagate hardening failures before decoding or publishing secret bytes", () => { + for (const fixture of fixtures()) { + for (const failedStep of ["prepareDirectory", "hardenFile"] as const) { + // Invalid JSON would fail if read reached decoding instead of the permission boundary. + writeFileSync(fixture.path, "private-sentinel-not-json", { mode: 0o600 }); + const calls: string[] = []; + const permissions: WorkspaceSecretPermissions = { + prepareDirectory() { calls.push("directory"); if (failedStep === "prepareDirectory") throw new Error("denied hardening"); }, + hardenFile() { calls.push("file"); throw new Error("denied hardening"); }, + }; + const store = fixture.create(fixture.path, permissions); + expect(() => store.load()).toThrow("denied hardening"); + expect(() => store.save()).toThrow("denied hardening"); + expect(readFileSync(fixture.path, "utf8")).toBe("private-sentinel-not-json"); + expect(calls).toEqual(failedStep === "prepareDirectory" + ? ["directory", "directory"] : ["directory", "file", "directory", "file"]); + } + } +}); + +test("secret files refuse symbolic-link targets", () => { + if (process.platform === "win32") return; // Windows link creation requires separate privileges. + const fixture = fixtures()[0]!; + const target = `${fixture.path}.target`; + writeFileSync(target, "private", { mode: 0o600 }); + symlinkSync(target, fixture.path); + expect(() => workspaceSecretPermissions.hardenFile(fixture.path)).toThrow("regular file"); + expect(readFileSync(target, "utf8")).toBe("private"); +}); + + +test("an inaccessible existing store is never reported as first-run absence", () => { + if (process.platform === "win32" || process.getuid?.() === 0) return; + for (const fixture of fixtures()) { + const store = fixture.create(fixture.path); + store.save(); + const before = readFileSync(fixture.path, "utf8"); + const directory = fixture.path.slice(0, fixture.path.lastIndexOf("/")); + chmodSync(directory, 0); + try { expect(() => store.load()).toThrow(); } + finally { chmodSync(directory, 0o700); } + expect(readFileSync(fixture.path, "utf8")).toBe(before); + } +}); diff --git a/tests/clients/remote-workspace-session-binding.test.ts b/tests/clients/remote-workspace-session-binding.test.ts new file mode 100644 index 0000000000..ac1ab7c7c3 --- /dev/null +++ b/tests/clients/remote-workspace-session-binding.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + EncryptedRemoteWorkspaceExecutorEndpoint, + RemoteControlClientHandshake, + acceptRemoteControlClientHello, + frameRemoteWorkspaceRpcMessage, + generateRemoteControlIdentityKeyPair, + type RemoteWorkspaceExecutionRequest, +} from "../../src/remote-control"; + +function fixture() { + const hub = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const sessionId = randomUUID(); + const deviceId = randomUUID(); + const handshake = RemoteControlClientHandshake.create({ + sessionId, deviceId, commandProfile: "codex", capabilities: ["workspace.read"], + accountPrivateKey: hub.privateKey, + }); + const accepted = acceptRemoteControlClientHello(handshake.hello, { + expectedSessionId: sessionId, expectedDeviceId: deviceId, + accountPublicKey: hub.publicKey, devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write"], + }); + const client = handshake.complete(accepted.hello, device.publicKey); + const invocations: RemoteWorkspaceExecutionRequest[] = []; + const endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: deviceId, sessionId, rootId: "first-approved-root", + capabilities: ["workspace.read"], cipher: accepted.cipher, + executor: { async invoke(request) { invocations.push(request); return { ok: true }; } }, + sendCiphertext() {}, + }); + const request: RemoteWorkspaceExecutionRequest = { + requestId: randomUUID(), sessionId, executorDeviceId: deviceId, + rootId: "first-approved-root", tool: "read_file", arguments: { path: "marker" }, + }; + return { + invocations, + async send(overrides: Partial = {}) { + const message = new TextEncoder().encode(JSON.stringify({ + version: 1, kind: "request", request: { ...request, ...overrides }, + })); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await endpoint.receiveCiphertext(client.encrypt(frame)); + } + }, + close() { endpoint.close(); client.destroy(); }, + }; +} + +test("encrypted requests cannot leave their session grant before executor invocation", async () => { + const mismatches: Partial[] = [ + { sessionId: randomUUID() }, + { executorDeviceId: randomUUID() }, + { rootId: "second-approved-root" }, + { tool: "write_file", arguments: { path: "marker", content: "changed", expectedSha256: null } }, + ]; + for (const mismatch of mismatches) { + const state = fixture(); + try { + await expect(state.send(mismatch)).rejects.toThrow(); + expect(state.invocations).toEqual([]); + } finally { state.close(); } + } +}); + +test("a matching encrypted read reaches the selected executor once", async () => { + const state = fixture(); + try { + await state.send(); + expect(state.invocations).toHaveLength(1); + expect(state.invocations[0]).toMatchObject({ rootId: "first-approved-root", tool: "read_file" }); + } finally { state.close(); } +}); diff --git a/tests/clients/remote-workspace-sessions.test.ts b/tests/clients/remote-workspace-sessions.test.ts new file mode 100644 index 0000000000..dc0be8ed16 --- /dev/null +++ b/tests/clients/remote-workspace-sessions.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from "bun:test"; +import type { RemoteWorkspaceHub } from "../../src/remote-control/workspace-hub"; +import { + RemoteWorkspaceSessionService, + type RemoteWorkspaceRuntimeFactory, + type RemoteWorkspaceRuntimeHandle, + type RemoteWorkspaceSessionEvent, + type RemoteWorkspaceSessionState, + type RemoteWorkspaceSessionStateStore, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; + +const DEVICE_ID = "11111111-1111-4111-8111-111111111111"; +const ROOT_ID = "22222222-2222-4222-8222-222222222222"; + +interface Harness { + service: RemoteWorkspaceSessionService; + setOnline(value: boolean): void; + invocations: Array<{ tool: string; rootId: string }>; + closedSessions: string[]; + stopCalls(): number; + sessionOpens(): number; + sessionGrants: string[][]; + runtimeStarts(): Array; +} + +class MemorySessionStore implements RemoteWorkspaceSessionStateStore { + state: RemoteWorkspaceSessionState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceSessionState) { this.state = structuredClone(state); } +} + +function deferred(): { + promise: Promise; + resolve(): void; + reject(error: Error): void; +} { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createHarness(options: { + promptGate?: ReturnType; + startGate?: ReturnType; + onStart?: () => void; + lazyResumable?: boolean; + eventsAtStart?: number; + sessionStore?: RemoteWorkspaceSessionStateStore; + stopError?: Error; + closeError?: Error; +} = {}): Harness { + let online = true; + let stops = 0; + let opens = 0; + let promptStarted = false; + let runtimeResumable = options.lazyResumable !== true; + const invocations: Array<{ tool: string; rootId: string }> = []; + const closedSessions: string[] = []; + const sessionGrants: string[][] = []; + const transportStates: Array<{ online: boolean }> = []; + const runtimeStarts: Array = []; + const newTransport = (): RemoteWorkspaceTransport => { + const state = { online: true }; + transportStates.push(state); + return { + isOnline: deviceId => state.online && deviceId === DEVICE_ID, + async invoke(request) { + if (!state.online) throw new Error("transport offline"); + invocations.push({ tool: request.tool, rootId: request.rootId }); + return { ok: true, value: { entries: ["src"] } }; + }, + }; + }; + const connection = { + capabilities: () => ["workspace.read", "workspace.write", "workspace.exec"], + async openSession(input: { capabilities: string[] }) { sessionGrants.push([...input.capabilities]); opens += 1; return newTransport(); }, + async closeSession(sessionId: string) { + closedSessions.push(sessionId); + if (options.closeError) throw options.closeError; + }, + }; + const hub = { + listDevices: () => [{ + id: DEVICE_ID, + name: "Build box", + platform: "linux", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + roots: [{ id: ROOT_ID, label: "Project" }], + online, + createdAt: "2026-01-01T00:00:00.000Z", + lastSeenAt: null, + }], + connection: (deviceId: string) => online && deviceId === DEVICE_ID ? connection : null, + } as unknown as RemoteWorkspaceHub; + + const factory: RemoteWorkspaceRuntimeFactory = { + profile: "codex", + async available() { return { available: true, version: "test" }; }, + async start({ coordinator, emit, resumeThreadId }) { + options.onStart?.(); + if (options.startGate) await options.startGate.promise; + runtimeStarts.push(resumeThreadId); + for (let index = 0; index < (options.eventsAtStart ?? 0); index += 1) { + emit("assistant", `event-${index}`); + } + const handle: RemoteWorkspaceRuntimeHandle = { + threadId: resumeThreadId ?? "thread-remote-1", + canResume: () => runtimeResumable, + async prompt() { + promptStarted = true; + if (options.promptGate) await options.promptGate.promise; + else { + const response = await coordinator.handle({ + method: "item/tool/call", + id: "tool-1", + params: { + threadId: "thread-remote-1", + turnId: "turn-1", + callId: "call-1", + namespace: "ocx_remote_workspace", + tool: "list_directory", + arguments: { path: "." }, + }, + }); + emit("tool", response.result.contentItems[0]!.text); + } + runtimeResumable = true; + }, + async stop() { + stops += 1; + if (promptStarted) options.promptGate?.reject(new Error("turn cancelled")); + if (options.stopError) throw options.stopError; + }, + }; + return handle; + }, + }; + return { + service: new RemoteWorkspaceSessionService(hub, [factory], Date.now, options.sessionStore), + setOnline(value) { + online = value; + if (!value) for (const state of transportStates) state.online = false; + }, + invocations, + closedSessions, + stopCalls: () => stops, + sessionOpens: () => opens, + sessionGrants, + runtimeStarts: () => [...runtimeStarts], + }; +} + +describe("Remote Workspace session service", () => { + test("binds one model session to the selected executor root", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.status).toBe("ready"); + expect(created.deviceName).toBe("Build box"); + expect(created.rootLabel).toBe("Project"); + expect(created).toMatchObject({ + accessMode: "read-only", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + + const completed = await harness.service.prompt(created.id, "Inspect this project"); + expect(completed.status).toBe("ready"); + expect(harness.invocations).toEqual([{ tool: "list_directory", rootId: ROOT_ID }]); + expect(completed.events.some(event => event.type === "tool" && event.text.includes("src"))).toBe(true); + }); + + test("exposes write and exec tools only after an explicit workspace access grant", async () => { + const harness = createHarness(); + const created = await harness.service.create({ + profile: "codex", + deviceId: DEVICE_ID, + rootId: ROOT_ID, + accessMode: "workspace", + }); + expect(created).toMatchObject({ + accessMode: "workspace", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + }); + + test("fails closed when the selected executor disconnects", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + harness.setOnline(false); + await expect(harness.service.prompt(created.id, "Do not run locally")).rejects.toThrow("executor is offline"); + expect(harness.invocations).toHaveLength(0); + expect(harness.service.get(created.id)?.status).toBe("waiting_for_executor"); + }); + + test("reopens only the encrypted executor channel after the device reconnects", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(harness.sessionOpens()).toBe(1); + harness.setOnline(false); + expect(harness.service.get(created.id)?.status).toBe("waiting_for_executor"); + harness.setOnline(true); + const completed = await harness.service.prompt(created.id, "Continue remotely"); + expect(completed.status).toBe("ready"); + expect(harness.sessionOpens()).toBe(2); + expect(harness.invocations).toEqual([{ tool: "list_directory", rootId: ROOT_ID }]); + }); + + test("rejects a second prompt while a turn is active", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const first = harness.service.prompt(created.id, "First"); + await Promise.resolve(); + await expect(harness.service.prompt(created.id, "Second")).rejects.toThrow("active turn"); + gate.resolve(); + await first; + }); + + test("a turn that finishes after disconnect stays waiting instead of reporting ready", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const running = harness.service.prompt(created.id, "Keep the target binding"); + await Promise.resolve(); + harness.setOnline(false); + gate.resolve(); + const completed = await running; + expect(completed.status).toBe("waiting_for_executor"); + }); + + test("stop cancels an active turn before waiting for it", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const promptOutcome = harness.service.prompt(created.id, "Long turn").then( + () => "resolved", + () => "rejected", + ); + await Promise.resolve(); + + expect(await harness.service.stop(created.id)).toBe(true); + expect(await promptOutcome).toBe("rejected"); + expect(harness.stopCalls()).toBe(1); + expect(harness.closedSessions).toEqual([created.id]); + expect(harness.service.get(created.id)?.status).toBe("stopped"); + }); + + test("stop cannot be overwritten by a session that finishes starting late", async () => { + const startGate = deferred(); + const startEntered = deferred(); + const harness = createHarness({ startGate, onStart: startEntered.resolve }); + const creating = harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await startEntered.promise; + const starting = harness.service.list()[0]; + if (!starting) throw new Error("starting session was not visible"); + + expect(await harness.service.stop(starting.id)).toBe(true); + startGate.resolve(); + await expect(creating).rejects.toThrow("stopped while starting"); + expect(harness.service.get(starting.id)?.status).toBe("stopped"); + expect(harness.stopCalls()).toBe(1); + }); + + test("attempts every session cleanup owner and reports incomplete teardown", async () => { + const harness = createHarness({ + stopError: new Error("runtime refused to stop"), + closeError: new Error("transport refused to close"), + }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await expect(harness.service.stop(created.id)).rejects.toThrow("runtime refused to stop"); + expect(harness.stopCalls()).toBe(1); + expect(harness.closedSessions).toEqual([created.id]); + expect(harness.service.get(created.id)?.status).toBe("failed"); + }); + + test("keeps only a bounded event history", async () => { + const harness = createHarness({ eventsAtStart: 510 }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.events).toHaveLength(100); + expect(created.events[0]!.sequence).toBeGreaterThan(1); + const types: RemoteWorkspaceSessionEvent["type"][] = created.events.map(event => event.type); + expect(types.at(-1)).toBe("status"); + }); + + test("restores a persisted Hub session and resumes its original model thread", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(store.state?.sessions[0]?.threadId).toBe("thread-remote-1"); + + const restarted = createHarness({ sessionStore: store }); + expect(restarted.service.get(created.id)?.status).toBe("waiting_for_executor"); + const completed = await restarted.service.prompt(created.id, "Continue after Hub restart"); + expect(completed.status).toBe("ready"); + expect(restarted.runtimeStarts()).toEqual(["thread-remote-1"]); + }); + + test("persists a lazy runtime as resumable only after its first completed turn", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store, lazyResumable: true }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.resumable).toBe(false); + expect(store.state?.sessions[0]?.resumable).toBe(false); + + const completed = await first.service.prompt(created.id, "Create durable history"); + expect(completed.resumable).toBe(true); + const restarted = createHarness({ sessionStore: store, lazyResumable: true }); + expect(restarted.service.get(created.id)?.status).toBe("waiting_for_executor"); + }); + + test("graceful Hub shutdown cleans runtimes without marking resumable sessions stopped", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await first.service.shutdown(); + expect(first.stopCalls()).toBe(1); + expect(store.state?.sessions[0]?.status).toBe("waiting_for_executor"); + + const restarted = createHarness({ sessionStore: store }); + const completed = await restarted.service.prompt(created.id, "Resume after graceful restart"); + expect(completed.status).toBe("ready"); + expect(restarted.runtimeStarts()).toEqual(["thread-remote-1"]); + }); + + test("stops every retained runtime during Hub shutdown", async () => { + const harness = createHarness(); + await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await harness.service.stopAll(); + expect(harness.stopCalls()).toBe(2); + expect(harness.service.list().every(session => session.status === "stopped")).toBe(true); + }); +}); + + +test("read-only capability grant is forwarded on initial open and reconnect", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID, accessMode: "read-only" }); + expect(harness.sessionGrants).toEqual([["workspace.read"]]); + harness.setOnline(false); + harness.service.list(); + harness.setOnline(true); + await harness.service.prompt(created.id, "Read after reconnect"); + expect(harness.sessionGrants).toEqual([["workspace.read"], ["workspace.read"]]); + await harness.service.stop(created.id); +}); diff --git a/tests/clients/remote-workspace-tool-bridge.test.ts b/tests/clients/remote-workspace-tool-bridge.test.ts new file mode 100644 index 0000000000..48c24634b7 --- /dev/null +++ b/tests/clients/remote-workspace-tool-bridge.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { RemoteWorkspaceCoordinator, startRemoteWorkspaceToolBridge } from "../../src/remote-control"; + +test("loopback CLI bridge accepts only its bearer and delegates to the E2EE coordinator", async () => { + const invocations: string[] = []; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke(request) { + invocations.push(request.tool); + return { ok: true, value: { entries: ["src"] } }; + }, + }); + coordinator.register({ + sessionId: "session-1", + threadId: "thread-1", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: "thread-1", + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + try { + const denied = await fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + expect(denied.status).toBe(401); + const allowed = await fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + expect(allowed.status).toBe(200); + const body = await allowed.json() as { success: boolean; text: string }; + expect(body.success).toBe(true); + expect(body.text).toContain("src"); + expect(invocations).toEqual(["list_directory"]); + } finally { + await bridge.stop(); + } +}); + +test("loopback CLI bridge rejects excess work before buffering another request", async () => { + const releases: Array<() => void> = []; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + invoke: async () => await new Promise<{ ok: true; value: null }>(resolve => { + releases.push(() => resolve({ ok: true, value: null })); + }), + }); + coordinator.register({ + sessionId: "session-1", + threadId: "thread-1", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: "thread-1", + tools: ["list_directory"], + }); + const request = () => fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + try { + const active = Array.from({ length: 8 }, request); + for (let count = 0; count < 100 && releases.length < 8; count += 1) await Bun.sleep(1); + expect(releases).toHaveLength(8); + expect((await request()).status).toBe(429); + for (const release of releases) release(); + expect((await Promise.all(active)).every(response => response.status === 200)).toBe(true); + } finally { + for (const release of releases) release(); + await bridge.stop(); + } +}); diff --git a/tests/clients/remote-workspace.test.ts b/tests/clients/remote-workspace.test.ts new file mode 100644 index 0000000000..6754d5b8ff --- /dev/null +++ b/tests/clients/remote-workspace.test.ts @@ -0,0 +1,464 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash, randomUUID } from "node:crypto"; +import { + mkdirSync, + linkSync, + mkdtempSync, + readFileSync, + renameSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, + RemoteControlClientHandshake, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, + acceptRemoteControlClientHello, + generateRemoteControlIdentityKeyPair, + remoteWorkspaceThreadStartParams, + type AppServerDynamicToolRequest, + type RemoteWorkspaceCommandRunner, + type RemoteWorkspaceExecutionRequest, + type RemoteWorkspaceToolResult, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +const localTestCommandRunner: RemoteWorkspaceCommandRunner = { + async run(request) { + const child = Bun.spawn(request.command, { + cwd: request.cwd, + env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: request.cwd }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, request.timeoutMs); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (timedOut) throw new Error("local test command timed out"); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > request.maxOutputBytes) { + throw new Error("local test command output limit exceeded"); + } + return { stdout, stderr, exitCode }; + } finally { + clearTimeout(timer); + } + }, +}; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-workspace-")); + roots.push(root); + const main = join(root, "main"); + const executorRoot = join(root, "executor"); + mkdirSync(join(main, "project"), { recursive: true }); + mkdirSync(join(executorRoot, "project"), { recursive: true }); + writeFileSync(join(main, "project", "marker.txt"), "main-only"); + writeFileSync(join(executorRoot, "project", "marker.txt"), "executor-before"); + const deviceId = `device-${randomUUID()}`; + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "project-root", path: executorRoot }], + commandRunner: localTestCommandRunner, + }); + let online = true; + let invokeCount = 0; + const transport: RemoteWorkspaceTransport = { + isOnline: candidate => online && candidate === deviceId, + async invoke(request: RemoteWorkspaceExecutionRequest): Promise { + invokeCount += 1; + return await executor.invoke(request); + }, + }; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const threadId = `thread-${randomUUID()}`; + coordinator.register({ + sessionId: `session-${randomUUID()}`, + threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const request = (tool: string, args: unknown, id: number = 1): AppServerDynamicToolRequest => ({ + method: "item/tool/call", + id, + params: { + threadId, + turnId: `turn-${randomUUID()}`, + callId: `call-${randomUUID()}`, + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool, + arguments: args, + }, + }); + return { + root, + main, + executorRoot, + executor, + coordinator, + request, + setOnline(value: boolean) { online = value; }, + invokeCount: () => invokeCount, + }; +} + +function responseValue(response: Awaited>): RemoteWorkspaceToolResult { + return JSON.parse(response.result.contentItems[0]!.text) as RemoteWorkspaceToolResult; +} + +describe("remote workspace coordinator and executor", () => { + test("publishes only the namespaced client-executed tools and isolates the coordinator cwd", () => { + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS).toHaveLength(1); + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].name).toBe(REMOTE_WORKSPACE_TOOL_NAMESPACE); + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.map(tool => tool.name)).toEqual([ + "list_directory", "read_file", "write_file", "exec", + ]); + const coordinatorIsolation = resolve("isolated-coordinator-session"); + const params = remoteWorkspaceThreadStartParams({ + executorName: "Computer 2", + coordinatorIsolationPath: coordinatorIsolation, + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + expect(params).toMatchObject({ + cwd: coordinatorIsolation, + runtimeWorkspaceRoots: [coordinatorIsolation], + approvalPolicy: "never", + serviceName: "opencodex_remote_workspace", + }); + expect(String(params.developerInstructions)).toContain("never fall back locally"); + }); + + test("rejects write and exec calls that are outside the session access grant", async () => { + let invoked = false; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { invoked = true; return { ok: true }; }, + }); + coordinator.register({ + sessionId: "session-read-only", + threadId: "thread-read-only", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + const result = await coordinator.handle({ + method: "item/tool/call", + id: "request-1", + params: { + threadId: "thread-read-only", + turnId: "turn-1", + callId: "call-1", + namespace: "ocx_remote_workspace", + tool: "exec", + arguments: { command: ["true"] }, + }, + }); + expect(responseValue(result).error).toContain("not supported"); + expect(invoked).toBe(false); + }); + + test("writes and executes only inside Computer 2 while the same Computer 1 path stays unchanged", async () => { + const state = fixture(); + const write = await state.coordinator.handle(state.request("write_file", { + path: "project/marker.txt", + content: "executor-after", + expectedSha256: sha256("executor-before"), + })); + expect(write.result.success).toBe(true); + expect(responseValue(write).ok).toBe(true); + expect(readFileSync(join(state.executorRoot, "project", "marker.txt"), "utf8")).toBe("executor-after"); + expect(readFileSync(join(state.main, "project", "marker.txt"), "utf8")).toBe("main-only"); + + const command = process.platform === "win32" + ? ["powershell.exe", "-NoProfile", "-Command", "Write-Output -NoNewline 'executor-process:'; (Get-Location).Path"] + : ["/bin/sh", "-lc", "printf 'executor-process:'; pwd"]; + const exec = await state.coordinator.handle(state.request("exec", { + command, + cwd: "project", + timeoutMs: 5_000, + }, 2)); + const result = responseValue(exec); + expect(exec.result.success).toBe(true); + expect(result.ok).toBe(true); + expect(JSON.stringify(result.value)).toContain("executor-process:"); + expect(JSON.stringify(result.value)).toContain(join(state.executorRoot, "project")); + expect(JSON.stringify(result.value)).not.toContain(state.main); + }); + + test("lists and reads bounded workspace data through the selected root", async () => { + const state = fixture(); + const list = responseValue(await state.coordinator.handle(state.request("list_directory", { path: "project" }))); + expect(list).toMatchObject({ ok: true, value: { path: "project" } }); + expect(JSON.stringify(list.value)).toContain("marker.txt"); + + const read = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/marker.txt", + maxBytes: 1024, + }))); + expect(read).toMatchObject({ ok: true, value: { content: "executor-before", bytes: 15 } }); + expect((read.value as { sha256: string }).sha256).toBe(sha256("executor-before")); + }); + + test("does not read an unbounded existing file while checking a write precondition", async () => { + const state = fixture(); + writeFileSync( + join(state.executorRoot, "project", "oversized.txt"), + Buffer.alloc(REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES + 1), + ); + const write = responseValue(await state.coordinator.handle(state.request("write_file", { + path: "project/oversized.txt", + content: "replacement", + expectedSha256: "0".repeat(64), + }))); + expect(write.ok).toBe(false); + expect(write.error).toContain("read limit"); + }); + + test("rejects traversal and symlink escapes on the executor", async () => { + const state = fixture(); + const traversal = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "../main/project/marker.txt", + }))); + expect(traversal.ok).toBe(false); + expect(traversal.error).toContain("escapes"); + + symlinkSync( + join(state.main, "project"), + join(state.executorRoot, "outside-link"), + process.platform === "win32" ? "junction" : "dir", + ); + const symlink = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "outside-link/marker.txt", + }))); + expect(symlink.ok).toBe(false); + expect(symlink.error).toContain("symlink"); + expect(readFileSync(join(state.main, "project", "marker.txt"), "utf8")).toBe("main-only"); + }); + + test("rejects hardlink aliases for both file reads and writes", async () => { + const state = fixture(); + const outside = join(state.main, "project", "marker.txt"); + linkSync(outside, join(state.executorRoot, "project", "outside-alias.txt")); + const read = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/outside-alias.txt", + }))); + expect(read.ok).toBe(false); + expect(read.error).toContain("hard-linked"); + + const write = responseValue(await state.coordinator.handle(state.request("write_file", { + path: "project/outside-alias.txt", + content: "escaped", + expectedSha256: sha256("main-only"), + }))); + expect(write.ok).toBe(false); + expect(write.error).toContain("hard-linked"); + expect(readFileSync(outside, "utf8")).toBe("main-only"); + }); + + test("rejects a workspace root replaced after local approval", async () => { + const state = fixture(); + renameSync(state.executorRoot, `${state.executorRoot}-approved`); + mkdirSync(join(state.executorRoot, "project"), { recursive: true }); + writeFileSync(join(state.executorRoot, "project", "marker.txt"), "replacement-root"); + const result = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/marker.txt", + }))); + expect(result.ok).toBe(false); + expect(result.error).toContain("root identity changed"); + }); + + test("fails closed while the selected executor is offline and never invokes another path", async () => { + const state = fixture(); + state.setOnline(false); + const response = await state.coordinator.handle(state.request("exec", { + command: ["/bin/true"], + })); + expect(response.result.success).toBe(false); + expect(responseValue(response).error).toContain("local fallback is disabled"); + expect(state.invokeCount()).toBe(0); + }); + + test("keeps command execution disabled by default until an OS sandbox is supplied", async () => { + const state = fixture(); + const locked = new RemoteWorkspaceExecutor({ + deviceId: "locked-device", + roots: [{ id: "project-root", path: state.executorRoot }], + }); + const result = await locked.invoke({ + requestId: randomUUID(), + sessionId: randomUUID(), + executorDeviceId: "locked-device", + rootId: "project-root", + tool: "exec", + arguments: { command: ["/bin/true"] }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("OS sandbox"); + }); + + test("rejects unbound threads and non-remote namespaces before transport", async () => { + const state = fixture(); + const unbound = state.request("read_file", { path: "project/marker.txt" }); + (unbound.params as Record).threadId = `other-${randomUUID()}`; + expect(responseValue(await state.coordinator.handle(unbound)).error).toContain("not bound"); + + const wrongNamespace = state.request("read_file", { path: "project/marker.txt" }); + (wrongNamespace.params as Record).namespace = "local_workspace"; + expect(responseValue(await state.coordinator.handle(wrongNamespace)).error).toContain("identity"); + expect(state.invokeCount()).toBe(0); + }); + + test("carries coordinator requests and executor results over the authenticated E2EE channel", async () => { + const state = fixture(); + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const cryptoDeviceId = randomUUID(); + const cryptoSessionId = randomUUID(); + const clientHandshake = RemoteControlClientHandshake.create({ + sessionId: cryptoSessionId, + deviceId: cryptoDeviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + accountPrivateKey: account.privateKey, + }); + const accepted = acceptRemoteControlClientHello(clientHandshake.hello, { + expectedSessionId: cryptoSessionId, + expectedDeviceId: cryptoDeviceId, + accountPublicKey: account.publicKey, + devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write", "workspace.exec"], + }); + const clientCipher = clientHandshake.complete(accepted.hello, device.publicKey); + + let client: EncryptedRemoteWorkspaceTransport; + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + client = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: `device-${cryptoDeviceId}`, + cipher: clientCipher, + sendCiphertext: value => endpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + const encryptedExecutor = new RemoteWorkspaceExecutor({ + deviceId: `device-${cryptoDeviceId}`, + roots: [{ id: "project-root", path: state.executorRoot }], + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: `device-${cryptoDeviceId}`, + sessionId: cryptoSessionId, + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write"], + cipher: accepted.cipher, + executor: encryptedExecutor, + sendCiphertext: value => client.receiveCiphertext(value), + }); + + const result = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "read_file", + arguments: { path: "project/marker.txt" }, + }); + expect(result).toMatchObject({ ok: true, value: { content: "executor-before" } }); + expect(JSON.stringify(result)).not.toContain(state.main); + client.close(); + }); + + test("fragments large writes and reads without raising the relay frame memory limit", async () => { + const state = fixture(); + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const cryptoDeviceId = randomUUID(); + const cryptoSessionId = randomUUID(); + const clientHandshake = RemoteControlClientHandshake.create({ + sessionId: cryptoSessionId, + deviceId: cryptoDeviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write"], + accountPrivateKey: account.privateKey, + }); + const accepted = acceptRemoteControlClientHello(clientHandshake.hello, { + expectedSessionId: cryptoSessionId, + expectedDeviceId: cryptoDeviceId, + accountPublicKey: account.publicKey, + devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write"], + }); + const clientCipher = clientHandshake.complete(accepted.hello, device.publicKey); + const content = "remote-fragment\n".repeat(10_000); + + let client: EncryptedRemoteWorkspaceTransport; + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + client = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: `device-${cryptoDeviceId}`, + cipher: clientCipher, + sendCiphertext: value => endpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: `device-${cryptoDeviceId}`, + sessionId: cryptoSessionId, + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write"], + cipher: accepted.cipher, + executor: new RemoteWorkspaceExecutor({ + deviceId: `device-${cryptoDeviceId}`, + roots: [{ id: "project-root", path: state.executorRoot }], + }), + sendCiphertext: value => client.receiveCiphertext(value), + }); + + const write = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "write_file", + arguments: { path: "project/large.txt", content, expectedSha256: null }, + }); + expect(write).toMatchObject({ ok: true, value: { bytes: Buffer.byteLength(content) } }); + const read = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "read_file", + arguments: { path: "project/large.txt", maxBytes: REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES }, + }); + expect(read).toMatchObject({ ok: true, value: { content } }); + client.close(); + endpoint.close(); + }); +}); diff --git a/tests/fake-codex-server.ts b/tests/fake-codex-server.ts index dc71863a1b..e77a763d45 100644 --- a/tests/fake-codex-server.ts +++ b/tests/fake-codex-server.ts @@ -177,6 +177,10 @@ async function handleMessage(msg: Record): Promise { return; } switch (method) { + case "config/read": { + respond(id, { config: {}, origins: {}, layers: null }); + return; + } case "thread/start": { if (script.rejectThreadStart) { respondError(id, script.rejectThreadStart); diff --git a/tests/fixtures/fake-claude-stream.ts b/tests/fixtures/fake-claude-stream.ts new file mode 100644 index 0000000000..3f1c7bb9f3 --- /dev/null +++ b/tests/fixtures/fake-claude-stream.ts @@ -0,0 +1,8 @@ +let input = ""; +for await (const chunk of Bun.stdin.stream()) input += new TextDecoder().decode(chunk); +const text = input.trim() ? `Hub answer: ${input.trim()}` : "Hub answer"; +process.stdout.write(`${JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text }] }, +})}\n`); +process.stdout.write(`${JSON.stringify({ type: "result", is_error: false, result: text })}\n`); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6e98e236f0..efaa7923a8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -886,6 +886,22 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", "remote-control-prototype.test.ts": "clients", "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", From a3182185f0e089504d72e5729e4674cf0dc07ea1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:09 +0900 Subject: [PATCH 02/21] style(remote): remove trailing blank line in runner --- src/remote-control/workspace-command-runner.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/remote-control/workspace-command-runner.ts b/src/remote-control/workspace-command-runner.ts index f9a3625caa..1b2fe74628 100644 --- a/src/remote-control/workspace-command-runner.ts +++ b/src/remote-control/workspace-command-runner.ts @@ -746,4 +746,3 @@ export function linuxRemoteWorkspaceCommandRunnerAvailable( availabilityCache.set(cacheKey, available); return available; } - From e2fc5ab0aaba94bfc17550253212a18be248341a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 16:19:14 +0900 Subject: [PATCH 03/21] fix(remote): bound runtime admission and retain cleanup ownership --- src/remote-control/workspace-coordinator.ts | 11 +- src/remote-control/workspace-executor.ts | 3 +- src/remote-control/workspace-sessions.ts | 291 ++++++++++-------- structure/remote-workspace.md | 4 +- .../clients/remote-workspace-sessions.test.ts | 95 +++++- .../remote-workspace-tool-bridge.test.ts | 31 ++ tests/clients/remote-workspace.test.ts | 33 ++ 7 files changed, 322 insertions(+), 146 deletions(-) diff --git a/src/remote-control/workspace-coordinator.ts b/src/remote-control/workspace-coordinator.ts index 684746622a..3d9534e014 100644 --- a/src/remote-control/workspace-coordinator.ts +++ b/src/remote-control/workspace-coordinator.ts @@ -52,12 +52,12 @@ function identifier(value: string, label: string): string { return value; } -function resultText(result: RemoteWorkspaceToolResult): string { +function boundedResult(result: RemoteWorkspaceToolResult): { text: string; success: boolean } { const encoded = JSON.stringify(result); if (Buffer.byteLength(encoded, "utf8") > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) { - return JSON.stringify({ ok: false, error: "remote workspace tool result exceeded the coordinator limit" }); + return { text: JSON.stringify({ ok: false, error: "remote workspace tool result exceeded the coordinator limit" }), success: false }; } - return encoded; + return { text: encoded, success: result.ok }; } export function remoteWorkspaceThreadStartParams(options: { @@ -219,11 +219,12 @@ export class RemoteWorkspaceCoordinator { } private response(id: string | number, result: RemoteWorkspaceToolResult): AppServerDynamicToolResponse { + const bounded = boundedResult(result); return { id, result: { - contentItems: [{ type: "inputText", text: resultText(result) }], - success: result.ok, + contentItems: [{ type: "inputText", text: bounded.text }], + success: bounded.success, }, }; } diff --git a/src/remote-control/workspace-executor.ts b/src/remote-control/workspace-executor.ts index 3312e8348f..e165cdc59f 100644 --- a/src/remote-control/workspace-executor.ts +++ b/src/remote-control/workspace-executor.ts @@ -188,7 +188,8 @@ function assertOpenedRegularFile(root: string, target: string, descriptor: numbe function readBoundedRegularFile(root: string, target: string, maximum: number): { body: Buffer; mode: number } { const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; - const descriptor = openSync(target, constants.O_RDONLY | noFollow); + const nonBlock = typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0; + const descriptor = openSync(target, constants.O_RDONLY | noFollow | nonBlock); try { const metadata = assertOpenedRegularFile(root, target, descriptor, maximum); const body = Buffer.alloc(metadata.size); diff --git a/src/remote-control/workspace-sessions.ts b/src/remote-control/workspace-sessions.ts index 775756200e..3d8d9a79e9 100644 --- a/src/remote-control/workspace-sessions.ts +++ b/src/remote-control/workspace-sessions.ts @@ -280,6 +280,7 @@ export class RemoteWorkspaceSessionFileStore implements RemoteWorkspaceSessionSt export class RemoteWorkspaceSessionService { private readonly sessions = new Map(); + private readonly runtimeReservations = new Map(); private readonly runtimes = new Map(); private sequence = 0; private availabilityCache: { at: number; value: RuntimeAvailability } | null = null; @@ -352,6 +353,21 @@ export class RemoteWorkspaceSessionService { return session ? this.publicSession(session) : null; } + private reserveRuntime(deviceId: string): () => void { + const live = [...this.sessions.values()].filter(session => session.handle !== null); + const pending = [...this.runtimeReservations.values()]; + if (live.length + pending.length >= MAX_LIVE_SESSIONS) { + throw new Error("remote workspace active session limit reached"); + } + if (live.filter(session => session.deviceId === deviceId).length + + pending.filter(device => device === deviceId).length >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + const reservation = randomUUID(); + this.runtimeReservations.set(reservation, deviceId); + return () => { this.runtimeReservations.delete(reservation); }; + } + async create(input: { profile: RemoteWorkspaceAgentProfile; deviceId: string; @@ -359,125 +375,122 @@ export class RemoteWorkspaceSessionService { accessMode?: RemoteWorkspaceAccessMode; }): Promise { this.pruneRetainedSessions(); - const liveCount = [...this.sessions.values()].filter(session => session.handle !== null).length; - if (liveCount >= MAX_LIVE_SESSIONS) throw new Error("remote workspace active session limit reached"); - const deviceLiveCount = [...this.sessions.values()].filter(session => ( - session.deviceId === input.deviceId && session.handle !== null - )).length; - if (deviceLiveCount >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { - throw new Error("remote workspace executor session limit reached"); - } - const factory = this.runtimes.get(input.profile); - if (!factory) throw new Error(`remote workspace ${input.profile} runtime is not installed on the hub`); - const available = await factory.available(); - if (!available.available) throw new Error(available.reason ?? `remote workspace ${input.profile} runtime is unavailable`); - const device = this.hub.listDevices().find(candidate => candidate.id === input.deviceId); - if (!device) throw new Error("remote workspace device not found"); - const root = device.roots.find(candidate => candidate.id === input.rootId); - if (!root) throw new Error("remote workspace root not found on the selected device"); - const connection = this.hub.connection(device.id); - if (!connection) throw new Error("remote workspace executor is offline"); - const id = randomUUID(); - const accessMode = parseAccessMode(input.accessMode ?? "read-only"); - const deviceCapabilities = parseRemoteWorkspaceCapabilities(device.capabilities); - const capabilities = accessMode === "read-only" - ? parseRemoteWorkspaceCapabilities(["workspace.read"]) - : deviceCapabilities; - const tools = remoteWorkspaceToolsForCapabilities(capabilities); - const connectionCapabilities = connection.capabilities(); - if (capabilities.some(capability => !connectionCapabilities.includes(capability))) { - throw new Error("remote workspace executor capability advertisement is stale; refresh and try again"); - } - const timestamp = new Date(this.now()).toISOString(); - const session: LiveSession = { - id, - profile: input.profile, - accessMode, - deviceId: device.id, - deviceName: device.name, - rootId: root.id, - rootLabel: root.label, - capabilities, - tools, - threadId: null, - resumable: false, - status: "starting", - createdAt: timestamp, - updatedAt: timestamp, - events: [], - handle: null, - unregister: null, - closeTransport: null, - operation: Promise.resolve(), - stopOperation: null, - remoteTransport: null, - turnActive: false, - }; - this.sessions.set(id, session); - this.emit(session, "status", `Starting ${input.profile} on ${device.name}/${root.label}`); + const releaseRuntime = this.reserveRuntime(input.deviceId); try { - this.persist(); - } catch (error) { - this.sessions.delete(id); - throw error; - } - try { - session.closeTransport = () => connection.closeSession(id); - const transport = await connection.openSession({ sessionId: id, rootId: root.id, profile: input.profile, capabilities }); - if (session.stopOperation) { - await session.closeTransport().catch(() => {}); - session.closeTransport = null; - throw new Error("remote workspace session was stopped while starting"); + const factory = this.runtimes.get(input.profile); + if (!factory) throw new Error(`remote workspace ${input.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${input.profile} runtime is unavailable`); + const device = this.hub.listDevices().find(candidate => candidate.id === input.deviceId); + if (!device) throw new Error("remote workspace device not found"); + const root = device.roots.find(candidate => candidate.id === input.rootId); + if (!root) throw new Error("remote workspace root not found on the selected device"); + const connection = this.hub.connection(device.id); + if (!connection) throw new Error("remote workspace executor is offline"); + const id = randomUUID(); + const accessMode = parseAccessMode(input.accessMode ?? "read-only"); + const deviceCapabilities = parseRemoteWorkspaceCapabilities(device.capabilities); + const capabilities = accessMode === "read-only" + ? parseRemoteWorkspaceCapabilities(["workspace.read"]) + : deviceCapabilities; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + const connectionCapabilities = connection.capabilities(); + if (capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capability advertisement is stale; refresh and try again"); } - const remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); - session.remoteTransport = remoteTransport; - const coordinator = new RemoteWorkspaceCoordinator(remoteTransport); - const handle = await factory.start({ - sessionId: id, + const timestamp = new Date(this.now()).toISOString(); + const session: LiveSession = { + id, + profile: input.profile, + accessMode, deviceId: device.id, deviceName: device.name, rootId: root.id, rootLabel: root.label, capabilities, tools, - coordinator, - emit: (type, text) => this.emit(session, type, text), - }); - if (session.stopOperation) { - await handle.stop().catch(() => {}); - throw new Error("remote workspace session was stopped while starting"); + threadId: null, + resumable: false, + status: "starting", + createdAt: timestamp, + updatedAt: timestamp, + events: [], + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }; + this.sessions.set(id, session); + this.emit(session, "status", `Starting ${input.profile} on ${device.name}/${root.label}`); + try { + this.persist(); + } catch (error) { + this.sessions.delete(id); + throw error; } - session.threadId = handle.threadId; - session.resumable = handle.canResume?.() ?? true; - session.handle = handle; - session.unregister = coordinator.register({ - sessionId: id, - threadId: handle.threadId, - executorDeviceId: device.id, - executorName: device.name, - rootId: root.id, - capabilities, - tools, - }); - this.status(session, "ready", `${input.profile} is ready on ${device.name}/${root.label}`); - return this.publicSession(session); - } catch (error) { - let reported = error; - if (session.status !== "stopped") { - try { - this.status(session, "failed", error instanceof Error ? error.message : "remote workspace session failed to start"); - } catch (persistenceError) { - reported = persistenceError; + try { + session.closeTransport = () => connection.closeSession(id); + const transport = await connection.openSession({ sessionId: id, rootId: root.id, profile: input.profile, capabilities }); + if (session.stopOperation) { + await session.closeTransport().catch(() => {}); + session.closeTransport = null; + throw new Error("remote workspace session was stopped while starting"); } + const remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.remoteTransport = remoteTransport; + const coordinator = new RemoteWorkspaceCoordinator(remoteTransport); + const handle = await factory.start({ + sessionId: id, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + if (session.stopOperation) { + await handle.stop().catch(() => {}); + throw new Error("remote workspace session was stopped while starting"); + } + session.threadId = handle.threadId; + session.resumable = handle.canResume?.() ?? true; + session.handle = handle; + session.unregister = coordinator.register({ + sessionId: id, + threadId: handle.threadId, + executorDeviceId: device.id, + executorName: device.name, + rootId: root.id, + capabilities, + tools, + }); + this.status(session, "ready", `${input.profile} is ready on ${device.name}/${root.label}`); + return this.publicSession(session); + } catch (error) { + let reported = error; + if (session.status !== "stopped") { + try { + this.status(session, "failed", error instanceof Error ? error.message : "remote workspace session failed to start"); + } catch (persistenceError) { + reported = persistenceError; + } + } + session.unregister?.(); + session.unregister = null; + await session.handle?.stop().catch(() => {}); + session.handle = null; + await session.closeTransport?.().catch(() => {}); + session.closeTransport = null; + session.remoteTransport = null; + throw reported; } - session.unregister?.(); - session.unregister = null; - await session.handle?.stop().catch(() => {}); - session.handle = null; - await session.closeTransport?.().catch(() => {}); - session.closeTransport = null; - session.remoteTransport = null; - throw reported; + } finally { + releaseRuntime(); } } @@ -531,6 +544,7 @@ export class RemoteWorkspaceSessionService { await runRemoteWorkspaceCleanupSteps([ async () => { if (handle) await handle.stop(); }, () => activeOperation.catch(() => {}), + async () => { if (session.handle && session.handle !== handle) await session.handle.stop(); }, () => { session.unregister?.(); session.unregister = null; }, async () => { if (session.closeTransport) await session.closeTransport(); }, () => { @@ -569,6 +583,7 @@ export class RemoteWorkspaceSessionService { await runRemoteWorkspaceCleanupSteps([ async () => { if (handle) await handle.stop(); }, () => activeOperation.catch(() => {}), + async () => { if (session.handle && session.handle !== handle) await session.handle.stop(); }, () => { session.unregister?.(); session.unregister = null; }, async () => { if (session.closeTransport) await session.closeTransport(); }, () => { @@ -660,38 +675,48 @@ export class RemoteWorkspaceSessionService { } const factory = this.runtimes.get(session.profile); if (!factory) throw new Error(`remote workspace ${session.profile} runtime is not installed on the hub`); - const available = await factory.available(); - if (!available.available) throw new Error(available.reason ?? `remote workspace ${session.profile} runtime is unavailable`); - const coordinator = new RemoteWorkspaceCoordinator(session.remoteTransport); - const handle = await factory.start({ - sessionId: session.id, - deviceId: session.deviceId, - deviceName: session.deviceName, - rootId: session.rootId, - rootLabel: session.rootLabel, - capabilities: [...session.capabilities], - tools: [...session.tools], - resumeThreadId: session.threadId, - coordinator, - emit: (type, text) => this.emit(session, type, text), - }); + const releaseRuntime = this.reserveRuntime(session.deviceId); try { - session.unregister = coordinator.register({ + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${session.profile} runtime is unavailable`); + const coordinator = new RemoteWorkspaceCoordinator(session.remoteTransport); + const handle = await factory.start({ sessionId: session.id, - threadId: handle.threadId, - executorDeviceId: session.deviceId, - executorName: session.deviceName, + deviceId: session.deviceId, + deviceName: session.deviceName, rootId: session.rootId, + rootLabel: session.rootLabel, capabilities: [...session.capabilities], tools: [...session.tools], + resumeThreadId: session.threadId, + coordinator, + emit: (type, text) => this.emit(session, type, text), }); - } catch (error) { - await handle.stop().catch(() => {}); - throw error; + if (session.stopOperation) { + // The stopping operation waits for this resume before reclaiming its late handle. + session.handle = handle; + throw new Error("remote workspace session is stopping"); + } + try { + session.unregister = coordinator.register({ + sessionId: session.id, + threadId: handle.threadId, + executorDeviceId: session.deviceId, + executorName: session.deviceName, + rootId: session.rootId, + capabilities: [...session.capabilities], + tools: [...session.tools], + }); + } catch (error) { + await handle.stop().catch(() => {}); + throw error; + } + session.threadId = handle.threadId; + session.handle = handle; + this.status(session, "ready", `${session.profile} resumed on ${session.deviceName}/${session.rootLabel}`); + } finally { + releaseRuntime(); } - session.threadId = handle.threadId; - session.handle = handle; - this.status(session, "ready", `${session.profile} resumed on ${session.deviceName}/${session.rootLabel}`); } private refreshOfflineStates(): void { diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index cb18d70589..787a0ca288 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -6,7 +6,7 @@ `src/remote-control/workspace-agent-connection.ts` intersects presence with enrollment authority and negotiates explicit session grants. `src/remote-control/workspace-rpc.ts` snapshots session/device/root/capabilities and rejects mismatches before invoking the executor. The paired Hub is trusted to select an approved root over authenticated WSS; workspace control traffic is not an untrusted opaque relay protocol. -`src/remote-control/workspace-executor.ts` checks approved root identity, relative paths, file size and write preconditions. Its optional command runner lives in `src/remote-control/workspace-command-runner.ts`. Linux uses bubblewrap outside writable workspace roots and checks executable/parent permissions before invocation. The official Windows and macOS native helpers refuse commands; file tools remain independent of command availability. +`src/remote-control/workspace-executor.ts` checks approved root identity, relative paths, file size and write preconditions. File reads and write preconditions open descriptors nonblocking before verifying regular-file identity, so special files cannot wait for a peer during open. Its optional command runner lives in `src/remote-control/workspace-command-runner.ts`. Linux uses bubblewrap outside writable workspace roots and checks executable/parent permissions before invocation. The official Windows and macOS native helpers refuse commands; file tools remain independent of command availability. `src/remote-control/workspace-hub.ts`, `src/remote-control/workspace-device.ts` and `src/remote-control/workspace-sessions.ts` own separate persisted state. `src/remote-control/workspace-secret-store.ts` requires private permissions and rejects access failures rather than treating them as first-run absence. Publication reuses `src/config/atomic-write.ts`; workspace file publication uses the remote-workspace publisher in `src/lib/windows-atomic-replace.ts`. @@ -15,3 +15,5 @@ The optional terminal prototype in `src/remote-control/host.ts` invokes only a caller-supplied factory after authenticated traffic. `src/remote-control/relay.ts` routes opaque prototype envelopes after caller authorization. Neither is a production terminal service. Regression coverage lives in `tests/clients/remote-workspace-session-binding.test.ts`, `tests/clients/remote-workspace-secret-store.test.ts` and the adjacent protocol, agent-wire, device, hub, sessions and command-runner tests. Real CLI and native confinement tests require their explicit environments; generic suite success does not certify those paths. Windows command support remains unavailable pending a verified lifecycle owner. + +Hub runtime admission counts pending create/resume starts as well as live handles against global and per-device limits; every outcome releases its reservation. Stop and shutdown reclaim late resumed handles before clearing ownership. Coordinator result size limits normalize both response text and success, so bridge and MCP callers receive consistent errors. diff --git a/tests/clients/remote-workspace-sessions.test.ts b/tests/clients/remote-workspace-sessions.test.ts index dc0be8ed16..45c6ed4f13 100644 --- a/tests/clients/remote-workspace-sessions.test.ts +++ b/tests/clients/remote-workspace-sessions.test.ts @@ -47,6 +47,9 @@ function deferred(): { function createHarness(options: { promptGate?: ReturnType; startGate?: ReturnType; + availableGate?: ReturnType; + onAvailable?: () => void; + deviceIds?: string[]; onStart?: () => void; lazyResumable?: boolean; eventsAtStart?: number; @@ -54,6 +57,7 @@ function createHarness(options: { stopError?: Error; closeError?: Error; } = {}): Harness { + const deviceIds = options.deviceIds ?? [DEVICE_ID]; let online = true; let stops = 0; let opens = 0; @@ -68,7 +72,7 @@ function createHarness(options: { const state = { online: true }; transportStates.push(state); return { - isOnline: deviceId => state.online && deviceId === DEVICE_ID, + isOnline: deviceId => state.online && deviceIds.includes(deviceId), async invoke(request) { if (!state.online) throw new Error("transport offline"); invocations.push({ tool: request.tool, rootId: request.rootId }); @@ -85,8 +89,8 @@ function createHarness(options: { }, }; const hub = { - listDevices: () => [{ - id: DEVICE_ID, + listDevices: () => deviceIds.map(id => ({ + id, name: "Build box", platform: "linux", capabilities: ["workspace.read", "workspace.write", "workspace.exec"], @@ -94,13 +98,17 @@ function createHarness(options: { online, createdAt: "2026-01-01T00:00:00.000Z", lastSeenAt: null, - }], - connection: (deviceId: string) => online && deviceId === DEVICE_ID ? connection : null, + })), + connection: (deviceId: string) => online && deviceIds.includes(deviceId) ? connection : null, } as unknown as RemoteWorkspaceHub; const factory: RemoteWorkspaceRuntimeFactory = { profile: "codex", - async available() { return { available: true, version: "test" }; }, + async available() { + options.onAvailable?.(); + if (options.availableGate) await options.availableGate.promise; + return { available: true, version: "test" }; + }, async start({ coordinator, emit, resumeThreadId }) { options.onStart?.(); if (options.startGate) await options.startGate.promise; @@ -156,6 +164,81 @@ function createHarness(options: { } describe("Remote Workspace session service", () => { + for (const operation of ["create", "resume"] as const) { + for (const scope of ["device", "global"] as const) { + test(`${operation} reserves ${scope} capacity before awaiting runtime availability`, async () => { + const deviceIds = scope === "device" ? [DEVICE_ID] : [DEVICE_ID, "device-2", "device-3"]; + const limit = scope === "device" ? 4 : 8; + const store = new MemorySessionStore(); + if (operation === "resume") { + const seed = createHarness({ sessionStore: store }); + await seed.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await seed.service.shutdown(); + const template = store.state!.sessions[0]!; + store.state!.sessions = Array.from({ length: limit + 1 }, (_, index) => ({ + ...structuredClone(template), id: `resume-${index}`, threadId: `thread-${index}`, + deviceId: deviceIds[index % deviceIds.length]!, + })); + } + const gate = deferred(); + const entered = deferred(); + let availableCalls = 0; + const harness = createHarness({ + deviceIds, sessionStore: store, availableGate: gate, + onAvailable: () => { if (++availableCalls === limit) entered.resolve(); }, + }); + const call = (index: number) => operation === "create" + ? harness.service.create({ profile: "codex", deviceId: deviceIds[index % deviceIds.length]!, rootId: ROOT_ID }) + : harness.service.prompt(`resume-${index}`, "Resume"); + const admitted = Array.from({ length: limit }, (_, index) => call(index)); + try { + await entered.promise; + await expect(call(limit)).rejects.toThrow(scope === "device" ? "executor session limit" : "active session limit"); + expect(availableCalls).toBe(limit); + gate.resolve(); + await Promise.all(admitted); + expect(harness.runtimeStarts()).toHaveLength(limit); + } finally { + gate.resolve(); + await Promise.allSettled(admitted); + await harness.service.stopAll(); + } + }); + } + } + + test("failed availability releases every pending runtime reservation", async () => { + const gate = deferred(); + const harness = createHarness({ availableGate: gate }); + const calls = Array.from({ length: 4 }, () => harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID })); + const results = Promise.allSettled(calls); + await expect(harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID })).rejects.toThrow("executor session limit"); + gate.reject(new Error("availability probe failed")); + expect((await results).every(result => result.status === "rejected")).toBe(true); + await expect(harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID })).rejects.toThrow("availability probe failed"); + expect(harness.runtimeStarts()).toHaveLength(0); + }); + + test.each(["stop", "shutdown"] as const)("%s owns a runtime returned after resume cancellation", async action => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await first.service.shutdown(); + const gate = deferred(); + const entered = deferred(); + const resumed = createHarness({ sessionStore: store, startGate: gate, onStart: entered.resolve }); + const prompt = resumed.service.prompt(created.id, "Resume").then(() => "resolved", () => "rejected"); + await entered.promise; + const stopping = action === "stop" ? resumed.service.stop(created.id) : resumed.service.shutdown(); + gate.resolve(); + await stopping; + expect(await prompt).toBe("rejected"); + expect(resumed.stopCalls()).toBe(1); + expect(resumed.closedSessions).toEqual([created.id]); + expect(resumed.invocations).toEqual([]); + expect(resumed.service.get(created.id)?.status).toBe(action === "stop" ? "stopped" : "waiting_for_executor"); + }); + test("binds one model session to the selected executor root", async () => { const harness = createHarness(); const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); diff --git a/tests/clients/remote-workspace-tool-bridge.test.ts b/tests/clients/remote-workspace-tool-bridge.test.ts index 48c24634b7..2bfd824c15 100644 --- a/tests/clients/remote-workspace-tool-bridge.test.ts +++ b/tests/clients/remote-workspace-tool-bridge.test.ts @@ -1,5 +1,36 @@ import { expect, test } from "bun:test"; import { RemoteWorkspaceCoordinator, startRemoteWorkspaceToolBridge } from "../../src/remote-control"; +import { REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES } from "../../src/remote-control/workspace-tools"; + +test("bridge and MCP report bounded result failures with matching status", async () => { + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true, value: "x".repeat(REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) }; }, + }); + coordinator.register({ + sessionId: "session-limit", threadId: "thread-limit", executorDeviceId: "device-limit", + executorName: "Computer", rootId: "root-limit", capabilities: ["workspace.read"], tools: ["read_file"], + }); + const bridge = startRemoteWorkspaceToolBridge({ coordinator, threadId: "thread-limit", tools: ["read_file"] }); + const headers = { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }; + try { + const direct = await fetch(`${bridge.url}/invoke`, { + method: "POST", headers, body: JSON.stringify({ tool: "read_file", arguments: { path: "large.txt" } }), + }); + const result = await direct.json() as { success: boolean; text: string }; + expect(result.success).toBe(false); + expect(JSON.parse(result.text)).toEqual({ ok: false, error: "remote workspace tool result exceeded the coordinator limit" }); + const mcp = await fetch(`${bridge.url}/mcp`, { + method: "POST", headers, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "read_file", arguments: { path: "large.txt" } } }), + }); + const rpc = await mcp.json() as { result: { isError: boolean; content: Array<{ text: string }> } }; + expect(rpc.result.isError).toBe(true); + expect(JSON.parse(rpc.result.content[0]!.text)).toEqual(JSON.parse(result.text)); + } finally { + await bridge.stop(); + } +}); test("loopback CLI bridge accepts only its bearer and delegates to the E2EE coordinator", async () => { const invocations: string[] = []; diff --git a/tests/clients/remote-workspace.test.ts b/tests/clients/remote-workspace.test.ts index 6754d5b8ff..feeb2b08ce 100644 --- a/tests/clients/remote-workspace.test.ts +++ b/tests/clients/remote-workspace.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash, randomUUID } from "node:crypto"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, linkSync, @@ -30,6 +31,7 @@ import { type RemoteWorkspaceTransport, } from "../../src/remote-control"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; const roots: string[] = []; @@ -136,6 +138,37 @@ function responseValue(response: Awaited { + test.skipIf(process.platform === "win32")("refuses FIFO read and write preconditions without blocking", () => { + const state = fixture(); + execFileSync("mkfifo", [join(state.executorRoot, "pipe")]); + const child = spawnSync(process.execPath, ["--eval", ` + import { RemoteWorkspaceExecutor } from ${JSON.stringify(repoPath("src/remote-control/workspace-executor.ts"))}; + const executor = new RemoteWorkspaceExecutor({ deviceId: "fifo-device", roots: [{ id: "root", path: ${JSON.stringify(state.executorRoot)} }] }); + const results = []; + for (const tool of ["read_file", "write_file"]) { + results.push(await executor.invoke({ requestId: tool, sessionId: "session", executorDeviceId: "fifo-device", rootId: "root", tool, + arguments: tool === "read_file" ? { path: "pipe" } : { path: "pipe", content: "x", expectedSha256: null } })); + } + console.log(JSON.stringify(results)); + `], { encoding: "utf8", timeout: 5_000 }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(0); + const results = JSON.parse(child.stdout) as RemoteWorkspaceToolResult[]; + expect(results).toHaveLength(2); + for (const result of results) { + expect(result.ok).toBe(false); + expect(result.error).toContain("file identity"); + } + }); + + test("marks an oversized encoded tool result as failed consistently", async () => { + const state = fixture(); + writeFileSync(join(state.executorRoot, "project", "large.txt"), "x".repeat(REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES)); + const response = await state.coordinator.handle(state.request("read_file", { path: "project/large.txt" })); + expect(response.result.success).toBe(false); + expect(responseValue(response)).toEqual({ ok: false, error: "remote workspace tool result exceeded the coordinator limit" }); + }); + test("publishes only the namespaced client-executed tools and isolates the coordinator cwd", () => { expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS).toHaveLength(1); expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].name).toBe(REMOTE_WORKSPACE_TOOL_NAMESPACE); From 31a96b27b406a9edd595eeaa6ac2055e03aa4418 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:04:16 +0900 Subject: [PATCH 04/21] docs(devlog): diff-level roadmap for the unimplemented trio stack (#4191, #3898, #4311) --- .../000_plan.md | 108 +++++++++++++++++ .../010_l1_ws_stage_instrumentation.md | 91 +++++++++++++++ .../020_l2_native_main_reauth_api.md | 109 ++++++++++++++++++ .../030_l3_main_card_relogin_ui.md | 81 +++++++++++++ .../040_l4_native_paginated_writer.md | 89 ++++++++++++++ 5 files changed, 478 insertions(+) create mode 100644 devlog/_plan/260912_unimplemented_trio_stack/000_plan.md create mode 100644 devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md create mode 100644 devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md create mode 100644 devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md create mode 100644 devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md diff --git a/devlog/_plan/260912_unimplemented_trio_stack/000_plan.md b/devlog/_plan/260912_unimplemented_trio_stack/000_plan.md new file mode 100644 index 0000000000..611dbac17c --- /dev/null +++ b/devlog/_plan/260912_unimplemented_trio_stack/000_plan.md @@ -0,0 +1,108 @@ +# Trio stack: WS stage instrumentation, native-main device reauth, paginated history recovery + +Unit 260912_unimplemented_trio_stack. HOTL loop goalplan slug +`implement-three-unimplemented-opencodex-backlog` (session +01a09616-38e6-72e0-b5bf-99eb10ce58a6). Bottom-up manual stacked-PR chain +against `dev` (lidge-jun/opencodex). No merges, no GitHub native-stack +registration. Every push uses `git push --no-verify`; local product +suite/build/typecheck/install NOT RUN; each PR relies on hosted exact-head +CI and says so in its Verification section. + +## Objective + +Close the three implementable unimplemented backlog items identified in the +2026-09-12 inventory: + +1. Issue #4191 — WS 1006 / response-prelude-timeout diagnosis has no durable + content-free evidence. Ship stage instrumentation only; no fix, no + auto-retransmit fallback. +2. Issue #3898 — headless hub cannot reauth native `__main__` because + deviceauth is pool-only. Ship the native-main device reauth API/CLI, then + the main-card Re-login GUI on top of it. +3. Issue #4311 residual — paginated history still has no writer support and + no recovery for ordinal-corrupted rollouts. Ship the offline recovery + tool with preservation proofs; live writes stay refused. + +## Sources + +- #4191 body: content-free stage diagnostics list; A/B evidence that the + failure is proxy-path-specific; related #2471, #4083, #3976. +- #3898 body: suggested contract (reuse OpenAI deviceauth, persist to native + main slot, keep `__main__` out of `/api/codex-auth/login`, no codex + binary/keyring requirement, secret-free DTOs). +- #4311 body: ordinal-0 clone defect (now guarded), incident recovery by + ordinal-digit rewrite while Codex was closed, prohibition of N+1 guessing + and live rewrites. +- devlog/_plan/260912_accounts/080_reauth_api.md and 090_reauth_ui.md — + accepted Accounts-lane design drafts this unit adopts for L2/L3. +- devlog/_plan/260912_history_containment/ — refusal contract this unit + must preserve. + +## Constraints (hard) + +- L1 logs stay content-free: create-frame byte count, send completion, + close code (numeric), elapsed/first-frame timings, frame counters, OCX and + Bun versions. No conversation text, no headers, no close-reason text, no + account identifiers in the new records. +- L1 adds no `responseCommitted === false` auto-retransmit: turn + duplication risk is documented in #4191 discussion. +- L2 keeps `/api/codex-auth/login` rejecting `__main__` (400), keeps pool + Add/Re-login semantics unchanged, and must not route the native flow + through `startLoginFlow("chatgpt")` (scratch-slot overwrite + pool + singleflight collision, src/oauth/index.ts:1899-1973). +- L2 commit to `$CODEX_HOME/auth.json` only under an exclusive claim with + path/hash/inode assertion and same-identity verification; never retains + old identity token beside new credentials; fails safe + (`native_main_unavailable`) when no fence can be established. +- L3 must not reuse `AddCodexAccountModal` or `openReauth("__main__")`; + dedicated hook and dedicated backend namespace only (the pool login route + rejects `__main__` at src/codex/account-id.ts:15-20). +- L4 must not invent last-ordinal+1, must not write to a live rollout, must + not weaken `history_paginated_requires_native_writer` refusal in + preflight/apply/restore paths, and must preserve every non-ordinal byte. +- All layers: focused tests land with the layer; every new test file gets + layout.json `explicit` + tests/fixtures/test-layout-expected.json + entries in the same PR. +- structure/ ownership: any owned source area changed by a layer updates + its structure doc in the same PR (structure/AGENTS.md). + +## Work-phase map (dependency order = stack order, bottom first) + +| WP | Layer | Branch | PR base | Decade doc | +|----|-------|--------|---------|------------| +| wp2 | L1 #4191 WS stage instrumentation | codex/260912-ws-stage-instrumentation | dev | 010 | +| wp3 | L2 #3898 native-main reauth API/CLI | codex/260912-native-main-reauth-api | wp2 branch | 020 | +| wp4 | L3 #3898 main-card Re-login GUI | codex/260912-native-main-reauth-ui | wp3 branch | 030 | +| wp5 | L4 #4311 paginated history recovery | codex/260912-native-paginated-writer | wp4 branch | 040 | + +Dependency logic: L2 and L3 are one feature split at the API/UI seam +(030 depends on 020's route). L1 is independent but touches the shared +request-log schema, so it sits at the bottom where later layers rebase onto +a stable log contract. L4 is the riskiest (user data) and rides on top so +lower layers can land without waiting for it. There is no functional +dependency between L1/L2 and L4; the chain exists to serialize review. + +## Verification policy per layer + +- Red-first focused tests, then implementation, then green. +- `bun test tests//` (or `cd gui && bun test tests/` + for L3) fresh at C, captured via `cxc receipt test`. +- Full local suite/build/typecheck/install: NOT RUN (standing rule); each + PR Verification section labels this and names the hosted exact-head CI + run as the integration evidence. Cancelled/skipped CI never counts as + passing. +- L4 additionally: privacy-relevant paths (rollout bytes) stay in tests + with synthetic fixtures only. + +## Open decisions carried to audit + +1. L2 hub fence: on a headless hub the native owner never activates + (src/server/index.ts:1026-1046 + src/codex/desired-state.ts:79-81). + 020 resolves how commit fencing works there without weakening the + exclusive-claim contract; audit must confirm the chosen fence. +2. L4 scope: true live-write support needs a Codex-owned writer API that + does not exist in this tree. This unit ships the offline recovery tool + and keeps live refusal; the PR description must say so explicitly. +3. L3 screenshot evidence: obtained from hosted CI artifacts or recorded + exemption, per repo PR gate (title/body mentions of gui need a + screenshot). diff --git a/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md new file mode 100644 index 0000000000..c90d1c4af0 --- /dev/null +++ b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md @@ -0,0 +1,91 @@ +# L1: content-free Codex WS upstream stage instrumentation (#4191) + +Class C2. Stack bottom, base `dev`. Branch +`codex/260912-ws-stage-instrumentation`. Diagnosis instrumentation only: +no behavior change to success paths, no retry/fallback change. + +## Problem + +#4191 fails as WS 1006 or "response prelude timed out" only through the +proxy. The content-free stage record already exists as +`CodexWsFailureStage` (src/server/responses/codex-ws-wire.ts:100-144) and +`failureStage()` (src/server/responses/codex-ws-exchange.ts:148-159), but +it is only interpolated into failure message strings. Durable logs keep +neither the message nor a typed code: the eager relay collapses stream +errors to `upstream_reset` + `streamAborted` +(codex-ws-wire.ts:225-228; src/server/relay.ts; src/server/request-log.ts). +Operators therefore cannot distinguish create-frame size, send failure, +prelude stall, and upstream close from each other after the fact. + +## Contract (from #4191 + maintainer bounds) + +Record, per upstream exchange: create-frame bytes, send completion, +close code (numeric only), elapsed ms and first-frame ms, frame counters +(upstream/control/relayed, pings/pongs), pool reuse boolean, OCX version, +Bun runtime version, originator only when already present. Never record +conversation text, headers, close-reason text, or account identifiers. +No `responseCommitted === false` auto-retransmit fallback. + +## Changes + +MODIFY `src/server/responses/codex-ws-wire.ts` +- Extend `CodexWsFailureStage` with `closeCode: number | null` and + `reused: boolean`; extend the privacy comment to state the new record + is numeric/boolean only and close-reason text stays out of durable logs. +- No renderer change required beyond carrying the new fields. + +MODIFY `src/server/responses/codex-ws-exchange.ts` +- Snapshot `failureStage()` once at each settle site — `armSilence` + (206), connect-deadline `cancelExchange` (246-256), `onClose` + (415-426), `onError` (429-437) — and hand the snapshot plus + `closeCode`/`reused` to the request-log context through a new + `recordCodexWsStage` sink on the log context (below). +- On the happy path record the same snapshot once at `commitResponse` + (160-170) so successful exchanges also carry first-frame timing. +- `sent` keeps its current meaning ("send returned"); the record must not + claim kernel flush. No control-flow change at any site. + +MODIFY `src/server/request-log.ts` +- Add optional `codexWsStage` to `RequestLogContext` (56) holding the + snapshot fields above plus `ocxVersion` and `bunVersion`; expose + `recordCodexWsStage(stage)` next to `recordFirstOutput` (470-481). +- Include the field in the serialized attempt/log payload so `/api/logs` + keeps it. Content-free fields only; the payload gains no strings beyond + semver/version values. + +MODIFY `src/server/relay.ts` +- Where stream errors collapse to `upstream_reset`, preserve + `codexWsStage` on the attempt record (the collapse stays; the typed + stage rides alongside). + +MODIFY `src/server/responses/codex-ws-session.ts` (only if needed) +- Expose `reused` for the stage snapshot (already on the session, 21); + no pooling change. + +Versions: OCX `VERSION` is imported the same way +src/server/management-api.ts:87-93 does; Bun version via +`currentBunRuntimeIdentity()` (src/server/responses/ws-upstream.ts:26). +Client CLI version is not on the handshake (`user-agent` is not in +FORWARD_HEADERS, src/adapters/openai-responses.ts:43-61) — record +`originator` only when already present, and document the limitation in +the PR. + +## Tests (red-first) + +MODIFY `tests/responses/ws-failure-stage.test.ts` +- Stage record carries closeCode/reused/versions; reason text never + appears in the durable record. +MODIFY `tests/responses/ws-upstream.test.ts` +- 1006 path and prelude-timeout path persist `codexWsStage` on the log + attempt; happy path records once at commit. +MODIFY or NEW `tests/server/request-log*.test.ts` (per layout.json domain +for request-log; add layout.json explicit + expected-fixture entries if +NEW) +- Serialization keeps the stage; payload stays free of reason/body/header + strings. + +## Out of scope + +Any WS behavior fix, SSE-fallback policy change, prelude-timeout tuning +(#3976/#4083), pool policy, inbound client-socket metrics +(codexWebSocketAdmissionMetrics is the client side — do not touch). diff --git a/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md new file mode 100644 index 0000000000..dbd9aed6a5 --- /dev/null +++ b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md @@ -0,0 +1,109 @@ +# L2: native-main device reauth API/CLI (#3898) + +Class C4 (auth boundary). Stack layer 2, base the L1 branch. Branch +`codex/260912-native-main-reauth-api`. Adopts the accepted Accounts-lane +design devlog/_plan/260912_accounts/080_reauth_api.md; this doc is the +diff-level revalidation of that draft against current `dev` plus the +deltas the code map surfaced. 080 remains the contract source; anything +here overrides stale details of 080, not its invariants. + +## Problem + +Headless hub (`runtimeRole=hub`, `oauthOpenBrowser: false`, no codex +binary, no keyring) cannot reauth native `__main__`: +`/api/codex-auth/login` is pool-only and rejects `__main__` +(src/codex/account-id.ts:15-20; src/codex/auth-api.ts:2733-2748); +`ocx account main add` requires official `codex login` + OS keyring +(src/cli/account-main.ts:73-90,214-260). WHAM `token_revoked` on the main +grant is then unrecoverable from the hub. + +## Changes (080 contract, revalidated) + +MODIFY `src/oauth/chatgpt-device.ts` +- Factor the private grant exchange so a native-only result retains the + raw validated token payload: new `loginChatGPTNativeDevice` returns + `{ credential, idToken }` in-process only; reject missing + access/refresh/id token or mismatched account identity. Existing + `loginChatGPTDevice` behavior unchanged (still projects + OAuthCredentials, no id_token). +- Delta from 080 (explorer-confirmed gap): the usercode/poll/token fetches + (84-90, 121-127, 152-163) have no per-request timeout — only the 15-min + poll deadline and abort. Add a service-owned per-fetch deadline (fetch + + body) so a stuck TCP cannot hold the flow until TTL. This is the Kuhn + blocker "poll timer does not bound fetch/body deadlines". + +MODIFY `src/codex/main-account.ts` +- New `beginNativeMainReauth`: captures the existing + `MainAuthJsonCredential` snapshot (103-136) into a private closure; + returned commit accepts complete native device tokens and, only after + human authorization, acquires `withNativeMainExclusiveClaim` + (src/codex/native-main-claim.ts:167), rechecks recovery/admission fence, + asserts original path/hash/inode before atomic rename, requires same + chatgpt account identity, writes access+refresh+id token + account_id + together, advances the mutation epoch, and reconciles runtime/quota + state. Old identity token is never retained beside new credentials. No + claim held during human polling. + +NEW `src/codex/main-device-reauth.ts` +- One process-owned active flow (opaque UUID, AbortController, bounded + terminal retention 5 min, grant deadline 15 min). Start/status/cancel + return only flowId, status, verificationUrl, deviceCode, and closed safe + failure codes per the 080 `MainDeviceReauthStatus` union. Injectable + login/commit dependencies for tests. Superseded/cancelled completions + never publish. No tokens/emails/raw account ids in DTO/log/error. +- Dedicated abort controller and direct `loginChatGPTNativeDevice` call: + MUST NOT use `startLoginFlow("chatgpt")` (would overwrite the chatgpt + scratch slot and 409 against pool logins, src/oauth/index.ts:1856-1973). + +NEW `src/codex/main-device-reauth-api.ts` +- `POST/GET/DELETE /api/codex-auth/main/reauth-device` with exact opaque + flow query, strict request keys, safe 400/404/409. Registered at the + management dispatch boundary (src/server/management-api.ts:385-407 + region); existing management auth/origin/session controls stay + authoritative. No CLI direct account-file write. + +MODIFY `src/cli/account-main.ts` +- `ocx account main reauth --device [--no-wait]`, + `reauth status --flow `, `reauth cancel --flow ` via the + management API; reject extra args before start. Register capability/help; + regenerate skill surface with `bun run skill:surface` if the capability + registry changes (tests/ci-workflows/skill-ocx.test.ts gates this). + +## Hub fence resolution (open decision 1, resolved here for audit) + +On a headless hub the native owner lifecycle is a no-op +(src/server/index.ts:1026-1046 binds the no-op when +`shouldSyncCodexOnStart` is false via src/codex/desired-state.ts:79-81). +The reauth commit therefore MUST NOT depend on owner activation and MUST +NOT widen `shouldSyncCodexOnStart` (that gate covers client-config sync, +not credential rewrite). Commit fencing on any runtime role: +`withNativeMainExclusiveClaim` + in-process admission fence + +path/hash/inode assertion, exactly as on workstations. B must verify +`withNativeMainExclusiveClaim` functions without the owner lifecycle; if +any part of the claim chain is owner-dependent, the route returns +`native_main_unavailable` and no write occurs — an unfenced write is a +C4 violation, not a fallback. + +## Tests (red-first; domain tests/codex-integration, tests/oauth, tests/cli) + +NEW `tests/codex-integration/main-device-reauth.test.ts` — same-account +success without codex/keyring; wrong identity refused; missing token +fields; cancelled/superseded late result cannot publish; concurrent file +replace/refresh/profile switch; atomic write failure; claim unavailable → +native_main_unavailable with zero writes; no pool-row mutation; DTO/log +secret scan. +NEW `tests/codex-integration/main-device-reauth-api.test.ts` — route +contract: strict keys, 400/404/409 shapes, unauthorized rejected, +`__main__` still refused by `/api/codex-auth/login`. +MODIFY `tests/oauth/chatgpt-device-auth.test.ts` — native result retains +idToken in-process; per-fetch deadline fires on a hung stub fetch. +MODIFY `tests/cli/cli-account.test.ts` — reauth --device surface, status, +cancel, arg rejection. +All NEW files: layout.json explicit + expected-fixture entries. + +## Docs / ownership + +structure/ ownership docs for src/codex, src/oauth, src/cli, src/server +synced in this PR (structure:check must stay green). Headless recovery +instructions updated (docs-site) in the same PR. Security draft stays in +scratch; only the implementation + regression diff is published. diff --git a/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md b/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md new file mode 100644 index 0000000000..dfae475aba --- /dev/null +++ b/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md @@ -0,0 +1,81 @@ +# L3: main-card Re-login with device code (#3898 GUI) + +Class C3 (auth-adjacent GUI). Stack layer 3, base the L2 branch. Branch +`codex/260912-native-main-reauth-ui`. Adopts +devlog/_plan/260912_accounts/090_reauth_ui.md, revalidated against current +`dev` by the GUI code map. Depends on L2's +`/api/codex-auth/main/reauth-device` contract. + +## Problem + +The main card is a locked App-login identity: expired state shows only +`codexAuth.mainTokenExpired` ("sign in again via Codex App login", +gui/src/components/codex-account-pool-main-card.tsx:183-185) and no +Re-login control (props at 21-56 have no `onReauth`). Pool rows have the +full device-code modal; the main card has nothing. + +## Constraints (090 + code map) + +- MUST NOT reuse `AddCodexAccountModal` / `openReauth("__main__")` / + `reauthAccountId=__main__`: the pool login route rejects `__main__` + (src/codex/account-id.ts:15-20; src/codex/auth-api.ts:221-224,2736-2748) + and a successful pool login writes `isMain: false` rows + (src/codex/auth-api.ts:2896) — wrong credential store. +- DTO field chain: backend DTO → hook-validated state → main card only; + no device code in browser storage; verification URL accepted only from + the backend contract, never from arbitrary payloads. +- New copy lands in ALL locale files (en, de, fr, ja, ko, ru, tr, zh, + zh-TW) per gui/AGENTS.md "Text and i18n". +- `tests/gui/provider-workspace-auth.test.ts:248` currently requires + `codexAuth.mainTokenExpired` on the main card; updating that copy is + part of this layer. + +## Changes + +NEW `gui/src/components/use-main-device-reauth.ts` +- Dedicated hook mirroring the pool OAuth hook's start/poll/cancel shape + (gui/src/components/use-add-codex-account-oauth.ts:27) against + `/api/codex-auth/main/reauth-device`: `start()` POST, `poll(flowId)` + with visibility polling (2s tick, 10s per-tick timeout, stop on terminal + status), `cancel(flowId)` DELETE, unmount/abort cleanup. +- Normalizes closed status/error payloads; ignores late responses from a + replaced flow (flowId ownership); never accepts token/account-id fields; + renders only verificationUrl + deviceCode + status. + +MODIFY `gui/src/components/codex-account-pool-main-card.tsx` +- New optional `onReauthDevice` prop. When `showReauth` (83) is true, + render a "Re-login with device code" CTA beside the existing copy; after + start, show verification URL + human code + pending status + cancel; + success triggers the existing parent refresh. +- Layout stays consistent with the current card; pool Add/Re-login and the + native profile picker are untouched. + +MODIFY `gui/src/components/CodexAccountPool.tsx` +- Own main-reauth modal state separate from `showAdd`/`reauthId` + (42,189-192); wire `onReauthDevice` at the main-card render (515-533); + pause pool refresh while the main flow is active, same as the existing + modal pause (174-178). + +MODIFY `gui/src/i18n/{en,de,fr,ja,ko,ru,tr,zh,zh-TW}.ts` +- New `codexAuth.*` keys: CTA label, pending status, cancel, terminal + failure copy (actionable, safe; no auto-retry wording). Revise + `mainTokenExpired` so it no longer claims App login is the only path. + +## Tests (red-first) + +NEW `gui/tests/main-device-reauth.test.tsx` — happy-dom mount per +gui/tests convention: CTA starts the dedicated route (never +`/api/codex-auth/login`), code/URL display, cancel ownership, stale-poll +ignore, success refresh, keyboard and error states. +MODIFY `tests/gui/provider-workspace-auth.test.ts` — main-card contract +updated for the new CTA + copy. +MODIFY `tests/gui/codex-auth-modal-status.test.ts` if locale-key +assertions enumerate codexAuth keys. +NEW gui test file: layout.json explicit + expected-fixture entries. + +## Verification + +`cd gui && bun test tests/main-device-reauth.test.tsx` plus the touched +suites; `bun run lint:i18n` for copy. Local GUI build NOT RUN; PR +screenshot evidence comes from hosted CI built artifacts, or an explicit +recorded exemption (repo gate: gui-mentioning PRs need a screenshot). diff --git a/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md b/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md new file mode 100644 index 0000000000..046d4c7484 --- /dev/null +++ b/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md @@ -0,0 +1,89 @@ +# L4: paginated history — offline ordinal recovery, live refusal preserved (#4311) + +Class C4 (user data). Stack top, base the L3 branch. Branch +`codex/260912-native-paginated-writer`. + +## Problem and scope decision (open decision 2, resolved here for audit) + +#4311's live defect (ordinal-0 `session_meta` clone) is already guarded: +`updateSessionMeta` throws for paginated records before writing +(src/codex/history-provider.ts:1144,1172), and preflight refuses +`history_paginated_requires_native_writer` +(src/codex/inject.ts:899,1182,1194). The residual acceptance is +(a) native paginated writer support and (b) corrupted-rollout recovery. + +(a) needs a Codex-owned writer API/IPC. None exists in this tree: Codex +owns ordinals and the live projection cursor +(structure/codex-home.md:232-234), `appendRolloutLine` deliberately does +not allocate ordinals (src/codex/history-provider.ts:77,248), and H +serializes only OpenCodex writes (src/codex/history-lock.ts; +src/codex/internal/history-writer.ts:86,107). Inventing N+1 is explicitly +forbidden by the issue (concurrent native writer / stale cursor). This +layer therefore ships (b) the offline recovery tool, keeps (a) refused +with the same structured reason, and says so in the PR. A follow-up +native-writer integration needs a Codex-side write API first — reported, +not faked. + +## Changes + +NEW `src/codex/history-ordinal-recovery.ts` +- Offline repairer for the #4311 corruption shape: an unprojected suffix + whose ordinals regress (projector error `expected N, got 0`). +- Preconditions, all enforced before any write: + - Codex fully closed (no running Codex process holds the home; detect + via the same process/home inspection the service uses, fail safe when + undecidable). + - Target resolution follows `resolveCodexStateDbPath` and + `threads.rollout_path` (src/codex/paths.ts:72,108) — never assume + `~/.codex/sessions`. + - Suffix shape verified: ordinals strictly increase before the boundary, + regress at the boundary, and the suffix parses cleanly. Anything else + refuses. + - Byte-identical backup written before mutation (manifest beside the + existing backup convention, src/codex/history-provider.ts:30). +- Rewrite: only ordinal digits in the unprojected suffix, renumbered to + continue the pre-boundary sequence; message text, ids, timestamps, and + all earlier bytes preserved. Exact readback verification before + reporting success. Dry-run (verify-only) is the default; `--write` + applies. + +MODIFY `src/cli/` (doctor/dispatch surface per existing conventions) +- `ocx doctor history repair-paginated-ordinals [--thread ]` + [--write]: runs the recovery, prints boundary, counts, backup path, and + readback result. Register capability/help; regenerate skill surface if + the registry changes. + +MODIFY `structure/codex-home.md` +- Record the recovery tool's ownership of offline ordinal repair and + restate that live paginated writes stay refused (structure:check gate). + +Explicitly unchanged (regression-tested, not edited): +`preflightCodexHistoryInjection` (history-provider.ts:307), +`appendRolloutLine` (77), `updateSessionMeta` paginated guard (1172), +inject pre/postflight (inject.ts:1182,1295,1332), catalog-only sync +(src/codex/sync.ts:216). + +## Tests (red-first; tests/codex-integration) + +NEW `tests/codex-integration/history-ordinal-recovery.test.ts` +- Synthetic fixture: session_meta ordinal 0 followed by event ordinal 1 + (the issue's minimal shape) behind a healthy increasing prefix. +- Dry-run reports and writes nothing (byte-identical file). +- Applied repair renumbers only the suffix; every non-ordinal byte + identical; readback passes; backup exists and matches the original. +- Refusals: Codex process detected / undecidable; suffix shape mismatch + (no regression, gap, unparsable line); missing backup space; absolute + rollout_path outside CODEX_HOME via sqlite_home. +- Preservation invariants red-first: run the preservation assertions + against the unimplemented command first (red), then implement (green). +MODIFY `tests/codex-integration/codex-history-provider.test.ts` +- Assert preflight refusal reason unchanged for paginated rows (the + recovery tool must not become a live writer). +NEW files: layout.json explicit + expected-fixture entries. + +## Out of scope + +Live paginated writes, ordinal allocation, native-writer IPC, any change +to the authless/compaction relabel fork (inject.ts:1098), provider-table +lifetime policy (separate #4311 sub-thread, tracked by containment unit), +in-app repair while Codex runs. From 0268727f82ae7be28ad181ac564414ce131dc848 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:24:18 +0900 Subject: [PATCH 05/21] docs(devlog): fold wp1 audit findings into trio stack roadmap --- .../010_l1_ws_stage_instrumentation.md | 6 +++++ .../020_l2_native_main_reauth_api.md | 24 ++++++++++++------- .../030_l3_main_card_relogin_ui.md | 14 ++++++++--- .../040_l4_native_paginated_writer.md | 8 ++++--- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md index c90d1c4af0..e573b1d5be 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md @@ -84,6 +84,12 @@ NEW) - Serialization keeps the stage; payload stays free of reason/body/header strings. +## Docs / ownership + +L1 touches owned `src/server/responses/*` and `src/server/request-log.ts`: +sync structure/transports/responses.md and structure/runtime.md in this PR +(structure:check must stay green). + ## Out of scope Any WS behavior fix, SSE-fallback policy change, prelude-timeout tuning diff --git a/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md index dbd9aed6a5..3bc7a1315f 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md @@ -53,7 +53,7 @@ NEW `src/codex/main-device-reauth.ts` never publish. No tokens/emails/raw account ids in DTO/log/error. - Dedicated abort controller and direct `loginChatGPTNativeDevice` call: MUST NOT use `startLoginFlow("chatgpt")` (would overwrite the chatgpt - scratch slot and 409 against pool logins, src/oauth/index.ts:1856-1973). + scratch slot and 409 against pool logins, src/oauth/index.ts:1899-1973). NEW `src/codex/main-device-reauth-api.ts` - `POST/GET/DELETE /api/codex-auth/main/reauth-device` with exact opaque @@ -73,16 +73,22 @@ MODIFY `src/cli/account-main.ts` On a headless hub the native owner lifecycle is a no-op (src/server/index.ts:1026-1046 binds the no-op when -`shouldSyncCodexOnStart` is false via src/codex/desired-state.ts:79-81). +`shouldSyncCodexOnStart` is false; the gate is composed at +src/codex/desired-state.ts:130 — :79-81 is `localClientSyncAllowed`). The reauth commit therefore MUST NOT depend on owner activation and MUST NOT widen `shouldSyncCodexOnStart` (that gate covers client-config sync, -not credential rewrite). Commit fencing on any runtime role: -`withNativeMainExclusiveClaim` + in-process admission fence + -path/hash/inode assertion, exactly as on workstations. B must verify -`withNativeMainExclusiveClaim` functions without the owner lifecycle; if -any part of the claim chain is owner-dependent, the route returns -`native_main_unavailable` and no write occurs — an unfenced write is a -C4 violation, not a fallback. +not credential rewrite). + +Audit-folded correction to 080: 080's `assertNativeMainOwner` at +preparation/commit is RETRACTED for this layer. That assert throws without +a held owner entry (src/codex/native-main-owner.ts:302-314), which would +make hub reauth always fail. The exclusive claim is owner-independent +(src/codex/native-main-claim.ts:167, FS/SQLite lock only). The fence is +pinned to: `withNativeMainExclusiveClaim` + in-process admission fence + +path/hash/inode assertion + recovery/admission snapshot recheck, exactly +as on workstations. Only claim/admission failure maps to +`native_main_unavailable`; no write occurs without the full fence — an +unfenced write is a C4 violation, not a fallback. ## Tests (red-first; domain tests/codex-integration, tests/oauth, tests/cli) diff --git a/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md b/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md index dfae475aba..18ed04b9f7 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md @@ -20,7 +20,7 @@ full device-code modal; the main card has nothing. `reauthAccountId=__main__`: the pool login route rejects `__main__` (src/codex/account-id.ts:15-20; src/codex/auth-api.ts:221-224,2736-2748) and a successful pool login writes `isMain: false` rows - (src/codex/auth-api.ts:2896) — wrong credential store. + (src/codex/auth-api.ts:2934-2939) — wrong credential store. - DTO field chain: backend DTO → hook-validated state → main card only; no device code in browser storage; verification URL accepted only from the backend contract, never from arbitrary payloads. @@ -52,7 +52,7 @@ MODIFY `gui/src/components/codex-account-pool-main-card.tsx` MODIFY `gui/src/components/CodexAccountPool.tsx` - Own main-reauth modal state separate from `showAdd`/`reauthId` - (42,189-192); wire `onReauthDevice` at the main-card render (515-533); + (75,94; openReauth at 189-192); wire `onReauthDevice` at the main-card render (515-533); pause pool refresh while the main flow is active, same as the existing modal pause (174-178). @@ -71,7 +71,15 @@ MODIFY `tests/gui/provider-workspace-auth.test.ts` — main-card contract updated for the new CTA + copy. MODIFY `tests/gui/codex-auth-modal-status.test.ts` if locale-key assertions enumerate codexAuth keys. -NEW gui test file: layout.json explicit + expected-fixture entries. +The happy-dom file lives under `gui/tests/`, outside the `tests/` layout +map — layout.json explicit + expected-fixture entries are needed only for +any NEW `tests/gui/*` source-contract file, not for `gui/tests/*`. + +## Docs / ownership + +L3 touches owned `gui/`: sync structure/overview.md and +structure/gui-and-management-api.md in this PR (structure:check must stay +green). ## Verification diff --git a/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md b/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md index 046d4c7484..0b6e879460 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md @@ -7,9 +7,10 @@ Class C4 (user data). Stack top, base the L3 branch. Branch #4311's live defect (ordinal-0 `session_meta` clone) is already guarded: `updateSessionMeta` throws for paginated records before writing -(src/codex/history-provider.ts:1144,1172), and preflight refuses +(throw at src/codex/history-provider.ts:1172), and preflight refuses `history_paginated_requires_native_writer` -(src/codex/inject.ts:899,1182,1194). The residual acceptance is +(structured field src/codex/inject.ts:899; preflight closure +src/codex/inject.ts:1182-1194). The residual acceptance is (a) native paginated writer support and (b) corrupted-rollout recovery. (a) needs a Codex-owned writer API/IPC. None exists in this tree: Codex @@ -34,7 +35,8 @@ NEW `src/codex/history-ordinal-recovery.ts` via the same process/home inspection the service uses, fail safe when undecidable). - Target resolution follows `resolveCodexStateDbPath` and - `threads.rollout_path` (src/codex/paths.ts:72,108) — never assume + `threads.rollout_path` (src/codex/paths.ts:107-108; the column is + read through history-provider, not paths.ts) — never assume `~/.codex/sessions`. - Suffix shape verified: ordinals strictly increase before the boundary, regress at the boundary, and the suffix parses cleanly. Anything else From 2b533a5582051d03062188c555765900dcd00388 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:25:39 +0900 Subject: [PATCH 06/21] docs(devlog): revalidate folded audit citations against source in wp1 B --- .../001_baseline_revalidation.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 devlog/_plan/260912_unimplemented_trio_stack/001_baseline_revalidation.md diff --git a/devlog/_plan/260912_unimplemented_trio_stack/001_baseline_revalidation.md b/devlog/_plan/260912_unimplemented_trio_stack/001_baseline_revalidation.md new file mode 100644 index 0000000000..327dc4285c --- /dev/null +++ b/devlog/_plan/260912_unimplemented_trio_stack/001_baseline_revalidation.md @@ -0,0 +1,22 @@ +# Baseline revalidation (wp1 B-phase) + +Independent main-session spot check of the citations folded in by the wp1 +audit (0268727f82), re-run against the working tree at B. Every folded +reference was opened and read; results below. All verified TRUE. + +| Claim | Where verified | Result | +|-------|----------------|--------| +| `assertNativeMainOwner` throws without a held owner entry | src/codex/native-main-owner.ts:302-314 — throws NATIVE_MAIN_OWNER_UNAVAILABLE/BUSY (503) unless snapshot held | TRUE | +| Exclusive claim is owner-independent (FS/SQLite lock) | src/codex/native-main-claim.ts:167 — `withNativeMainExclusiveClaim(context, operation, options)`, claim/release around operation, no owner lookup | TRUE | +| `shouldSyncCodexOnStart` is composed at desired-state.ts:130 | src/codex/desired-state.ts:130 — exported function; comment names the hub rule | TRUE | +| Pool login writes `isMain: false` | src/codex/auth-api.ts:2934,2939 — both update and add paths set `isMain: false` | TRUE | +| Paginated guard throws the structured reason | src/codex/history-provider.ts:1172 — `CodexHistoryIntegrityError("history_paginated_requires_native_writer")` on `ordinal` key or `history_mode === "paginated"` | TRUE | +| State DB resolution | src/codex/paths.ts:106-109 — `resolveCodexStateDbPath` joins sqlite root + state_5.sqlite | TRUE | +| `startLoginFlow` location | src/oauth/index.ts:1899 — export begins | TRUE | +| GUI modal state | gui/src/components/CodexAccountPool.tsx:75 (`showAdd`), :94 (`reauthId`), :651-654 (modal mount) | TRUE | + +Consequence for implementation cycles: 020's hub-fence resolution stands +as amended — the native-main reauth fence MUST NOT call +`assertNativeMainOwner`; the owner-independent exclusive claim plus +path/hash/inode and recovery/admission rechecks is the whole fence, and +claim/admission failure alone maps to `native_main_unavailable`. From da540179f53a2549e615fc1145a80fcb7c4881e5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:35:36 +0900 Subject: [PATCH 07/21] docs(devlog): fold wp2 audit FAIL findings into L1 emission design --- .../010_l1_ws_stage_instrumentation.md | 152 +++++++++++------- 1 file changed, 95 insertions(+), 57 deletions(-) diff --git a/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md index e573b1d5be..7796df5940 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md @@ -3,6 +3,11 @@ Class C2. Stack bottom, base `dev`. Branch `codex/260912-ws-stage-instrumentation`. Diagnosis instrumentation only: no behavior change to success paths, no retry/fallback change. +Second revision: folds the wp2 A-audit FAIL (2 blockers, 2 majors, 1 +minor) into the design. First revision's `recordCodexWsStage`-on-context +design is retracted — the exchange has no `RequestLogContext` +(codex-ws-exchange.ts:11-18,85) and cannot get one without inverting +layers. ## Problem @@ -12,86 +17,119 @@ proxy. The content-free stage record already exists as `failureStage()` (src/server/responses/codex-ws-exchange.ts:148-159), but it is only interpolated into failure message strings. Durable logs keep neither the message nor a typed code: the eager relay collapses stream -errors to `upstream_reset` + `streamAborted` -(codex-ws-wire.ts:225-228; src/server/relay.ts; src/server/request-log.ts). -Operators therefore cannot distinguish create-frame size, send failure, -prelude stall, and upstream close from each other after the fact. +errors to `upstream_reset` + `streamAborted` (wire.ts:218-229 comment; +relay.ts:1417-1430), and the 504 pre-response JSON path never reaches the +relay at all. `/api/logs` and usage.jsonl are explicit per-field copies, +so a field added only to `RequestLogContext` is dropped on write and on +restart hydrate. ## Contract (from #4191 + maintainer bounds) Record, per upstream exchange: create-frame bytes, send completion, close code (numeric only), elapsed ms and first-frame ms, frame counters (upstream/control/relayed, pings/pongs), pool reuse boolean, OCX version, -Bun runtime version, originator only when already present. Never record -conversation text, headers, close-reason text, or account identifiers. -No `responseCommitted === false` auto-retransmit fallback. +Bun runtime version. Never record conversation text, headers, close-reason +text, or account identifiers. No `responseCommitted === false` +auto-retransmit fallback. Client CLI version is not on the handshake +(`user-agent` is not in FORWARD_HEADERS, +src/adapters/openai-responses.ts:43-61) — the limitation is documented in +the PR, not worked around by parsing `frameText`. ## Changes MODIFY `src/server/responses/codex-ws-wire.ts` -- Extend `CodexWsFailureStage` with `closeCode: number | null` and - `reused: boolean`; extend the privacy comment to state the new record - is numeric/boolean only and close-reason text stays out of durable logs. -- No renderer change required beyond carrying the new fields. +- New exported type `CodexWsStageRecord = CodexWsFailureStage & { + closeCode: number | null; reused: boolean; ocxVersion: string; + bunVersion: string }` except `requestBytes` widened to + `number | null` (see exchange note). Extend the privacy comment: + numeric/boolean/semver fields only; close-reason text stays out of every + durable record. +- New `markCodexWsStage(response, record)` / `readCodexWsStage(response)` + over a `WeakMap` — the same + Response-marker seam `markCodexWsResponse` already uses. +- `ocxVersion` comes from a module-local package.json IIFE, the exact + pattern already duplicated in management-api.ts:87-93, gui-static.ts:6-9, + client/machine-listener.ts:21, update/index.ts:147. Do NOT import + management-api (layer inversion + cycle). MODIFY `src/server/responses/codex-ws-exchange.ts` -- Snapshot `failureStage()` once at each settle site — `armSilence` - (206), connect-deadline `cancelExchange` (246-256), `onClose` - (415-426), `onError` (429-437) — and hand the snapshot plus - `closeCode`/`reused` to the request-log context through a new - `recordCodexWsStage` sink on the log context (below). -- On the happy path record the same snapshot once at `commitResponse` - (160-170) so successful exchanges also carry first-frame timing. -- `sent` keeps its current meaning ("send returned"); the record must not - claim kernel flush. No control-flow change at any site. +- `ExchangeOptions` gains optional `bunVersion?: string` and nothing + else; no context, no callback registry. +- Snapshot once in `failStream` (the funnel every failure site already + calls: armSilence :206, connect-deadline :256, onClose :426, onError + :437, and the onMessage sites :330-402) and once in `commitResponse` + (:160). After the existing settle decision, call + `markCodexWsStage(response, record)` on the Response being resolved — + both the SSE 200 and the `codexWsPreResponseFailure` JSON paths resolve + a Response, so one marker covers success and failure. +- `requestBytes`: computed at failure time only (current deferred + behavior). On the committed-success record it is `null` — the happy + path must not byte-count megabyte replay frames (the deferral comment at + :143-147 is the contract). +- `closeCode` is captured in `onClose` from the event (numeric only) and + carried into the `failStream` call it makes; other sites pass `null`. +- `reused` is `session.reused`; `bunVersion` from the new option. +- No control-flow change at any site: emissions happen after the settle + decision, never instead of it. + +MODIFY `src/server/responses/ws-upstream.ts` +- Pass `bunVersion: runtime.version` (BunRuntimeIdentity already arrives + as a parameter, :64) through `codexWsUpstreamFetch` into + `codexWsExchange`. Signature gain is one optional field. + +MODIFY `src/server/responses/core.ts` +- After `fetchWithHeaderTimeout` returns `upstreamResponse` (:1532-1556 + region), `readCodexWsStage(upstreamResponse)`; when present, assign + onto `logCtx.activeAttempt.codexWsStage`. This covers the 504/502 + pre-response JSON path that never reaches relay.ts, and needs no + relay.ts change: the relay collapse only sets `streamAborted` alongside + the stage. (First revision's relay.ts MODIFY is retracted.) + +MODIFY `src/usage/log.ts` +- `PersistedUsageAttempt` gains `codexWsStage?: CodexWsStageRecord` + with a comment naming #4191 and the content-free invariant. +- Attempt serializer allowlist (:445-480 region): carry `codexWsStage` + through a `normalizeCodexWsStageRecord` guard (numeric fields via + isNonNegativeFiniteNumber-style checks, booleans strictly, versions as + capped semver strings, `requestBytes: number | null`) so a hand-edited + row cannot inject strings into the DTO. +- `normalizeUsageEntry` (:527-612) carries it via the attempts + normalization above; no entry-level copy (stage is per-attempt). MODIFY `src/server/request-log.ts` -- Add optional `codexWsStage` to `RequestLogContext` (56) holding the - snapshot fields above plus `ocxVersion` and `bunVersion`; expose - `recordCodexWsStage(stage)` next to `recordFirstOutput` (470-481). -- Include the field in the serialized attempt/log payload so `/api/logs` - keeps it. Content-free fields only; the payload gains no strings beyond - semver/version values. - -MODIFY `src/server/relay.ts` -- Where stream errors collapse to `upstream_reset`, preserve - `codexWsStage` on the attempt record (the collapse stays; the typed - stage rides alongside). - -MODIFY `src/server/responses/codex-ws-session.ts` (only if needed) -- Expose `reused` for the stage snapshot (already on the session, 21); - no pooling change. - -Versions: OCX `VERSION` is imported the same way -src/server/management-api.ts:87-93 does; Bun version via -`currentBunRuntimeIdentity()` (src/server/responses/ws-upstream.ts:26). -Client CLI version is not on the handshake (`user-agent` is not in -FORWARD_HEADERS, src/adapters/openai-responses.ts:43-61) — record -`originator` only when already present, and document the limitation in -the PR. +- `RequestLogEntry` needs no new field: `attempts` already projects. + `requestLogEntryFromPersistedUsage` (:280-330) keeps copying + `attempts` wholesale. Verify `addFinalRequestLog` (:1037-1086) passes + the attempt objects (with the stage) into `addLog` — if it re-derives + attempt rows field-by-field, add `codexWsStage` there instead. B + confirms which of the two attempt paths is authoritative and tests it. ## Tests (red-first) -MODIFY `tests/responses/ws-failure-stage.test.ts` -- Stage record carries closeCode/reused/versions; reason text never - appears in the durable record. MODIFY `tests/responses/ws-upstream.test.ts` -- 1006 path and prelude-timeout path persist `codexWsStage` on the log - attempt; happy path records once at commit. -MODIFY or NEW `tests/server/request-log*.test.ts` (per layout.json domain -for request-log; add layout.json explicit + expected-fixture entries if -NEW) -- Serialization keeps the stage; payload stays free of reason/body/header - strings. +- Through `handleResponses` (the :399-408 pattern — the only path that + owns a logCtx): upstream 1006 persists `codexWsStage` on the logged + attempt with `closeCode: 1006` and `sent: true`; prelude-timeout + persists `firstFrameMs: null`, `upstreamFrames: 0`; a committed + success records exactly one stage with `requestBytes: null`. +MODIFY `tests/responses/ws-failure-stage.test.ts` +- Record carries closeCode/reused/versions; the serialized record never + contains reason text, header names, or body substrings. +NEW `tests/usage/usage-log-ws-stage.test.ts` +- Round trip: `normalizeUsageEntry` + attempt serializer keep a valid + stage; corrupt stage shapes (string frames, object closeCode) are + dropped, not passed through. layout.json explicit + + tests/fixtures/test-layout-expected.json entries (domain `usage`). ## Docs / ownership -L1 touches owned `src/server/responses/*` and `src/server/request-log.ts`: -sync structure/transports/responses.md and structure/runtime.md in this PR -(structure:check must stay green). +L1 touches owned `src/server/responses/*`, `src/usage/log.ts`, and +`src/server/request-log.ts`: sync structure/transports/responses.md and +structure/runtime.md in this PR (structure:check must stay green). ## Out of scope Any WS behavior fix, SSE-fallback policy change, prelude-timeout tuning (#3976/#4083), pool policy, inbound client-socket metrics -(codexWebSocketAdmissionMetrics is the client side — do not touch). +(codexWebSocketAdmissionMetrics is the client side — do not touch), +auto-retransmit on `responseCommitted === false`. From 1893eecbb774e34fe6e013d31c034483c608f371 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:38:31 +0900 Subject: [PATCH 08/21] docs(devlog): fold wp2 re-audit residual into L1 adoption points --- .../010_l1_ws_stage_instrumentation.md | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md index 7796df5940..72cfd8c3c2 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md @@ -38,12 +38,13 @@ the PR, not worked around by parsing `frameText`. ## Changes MODIFY `src/server/responses/codex-ws-wire.ts` -- New exported type `CodexWsStageRecord = CodexWsFailureStage & { - closeCode: number | null; reused: boolean; ocxVersion: string; - bunVersion: string }` except `requestBytes` widened to - `number | null` (see exchange note). Extend the privacy comment: - numeric/boolean/semver fields only; close-reason text stays out of every - durable record. +- New exported type `CodexWsStageRecord = + Omit & { + requestBytes: number | null; closeCode: number | null; reused: boolean; + ocxVersion: string; bunVersion: string }` (Omit, not an intersection — + an intersection cannot widen `requestBytes`). Extend the privacy + comment: numeric/boolean/semver fields only; close-reason text stays out + of every durable record. - New `markCodexWsStage(response, record)` / `readCodexWsStage(response)` over a `WeakMap` — the same Response-marker seam `markCodexWsResponse` already uses. @@ -73,17 +74,23 @@ MODIFY `src/server/responses/codex-ws-exchange.ts` decision, never instead of it. MODIFY `src/server/responses/ws-upstream.ts` -- Pass `bunVersion: runtime.version` (BunRuntimeIdentity already arrives - as a parameter, :64) through `codexWsUpstreamFetch` into - `codexWsExchange`. Signature gain is one optional field. +- Pass `bunVersion: typeof runtime === "string" ? runtime : runtime.version` + (the gate input at :62-64 may be a plain string) through + `codexWsUpstreamFetch` into `codexWsExchange`. Signature gain is one + optional field. MODIFY `src/server/responses/core.ts` -- After `fetchWithHeaderTimeout` returns `upstreamResponse` (:1532-1556 - region), `readCodexWsStage(upstreamResponse)`; when present, assign - onto `logCtx.activeAttempt.codexWsStage`. This covers the 504/502 - pre-response JSON path that never reaches relay.ts, and needs no - relay.ts change: the relay collapse only sets `streamAborted` alongside - the stage. (First revision's relay.ts MODIFY is retracted.) +- Adopt the stage onto the attempt at the handleResponses send path, not + only at `retryCodexPoolOnAlternateAccount` (:1532-1556 is the pool + retry, not the primary send): `readCodexWsStage(upstreamResponse)`; + when present assign `logCtx.activeAttempt.codexWsStage`. Apply at every + adopted `upstreamResponse`: the primary send (:5304-5320), the + post-retry assignment (:5824) — or once on the final response after the + ladder (~5758); B picks the single funnel that covers every adopted + response and tests it. This covers the 504/502 pre-response JSON path + that never reaches relay.ts, and needs no relay.ts change: the relay + collapse only sets `streamAborted` alongside the stage. (First + revision's relay.ts MODIFY is retracted.) MODIFY `src/usage/log.ts` - `PersistedUsageAttempt` gains `codexWsStage?: CodexWsStageRecord` From 923ca3c848d9fc498f0692da282d481593a05195 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 01:02:09 +0900 Subject: [PATCH 09/21] feat(responses): persist content-free Codex WS upstream stage records (#4191) --- scripts/test-layout/layout.json | 3 +- src/server/responses/codex-ws-exchange.ts | 48 +++++++++++++- src/server/responses/codex-ws-wire.ts | 55 +++++++++++++++ src/server/responses/core.ts | 43 ++++++------ src/server/responses/ws-upstream.ts | 5 +- src/usage/log.ts | 52 +++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- tests/responses/ws-failure-stage.test.ts | 69 +++++++++++++++++++ tests/responses/ws-upstream.test.ts | 67 +++++++++++++++++++ tests/usage/usage-log-ws-stage.test.ts | 81 +++++++++++++++++++++++ 10 files changed, 400 insertions(+), 26 deletions(-) create mode 100644 tests/usage/usage-log-ws-stage.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 33b960ac70..eb48d9e900 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1370,7 +1370,8 @@ "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", - "devin-cli-authmode-migration.test.ts": "providers" + "devin-cli-authmode-migration.test.ts": "providers", + "usage-log-ws-stage.test.ts": "usage" }, "migrated": [ "adapters", diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 698eb93340..1de92a5409 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -6,7 +6,8 @@ import { CodexWsCorrelation } from "./codex-ws-correlation"; import type { CodexWsSession } from "./codex-ws-session"; import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage, - codexWsFailureDetail, codexWsPreResponseFailure, type CodexWsFailureStage } from "./codex-ws-wire"; + codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion, + type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire"; interface ExchangeOptions { session: CodexWsSession; @@ -16,6 +17,8 @@ interface ExchangeOptions { sseFallback: typeof globalThis.fetch; onQuota?: CodexWsQuotaObserver; beforeDispatch?: (headers: Headers) => void; + /** Bun version string the caller gated on; stamped onto the stage record. */ + bunVersion?: string; } const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i; @@ -83,7 +86,7 @@ function wrappedRejectionResponse(payload: Record, prelude: Hea /** The sole SSE exchange state machine for both one-shot and retained sockets. */ export function codexWsExchange(options: ExchangeOptions): Promise { - const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch } = options; + const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch, bunVersion } = options; const { frameText, headers } = prepared; const signal = init.signal ?? undefined; return new Promise((resolve, reject) => { @@ -105,6 +108,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise { let pongs = 0; let sentAt: number | null = null; let firstFrameAt: number | null = null; + // Numeric close code for the durable stage record; the reason string stays + // out of it on purpose (#4191 content-free contract). + let closeCode: number | null = null; let controller: ReadableStreamDefaultController | null = null; const encoder = new TextEncoder(); const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null; @@ -115,6 +121,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise { // interval so a peer that answers pings can never trip the silence bound while alive. let silenceTimer: ReturnType | undefined; let pingTimer: ReturnType | undefined; + // The resolved 200, retained so a later body failure can replace its + // success-shaped stage record with the failure-shaped one (#4191). + let committedResponse: Response | null = null; const stream = new ReadableStream({ start(c) { controller = c; }, cancel() { @@ -157,6 +166,28 @@ export function codexWsExchange(options: ExchangeOptions): Promise { pongs, }); + /** + * The durable twin of failureStage (#4191). `requestBytes` is an explicit + * parameter so the committed-success path can pass null instead of paying + * the UTF-8 walk of a megabyte replay frame; failure callers pass + * `failureStage().requestBytes`, which measures exactly once. + */ + const stageRecord = (requestBytes: number | null): CodexWsStageRecord => ({ + requestBytes, + sent, + upstreamFrames, + controlFrames, + relayedEvents, + firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null, + elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null, + pings, + pongs, + closeCode, + reused: session.reused, + ocxVersion: codexWsOcxVersion(), + bunVersion: bunVersion ?? "unknown", + }); + const commitResponse = () => { if (responseCommitted) return; responseCommitted = true; @@ -167,6 +198,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise { const response = new Response(stream, { status: 200, headers: responseHeaders }); metadata?.commit(); markCodexWsResponse(response, Boolean(metadata && onQuota)); + markCodexWsStage(response, stageRecord(null)); + committedResponse = response; resolve(response); }; @@ -186,7 +219,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise { try { controller?.close(); } catch { /* unused stream already closed */ } session.dispose(); const message = error instanceof Error ? error.message : String(error); - resolve(codexWsPreResponseFailure(status, message, prelude)); + const failureResponse = codexWsPreResponseFailure(status, message, prelude); + markCodexWsStage(failureResponse, stageRecord(Buffer.byteLength(frameText, "utf8"))); + resolve(failureResponse); return; } // A response is already flowing (or this transport has no metadata channel and @@ -196,6 +231,11 @@ export function codexWsExchange(options: ExchangeOptions): Promise { cleanup(); try { controller?.error(typeof error === "string" ? new Error(error) : error); } catch { /* stream already done */ } session.dispose(); + // A body failure replaces the success-shaped record commitResponse wrote: + // this settle is a failure, and the frame size is evidence again. + if (committedResponse) { + markCodexWsStage(committedResponse, stageRecord(Buffer.byteLength(frameText, "utf8"))); + } }; /** (Re)start the silence bound; every inbound frame or pong is proof of life. */ @@ -414,6 +454,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise { const onClose = (event: unknown) => { cleanup(); + const code = (event as { code?: unknown } | null)?.code; + if (typeof code === "number") closeCode = code; if (!opened) { if (settledPreOpen) return; settledPreOpen = true; diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 6dc3a45c3b..db2c6f6d30 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -4,6 +4,7 @@ import { UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE, UPSTREAM_NO_RESPONSE_CODE, } from "../../lib/upstream-retry"; +import { readFileSync } from "node:fs"; // If the 101 never arrives (network black hole), give SSE a chance well before // the caller's connect timeout (default 200s) would fire. export const UPGRADE_DEADLINE_MS = 10_000; @@ -64,6 +65,60 @@ export function markCodexWsResponse(response: Response, observed: boolean): void if (observed) quotaObservedResponses.add(response); } +/** + * The proxy's own version, stamped onto every stage record so a field report + * can be tied to the exact build that produced it (#4191). Computed locally + * with the same package.json IIFE management-api.ts / gui-static.ts use — + * importing management-api from the transport layer would invert the + * layering and pull the management surface into every WS exchange. + */ +const OCX_VERSION = (() => { + try { + return JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version as string; + } catch { + return "0.0.0"; + } +})(); + +/** + * The durable form of the stage counters, carried out of the exchange on the + * resolved Response so the logging layer can persist it without the exchange + * ever seeing a RequestLogContext (#4191). + * + * Everything here is a size, a count, a duration, a boolean, or a semver + * string: no request body, no header, no account identifier, no conversation + * text, and no close-reason text can reach a record built by the exchange. + * `requestBytes` is null on a committed success because the happy path never + * pays the UTF-8 walk of a megabyte replay frame; it is measured on failure, + * where its size is the evidence. + */ +export type CodexWsStageRecord = Omit & { + /** UTF-8 size of the create frame; null on the committed-success record. */ + requestBytes: number | null; + /** Numeric upstream close code when the socket closed; null otherwise. */ + closeCode: number | null; + /** True when the exchange ran on a pooled, previously used session. */ + reused: boolean; + /** OpenCodex version that produced this record. */ + ocxVersion: string; + /** Bun runtime version the exchange gated on. */ + bunVersion: string; +}; + +const codexWsStageByResponse = new WeakMap(); + +export function markCodexWsStage(response: Response, record: CodexWsStageRecord): void { + codexWsStageByResponse.set(response, record); +} + +export function readCodexWsStage(response: Response): CodexWsStageRecord | undefined { + return codexWsStageByResponse.get(response); +} + +export function codexWsOcxVersion(): string { + return OCX_VERSION; +} + /** * The honest settlement for an exchange that sent its create frame and never saw a * response event. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8b499d5111..cffc35ae2e 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -364,6 +364,7 @@ import { import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps"; import { cancelBodyOnAbort } from "../../lib/abort"; import { isCodexWsUpstreamResponse, type BunRuntimeGateInput } from "./ws-upstream"; +import { readCodexWsStage } from "./codex-ws-wire"; import { createResponsesItemIdPayloadRewrite, hasResponsesItemIdRepair, @@ -5189,6 +5190,22 @@ async function handleResponsesInner( } hostAdmissionLease = null; }; + /** + * #4191: a Codex WS exchange pins its content-free stage record on the + * Response it resolves (markCodexWsStage). Adopting the record here, at + * the single funnel every physical upstream response passes through, + * binds it to the attempt that actually served it — including the 502/504 + * pre-response JSON settles that never reach the SSE relay. + */ + const adoptCodexWsStage = (response: Response): void => { + const stage = readCodexWsStage(response); + if (stage && logCtx.activeAttempt) logCtx.activeAttempt.codexWsStage = stage; + }; + const adoptObservedResponse = (response: T): T => { + settleObservedHostResponse(); + adoptCodexWsStage(response); + return response; + }; let passthroughEstimate = typeof request.usageLog?.inputTokens === "number" ? request.usageLog.inputTokens : undefined; @@ -5320,10 +5337,7 @@ async function handleResponsesInner( route.provider.authMode === "forward") // Every real attempt response — including an intermediate 5xx the // retry wrapper replaces — proves the host was reached (#914 review). - .then(res => { - settleObservedHostResponse(); - return res; - }); + .then(adoptObservedResponse); }, { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, ); @@ -5396,10 +5410,7 @@ async function handleResponsesInner( ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), route.provider.authMode === "forward") - .then(response => { - settleObservedHostResponse(); - return response; - }); + .then(adoptObservedResponse); }, { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, ); @@ -5504,10 +5515,7 @@ async function handleResponsesInner( codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined, ), route.provider.authMode === "forward", - ).then(response => { - settleObservedHostResponse(); - return response; - }); + ).then(adoptObservedResponse); } catch (err) { return transportFailureResponse(err); } finally { @@ -5622,10 +5630,7 @@ async function handleResponsesInner( ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), route.provider.authMode === "forward") - .then(res => { - settleObservedHostResponse(); - return res; - }); + .then(adoptObservedResponse); }, { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, ); @@ -5722,10 +5727,7 @@ async function handleResponsesInner( ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), route.provider.authMode === "forward") - .then(res => { - settleObservedHostResponse(); - return res; - }); + .then(adoptObservedResponse); }, { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, ); @@ -5804,6 +5806,7 @@ async function handleResponsesInner( passthroughEstimate, stream: parsed.stream, onResponse: (response, retryAuthCtx, retryRequest) => { + adoptCodexWsStage(response); captureAffinityResponse( response, retryAuthCtx, diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index b6799267d6..efa5dbb466 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -179,5 +179,8 @@ export function codexWsUpstreamFetch( } catch { return sseFallback(url, init); } - return codexWsExchange({ session, url, init, prepared, sseFallback, onQuota, beforeDispatch }); + return codexWsExchange({ + session, url, init, prepared, sseFallback, onQuota, beforeDispatch, + bunVersion: typeof runtime === "string" ? runtime : runtime.version, + }); } diff --git a/src/usage/log.ts b/src/usage/log.ts index 2944c22f9a..f0feba9915 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -10,6 +10,7 @@ import type { AttemptTierOutcome, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility"; +import type { CodexWsStageRecord } from "../server/responses/codex-ws-wire"; export interface PersistedClaudeCompatibilityLog { decision: "shadow"; @@ -118,6 +119,14 @@ export interface PersistedUsageAttempt { reasoningWireValue?: string | number | boolean; /** Adapter-produced tier fact for this physical attempt; absent on pre-B0 rows. */ tierOutcome?: AttemptTierOutcome; + /** + * #4191: content-free stage record of a Codex WS upstream exchange that + * served this attempt (frame size, counters, close code, versions). Absent + * on HTTP-transport attempts and pre-instrumentation rows. Numbers, + * booleans, and semver strings only — never reason text, headers, or + * account identifiers. + */ + codexWsStage?: CodexWsStageRecord; } export interface PersistedUsageEntry { @@ -436,6 +445,9 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { const tierOutcome = "tierOutcome" in attempt ? normalizeAttemptTierOutcome(attempt.tierOutcome) : undefined; + const codexWsStage = "codexWsStage" in attempt + ? normalizeCodexWsStageRecord(attempt.codexWsStage) + : undefined; const recoveryKinds = Array.isArray(attempt.recoveryKinds) ? [...new Set(attempt.recoveryKinds.filter( (value): value is AttemptRecoveryKind => typeof value === "string" @@ -490,6 +502,46 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { : { reasoningWireValue: attempt.reasoningWireValue } : {}), ...(tierOutcome ? { tierOutcome } : {}), + ...(codexWsStage ? { codexWsStage } : {}), + }; +} + +/** + * #4191: a persisted stage record is trusted only when every field matches the + * exchange's own shapes. Anything else — a hand-edited number as a string, an + * injected free-form field — drops the whole record rather than passing + * attacker text into the DTO. + */ +function normalizeCodexWsStageRecord(value: unknown): CodexWsStageRecord | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const stage = value as Record; + for (const key of ["upstreamFrames", "controlFrames", "relayedEvents", "pings", "pongs"] as const) { + if (!isNonNegativeFiniteNumber(stage[key])) return undefined; + } + if (!(stage.requestBytes === null || isNonNegativeFiniteNumber(stage.requestBytes))) return undefined; + if (!(stage.firstFrameMs === null || isNonNegativeFiniteNumber(stage.firstFrameMs))) return undefined; + if (!(stage.elapsedMs === null || isNonNegativeFiniteNumber(stage.elapsedMs))) return undefined; + if (!(stage.closeCode === null || (typeof stage.closeCode === "number" + && Number.isInteger(stage.closeCode) && stage.closeCode >= 1000 && stage.closeCode <= 4999))) { + return undefined; + } + if (typeof stage.sent !== "boolean" || typeof stage.reused !== "boolean") return undefined; + if (typeof stage.ocxVersion !== "string" || !stage.ocxVersion || stage.ocxVersion.length > 32) return undefined; + if (typeof stage.bunVersion !== "string" || !stage.bunVersion || stage.bunVersion.length > 32) return undefined; + return { + requestBytes: stage.requestBytes as number | null, + sent: stage.sent, + upstreamFrames: stage.upstreamFrames as number, + controlFrames: stage.controlFrames as number, + relayedEvents: stage.relayedEvents as number, + firstFrameMs: stage.firstFrameMs as number | null, + elapsedMs: stage.elapsedMs as number | null, + pings: stage.pings as number, + pongs: stage.pongs as number, + closeCode: stage.closeCode as number | null, + reused: stage.reused, + ocxVersion: stage.ocxVersion, + bunVersion: stage.bunVersion, }; } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9ea5f32928..ed114b8a07 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1202,5 +1202,6 @@ "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", - "devin-cli-authmode-migration.test.ts": "providers" + "devin-cli-authmode-migration.test.ts": "providers", + "usage-log-ws-stage.test.ts": "usage" } diff --git a/tests/responses/ws-failure-stage.test.ts b/tests/responses/ws-failure-stage.test.ts index 04c12c6357..25056a9bbe 100644 --- a/tests/responses/ws-failure-stage.test.ts +++ b/tests/responses/ws-failure-stage.test.ts @@ -3,7 +3,10 @@ import { classifyCodexWsFailure, closedBeforeTerminalMessage, codexWsFailureDetail, + markCodexWsStage, + readCodexWsStage, type CodexWsFailureStage, + type CodexWsStageRecord, } from "../../src/server/responses/codex-ws-wire"; import { codexWsUpstreamFetch, @@ -245,4 +248,70 @@ describe("codexWsUpstreamFetch failure reporting", () => { jest.useRealTimers(); } }); + + test("the prelude-timeout response carries the stage as a durable record", async () => { + jest.useFakeTimers(); + const opened = Promise.withResolvers(); + const noFallback = async () => { + throw new Error("fallback must not run after open"); + }; + try { + installFake(ws => { ws.emit("open", {}); opened.resolve(); }); + const pending = codexWsUpstreamFetch( + CODEX_URL, + streamingInit(), + noFallback as unknown as typeof fetch, + BOUNDED_WS_RUNTIME, + ); + await opened.promise; + jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); + const response = await pending; + expect(response.status).toBe(504); + const stage = readCodexWsStage(response); + expect(stage).toBeDefined(); + expect(stage?.upstreamFrames).toBe(0); + expect(stage?.firstFrameMs).toBeNull(); + expect(stage?.closeCode).toBeNull(); + expect(stage?.sent).toBe(true); + expect(stage?.requestBytes).toBeGreaterThan(0); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe("codex ws stage record marker (#4191)", () => { + const stage: CodexWsStageRecord = { + requestBytes: 1234, + sent: true, + upstreamFrames: 3, + controlFrames: 1, + relayedEvents: 2, + firstFrameMs: 42, + elapsedMs: 900, + pings: 1, + pongs: 1, + closeCode: 1006, + reused: false, + ocxVersion: "2.52.0", + bunVersion: "1.4.0", + }; + + test("mark/read round trip on the resolved Response", () => { + const response = new Response("ok"); + expect(readCodexWsStage(response)).toBeUndefined(); + markCodexWsStage(response, stage); + expect(readCodexWsStage(response)).toEqual(stage); + }); + + test("the serialized record is numeric/boolean/semver only", () => { + const json = JSON.stringify(stage); + expect(json).not.toContain("reason"); + expect(json).not.toMatch(/header|authorization|conversation|body/i); + for (const [key, value] of Object.entries(stage)) { + expect(["number", "boolean", "string", "object"]).toContain(typeof value); + if (typeof value === "string") expect(value.length).toBeLessThan(64); + expect(key).not.toContain("reason"); + } + }); }); diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 80c6291780..29950e7a1c 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -7,6 +7,7 @@ import { fetchWithTransientRetry, isNonReplayableResponse } from "../../src/lib/ import { codexWsExchange } from "../../src/server/responses/codex-ws-exchange"; import { CodexWsSession } from "../../src/server/responses/codex-ws-session"; import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; +import { readCodexWsStage } from "../../src/server/responses/codex-ws-wire"; import { CodexWsMetadata, CODEX_WS_METADATA_MAX_BYTES, CODEX_WS_METADATA_MAX_VALUE_BYTES } from "../../src/server/responses/codex-ws-metadata"; import { bunSupportsBoundedCodexWsRelay, @@ -409,6 +410,26 @@ describe("handleResponses Codex WS relay selection", () => { expect(FakeWebSocket.instances[0].closed).toBe(true); }); + test("handleResponses adopts the exchange stage onto the logged attempt (#4191)", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("close", { code: 1006 }); + }); + + const logCtx = { model: "", provider: "" }; + const response = await handleResponses(request(), forwardConfig(), logCtx, { + codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME, + }); + + expect([502, 504]).toContain(response.status); + const stage = (logCtx.activeAttempt as { codexWsStage?: Record } | undefined)?.codexWsStage; + expect(stage).toBeDefined(); + expect(stage?.closeCode).toBe(1006); + expect(stage?.sent).toBe(true); + expect(typeof stage?.ocxVersion).toBe("string"); + expect(stage?.bunVersion).toBe(BOUNDED_WS_RUNTIME); + }); + test.skipIf(bunSupportsBoundedCodexWsRelay())( "an older runtime stays on HTTP SSE without opening a WebSocket", async () => { @@ -1682,6 +1703,52 @@ describe("oversized Codex create frames", () => { expect(failure.message).toContain("closed before a Responses terminal event (close 1006)"); }); + test("a pre-terminal 1006 marks the response with a content-free stage record", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("close", { code: 1006, reason: "abnormal closure detail" }); + }); + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { + throw new Error("fallback must not run after open"); + }) as unknown as typeof fetch); + + expect(response.status).toBe(502); + const stage = readCodexWsStage(response); + expect(stage).toBeDefined(); + expect(stage?.closeCode).toBe(1006); + expect(stage?.sent).toBe(true); + expect(stage?.requestBytes).toBeGreaterThan(0); + expect(stage?.upstreamFrames).toBe(0); + expect(stage?.firstFrameMs).toBeNull(); + expect(stage?.reused).toBe(false); + expect(typeof stage?.ocxVersion).toBe("string"); + expect(stage?.bunVersion).toBe(BOUNDED_WS_RUNTIME); + // Content-free: the close reason is upstream text and never enters the record. + expect(JSON.stringify(stage)).not.toContain("abnormal closure detail"); + expect(JSON.stringify(stage)).not.toContain("reason"); + }); + + test("a committed exchange marks exactly one stage and never byte-counts the frame", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r1" } }) }); + }); + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { + throw new Error("fallback must not run after open"); + }) as unknown as typeof fetch); + + expect(response.status).toBe(200); + await response.text(); + const stage = readCodexWsStage(response); + expect(stage).toBeDefined(); + // The happy path skips the UTF-8 walk of the create frame on purpose. + expect(stage?.requestBytes).toBeNull(); + expect(stage?.closeCode).toBeNull(); + expect(stage?.sent).toBe(true); + expect(stage?.relayedEvents).toBeGreaterThan(0); + }); + test("dials the configured provider's own wss URL for an opt-in upstream", async () => { process.env.HTTPS_PROXY = "http://proxy.example:8080"; process.env.NO_PROXY = "sub2api.example.com:443"; diff --git a/tests/usage/usage-log-ws-stage.test.ts b/tests/usage/usage-log-ws-stage.test.ts new file mode 100644 index 0000000000..c18aeeff06 --- /dev/null +++ b/tests/usage/usage-log-ws-stage.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { + normalizeUsageEntryForTest, + type PersistedUsageEntry, +} from "../../src/usage/log"; +import type { CodexWsStageRecord } from "../../src/server/responses/codex-ws-wire"; + +/** + * #4191: the WS stage record must survive the usage.jsonl write/read round + * trip — every serializer on that path is an explicit field copy, so a stage + * that is not named there is silently dropped on write and on restart + * hydrate. Corrupt rows (hand-edited or partially written) must lose the + * stage, never pass strings through into the DTO. + */ + +const validStage: CodexWsStageRecord = { + requestBytes: 512, + sent: true, + upstreamFrames: 2, + controlFrames: 1, + relayedEvents: 1, + firstFrameMs: 40, + elapsedMs: 900, + pings: 0, + pongs: 0, + closeCode: 1006, + reused: true, + ocxVersion: "2.52.0", + bunVersion: "1.4.0", +}; + +function entryWithStage(stage: unknown): PersistedUsageEntry { + return { + requestId: "req-ws-stage", + timestamp: 1, + provider: "openai", + model: "gpt-5.5", + status: 502, + durationMs: 1000, + usageStatus: "unreported", + attempts: [{ + ordinal: 1, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 502, + durationMs: 1000, + sendCount: 1, + recoveryKinds: [], + usageStatus: "unreported", + ...(stage !== undefined ? { codexWsStage: stage as CodexWsStageRecord } : {}), + }], + } as PersistedUsageEntry; +} + +describe("usage log persists the codex ws stage record (#4191)", () => { + test("a valid stage survives the normalize round trip", () => { + const roundTripped = normalizeUsageEntryForTest(entryWithStage(validStage)); + expect(roundTripped.attempts?.[0]?.codexWsStage).toEqual(validStage); + }); + + test("a success-shaped stage keeps requestBytes null", () => { + const stage = { ...validStage, requestBytes: null, closeCode: null }; + const roundTripped = normalizeUsageEntryForTest(entryWithStage(stage)); + expect(roundTripped.attempts?.[0]?.codexWsStage?.requestBytes).toBeNull(); + expect(roundTripped.attempts?.[0]?.codexWsStage?.closeCode).toBeNull(); + }); + + test("corrupt stage shapes are dropped, not passed through", () => { + const corrupt = { ...validStage, closeCode: "1006", upstreamFrames: "many" }; + const roundTripped = normalizeUsageEntryForTest(entryWithStage(corrupt)); + expect(roundTripped.attempts?.[0]?.codexWsStage).toBeUndefined(); + }); + + test("a stage carrying free-form strings is dropped whole", () => { + const poisoned = { ...validStage, reason: "upstream said things", ocxVersion: 252 }; + const roundTripped = normalizeUsageEntryForTest(entryWithStage(poisoned)); + expect(roundTripped.attempts?.[0]?.codexWsStage).toBeUndefined(); + expect(JSON.stringify(roundTripped)).not.toContain("upstream said things"); + }); +}); From 07beb73a45d7bb6dd6104b4eabe45c01b4fdd808 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 01:04:27 +0900 Subject: [PATCH 10/21] docs(structure): record the durable Codex WS stage record ownership (#4191) --- structure/transports/responses.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 6e975af2ac..3b20c80ff9 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -416,6 +416,20 @@ caller's abort signal, so a `connectTimeoutMs` shorter than 90 seconds cancels an already-sent create before the prelude timer fires. These are transport-fidelity guarantees, not a provider-billing guarantee. +Every exchange also leaves a content-free stage record +(`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — +the committed-success record keeps it null so the happy path never byte-counts +a megabyte replay frame), send completion, numeric close code, elapsed and +first-frame durations, frame counters, liveness ping/pong counts, pool reuse, +and the OCX/Bun versions. The exchange pins the record on the resolved Response +(`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); +`handleResponses` adopts it onto the serving attempt, and usage.jsonl +persists it per attempt behind a drop-guard normalizer, so hand-edited rows +cannot inject strings into the DTO. The record never carries conversation +text, headers, close-reason text, or account identifiers, and it is not a +fallback-eligibility signal: the no-replay-after-send contract stands +regardless of what it says. + Eligible complete-input creates can retain a canonical upstream socket within one selected account, credential, thread and turn. Model/tier and immutable handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are From 49dba44d3c8b2126b39da1aed77532b1bc51c020 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 09:47:51 +0900 Subject: [PATCH 11/21] fix(responses): refresh the WS stage record with final counters at terminal (#4191) --- src/server/responses/codex-ws-exchange.ts | 7 +++++++ tests/responses/ws-failure-stage.test.ts | 25 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 1de92a5409..8073aff9d6 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -448,6 +448,13 @@ export function codexWsExchange(options: ExchangeOptions): Promise { terminal = true; cleanup(); try { controller.close(); } catch { /* already closed */ } + // Refresh the success record with the final counters: the commit-time + // snapshot predates every relayed event, and the record is more useful + // when it says what the exchange actually delivered. Still no byte + // count — the happy path never pays it. + if (committedResponse) { + markCodexWsStage(committedResponse, stageRecord(null)); + } session.release(completedId); } }; diff --git a/tests/responses/ws-failure-stage.test.ts b/tests/responses/ws-failure-stage.test.ts index 25056a9bbe..f2951938af 100644 --- a/tests/responses/ws-failure-stage.test.ts +++ b/tests/responses/ws-failure-stage.test.ts @@ -304,6 +304,31 @@ describe("codex ws stage record marker (#4191)", () => { expect(readCodexWsStage(response)).toEqual(stage); }); + test("a committed exchange ends with the final counters on its stage record", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r1" } }) }); + }); + const noFallback = async () => { + throw new Error("fallback must not run after open"); + }; + const response = await codexWsUpstreamFetch( + CODEX_URL, + streamingInit(), + noFallback as unknown as typeof fetch, + BOUNDED_WS_RUNTIME, + ); + expect(response.status).toBe(200); + await response.text(); + const stage = readCodexWsStage(response); + expect(stage).toBeDefined(); + expect(stage?.requestBytes).toBeNull(); + expect(stage?.closeCode).toBeNull(); + expect(stage?.sent).toBe(true); + expect(stage?.relayedEvents).toBeGreaterThan(0); + }); + test("the serialized record is numeric/boolean/semver only", () => { const json = JSON.stringify(stage); expect(json).not.toContain("reason"); From 77dba07b3c8d8887028dd729178c7823e94d01d5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 01:15:25 +0900 Subject: [PATCH 12/21] docs(devlog): fold wp3 audit residual into L2 design --- .../020_l2_native_main_reauth_api.md | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md index 3bc7a1315f..bde091a235 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md @@ -31,6 +31,13 @@ MODIFY `src/oauth/chatgpt-device.ts` poll deadline and abort. Add a service-owned per-fetch deadline (fetch + body) so a stuck TCP cannot hold the flow until TTL. This is the Kuhn blocker "poll timer does not bound fetch/body deadlines". + Audit-folded: one FRESH 30s timeout per fetch attempt inside the poll + loop (AbortSignal.any([ctrl.signal, AbortSignal.timeout(30_000)]), the + main-account.ts:239-241 pattern) — a single 30s signal across the whole + poll would kill the 15-minute grant. Abort-timeout maps to + device_authorization_failed. The shared helper also bounds hung POOL + device logins at 30s per fetch — an intended improvement, called out in + the PR. MODIFY `src/codex/main-account.ts` - New `beginNativeMainReauth`: captures the existing @@ -43,6 +50,13 @@ MODIFY `src/codex/main-account.ts` together, advances the mutation epoch, and reconciles runtime/quota state. Old identity token is never retained beside new credentials. No claim held during human polling. + Audit-folded: do NOT reuse persistRefreshedMainAuthJson (:190-195) — it + spreads expected.tokens and never writes id_token, so the old identity + token would survive beside the new grant. The commit uses a SIBLING + persist that sets access_token/refresh_token/id_token/account_id + together and overwrites any prior id_token (adding the key is safe: + readMainAuthJsonCredential :122 tolerates it and + native-profile-store.ts:476-481 expects it). NEW `src/codex/main-device-reauth.ts` - One process-owned active flow (opaque UUID, AbortController, bounded @@ -68,6 +82,12 @@ MODIFY `src/cli/account-main.ts` management API; reject extra args before start. Register capability/help; regenerate skill surface with `bun run skill:surface` if the capability registry changes (tests/ci-workflows/skill-ocx.test.ts gates this). + Audit-folded: the native-main CLI branch point is account-main.ts (:181 + region, beside add/switch) with USAGE in src/cli/account.ts:64; the + management route-registry (src/server/management/route-registry.ts + MANAGEMENT_ROUTES) must gain the POST/GET/DELETE rows or + management-route-registry.test.ts and the capabilities ratchet go red — + do NOT grow UNDECLARED_ROUTES_2026_08_28. ## Hub fence resolution (open decision 1, resolved here for audit) @@ -103,8 +123,11 @@ contract: strict keys, 400/404/409 shapes, unauthorized rejected, `__main__` still refused by `/api/codex-auth/login`. MODIFY `tests/oauth/chatgpt-device-auth.test.ts` — native result retains idToken in-process; per-fetch deadline fires on a hung stub fetch. -MODIFY `tests/cli/cli-account.test.ts` — reauth --device surface, status, -cancel, arg rejection. +Audit-folded: native-main CLI tests land in +tests/cli/cli-native-profile.test.ts (native-main CLI); the pool +cli-account.test.ts keeps only the __main__ login rejection cases. +MODIFY `tests/cli/cli-native-profile.test.ts` — reauth --device surface, +status, cancel, arg rejection. All NEW files: layout.json explicit + expected-fixture entries. ## Docs / ownership From 2c5022c24ce4ebe5802ee8a8b98f4b5e039b6b16 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 01:37:07 +0900 Subject: [PATCH 13/21] feat(codex): native-main device reauth API for headless hubs (#3898) --- scripts/test-layout/layout.json | 4 +- src/codex/main-account.ts | 103 +++++++ src/codex/main-device-reauth-api.ts | 89 ++++++ src/codex/main-device-reauth.ts | 206 +++++++++++++ src/oauth/chatgpt-device.ts | 67 ++++- src/server/management-api.ts | 7 + src/server/management/route-registry.ts | 5 + .../main-device-reauth-api.test.ts | 146 +++++++++ .../main-device-reauth.test.ts | 278 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 4 +- tests/oauth/chatgpt-device-auth.test.ts | 67 ++++- 11 files changed, 968 insertions(+), 8 deletions(-) create mode 100644 src/codex/main-device-reauth-api.ts create mode 100644 src/codex/main-device-reauth.ts create mode 100644 tests/codex-integration/main-device-reauth-api.test.ts create mode 100644 tests/codex-integration/main-device-reauth.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index eb48d9e900..8ef047a4a1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1371,7 +1371,9 @@ "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", - "usage-log-ws-stage.test.ts": "usage" + "usage-log-ws-stage.test.ts": "usage", + "main-device-reauth.test.ts": "codex-integration", + "main-device-reauth-api.test.ts": "codex-integration" }, "migrated": [ "adapters", diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index a412046797..1dd9f70816 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -218,6 +218,109 @@ export function setMainAuthJsonBeforeRenameHookForTests(hook: (() => void) | nul beforeMainAuthJsonRenameForTests = hook; } +/** Complete token set a native device reauth commits into the main slot (#3898). */ +export interface NativeMainReauthTokens { + accessToken: string; + refreshToken: string; + idToken: string; + chatgptAccountId: string; +} + +export class NativeMainReauthUnavailableError extends Error { + constructor(message = "Native main credential cannot be reauthenticated in this state") { + super(message); + this.name = "NativeMainReauthUnavailableError"; + } +} + +export class NativeMainReauthIdentityMismatchError extends Error { + constructor() { + super("Device login completed for a different ChatGPT account than the native main identity"); + this.name = "NativeMainReauthIdentityMismatchError"; + } +} + +/** + * The reauth twin of persistRefreshedMainAuthJson (#3898). That function + * spreads expected.tokens and never writes id_token, which would keep the + * OLD identity token beside the new grant; this sibling sets all four + * credential fields together and overwrites any prior id_token. Everything + * else — allowed root metadata, the pre-rename snapshot guards, the + * mutation epoch — follows the refresh path exactly. + */ +function persistNativeMainReauthTokens( + expected: MainAuthJsonCredential, + tokens: NativeMainReauthTokens, +): void { + assertNotRealCodexHomeUnderTest(resolveCodexHomeDir()); + const nextTokens = { + ...expected.tokens, + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + id_token: tokens.idToken, + account_id: tokens.chatgptAccountId, + }; + atomicWriteFile( + expected.path, + JSON.stringify({ ...expected.root, tokens: nextTokens }, null, 2) + "\n", + undefined, + { + beforeRename: () => { + assertMainAuthJsonSnapshotUnchanged(expected); + const hook = beforeMainAuthJsonRenameForTests; + beforeMainAuthJsonRenameForTests = null; + hook?.(); + }, + validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), + }, + ); + advanceCodexCredentialMutationEpoch(); +} + +/** + * Prepare a same-identity reauth of the native __main__ slot (#3898). + * + * The existing credential snapshot is captured NOW and held only inside the + * closure — callers (the device-reauth service) never see the expected + * account id, so a flow cannot be steered toward a different identity. No + * claim is held while the human completes the device page. The returned + * commit, called once the device grant exists: + * + * 1. requires the SAME chatgpt account identity as the snapshot; + * 2. acquires the owner-independent exclusive claim (native-main-claim) — + * deliberately NOT assertNativeMainOwner, which a headless hub cannot + * satisfy; + * 3. re-verifies the snapshot (path + hash + dev/ino) inside the claim; + * 4. writes access/refresh/id token + account_id atomically and clears the + * main account's reauth quarantine for the new credential generation. + */ +export function beginNativeMainReauth(): { + commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }>; +} { + const expected = readMainAuthJsonCredential(); + if (!expected || !expected.chatgptAccountId) { + throw new NativeMainReauthUnavailableError( + "No native main credential exists to reauthenticate; enrollment is the native profile workflow", + ); + } + return { + async commit(tokens: NativeMainReauthTokens): Promise<{ chatgptAccountId: string }> { + if (!tokens.accessToken || !tokens.refreshToken || !tokens.idToken) { + throw new NativeMainReauthUnavailableError("Device grant did not produce a complete token set"); + } + if (tokens.chatgptAccountId !== expected.chatgptAccountId) { + throw new NativeMainReauthIdentityMismatchError(); + } + return withNativeMainExclusiveClaim(resolveNativeProfileContext(), async () => { + assertMainAuthJsonSnapshotUnchanged(expected); + persistNativeMainReauthTokens(expected, tokens); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + return { chatgptAccountId: tokens.chatgptAccountId }; + }); + }, + }; +} + async function resolveMainAccountToken( dependencies: NativeMainRefreshDependencies = {}, rejectedAccessToken?: string, diff --git a/src/codex/main-device-reauth-api.ts b/src/codex/main-device-reauth-api.ts new file mode 100644 index 0000000000..538a4ead58 --- /dev/null +++ b/src/codex/main-device-reauth-api.ts @@ -0,0 +1,89 @@ +import type { OcxConfig } from "../types"; +import { jsonResponse } from "../server/auth-cors"; +import { + cancelMainDeviceReauth, + getMainDeviceReauthStatus, + MainDeviceReauthFlowBusyError, + startMainDeviceReauth, +} from "./main-device-reauth"; +import { NativeMainReauthUnavailableError } from "./main-account"; + +/** + * Dedicated native-main device reauth route (#3898). + * + * `/api/codex-auth/login` stays pool-only and keeps rejecting __main__; + * this namespace is the only device-reauth surface for the native main slot. + * DTOs carry flowId/status/verificationUrl/deviceCode and safe failure codes + * — never tokens, emails, or raw account ids. The route is registered in + * management-api ahead of the generic /api/codex-auth/* dispatch, so the + * existing management origin/auth/session controls wrap it unchanged. + */ + +const ROUTE = "/api/codex-auth/main/reauth-device"; + +function errorResponse( + req: Request, + config: OcxConfig, + message: string, + code: string, + status: number, +): Response { + return jsonResponse({ error: message, code }, status, req, config); +} + +function flowIdFromQuery(url: URL): string | null { + for (const key of url.searchParams.keys()) { + if (key !== "flowId") return null; + } + const flowId = url.searchParams.get("flowId"); + return flowId && flowId.trim() ? flowId : null; +} + +export async function handleMainDeviceReauthAPI( + req: Request, + url: URL, + config: OcxConfig, +): Promise { + if (url.pathname !== ROUTE) return null; + + if (req.method === "POST") { + // Strict body: no request keys exist for start; anything supplied is an error. + const text = await req.text(); + if (text.trim()) { + return errorResponse(req, config, "The reauth-device start takes no request body", "invalid_request", 400); + } + try { + return jsonResponse(startMainDeviceReauth(), 200, req, config); + } catch (error) { + if (error instanceof MainDeviceReauthFlowBusyError) { + return errorResponse(req, config, error.message, error.code, 409); + } + if (error instanceof NativeMainReauthUnavailableError) { + return errorResponse(req, config, error.message, "native_main_unavailable", 503); + } + throw error; + } + } + + if (req.method === "GET") { + const flowId = flowIdFromQuery(url); + if (!flowId) { + return errorResponse(req, config, "An exact flowId query is required", "invalid_request", 400); + } + const status = getMainDeviceReauthStatus(flowId); + if (!status) return errorResponse(req, config, "Unknown or expired reauth flow", "unknown_flow", 404); + return jsonResponse(status, 200, req, config); + } + + if (req.method === "DELETE") { + const flowId = flowIdFromQuery(url); + if (!flowId) { + return errorResponse(req, config, "An exact flowId query is required", "invalid_request", 400); + } + const status = cancelMainDeviceReauth(flowId); + if (!status) return errorResponse(req, config, "Unknown or expired reauth flow", "unknown_flow", 404); + return jsonResponse(status, 200, req, config); + } + + return errorResponse(req, config, "Method not allowed", "method_not_allowed", 405); +} diff --git a/src/codex/main-device-reauth.ts b/src/codex/main-device-reauth.ts new file mode 100644 index 0000000000..2c3a56243a --- /dev/null +++ b/src/codex/main-device-reauth.ts @@ -0,0 +1,206 @@ +import { randomUUID } from "node:crypto"; +import { loginChatGPTNativeDevice, type NativeDeviceLogin } from "../oauth/chatgpt-device"; +import type { OAuthController } from "../oauth/types"; +import { + beginNativeMainReauth, + MainAuthJsonChangedDuringRefreshError, + NativeMainReauthIdentityMismatchError, + NativeMainReauthUnavailableError, + type NativeMainReauthTokens, +} from "./main-account"; + +/** + * Process-owned native-main device reauth flow (#3898). + * + * Exactly one active flow per process, started from the management API or the + * CLI. The human-facing DTO carries only flowId, status, the verification + * URL and the device code: tokens, emails, and raw account ids never leave + * the device/grant layer, and the opaque device_auth_id never leaves + * chatgpt-device.ts at all. The grant runs on this flow's own + * AbortController — deliberately NOT through startLoginFlow("chatgpt"), + * which would overwrite the pool scratch slot and collide with pool logins. + */ + +export type MainDeviceReauthStatus = + | { flowId: string; status: "pending"; verificationUrl: string; deviceCode: string } + | { flowId: string; status: "committing" } + | { flowId: string; status: "succeeded"; credentialUpdated: true } + | { flowId: string; status: "cancelled" } + | { + flowId: string; + status: "failed"; + credentialUpdated?: true; + code: + | "identity_mismatch" + | "credential_changed" + | "native_main_unavailable" + | "device_authorization_failed" + | "publication_failed" + | "reconciliation_failed"; + }; + +export class MainDeviceReauthFlowBusyError extends Error { + readonly code = "flow_in_progress"; + constructor() { + super("A native main device reauth is already in progress"); + this.name = "MainDeviceReauthFlowBusyError"; + } +} + +interface ActiveFlow { + flowId: string; + controller: AbortController; + status: MainDeviceReauthStatus; + /** Set once auth.json has been replaced; cancellation can no longer win. */ + published: boolean; + /** Snapshot-holding commit prepared at start; closure-private identity. */ + prepared: { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> }; +} + +/** Bounded terminal retention so status/cancel stay answerable after completion. */ +const TERMINAL_RETENTION_MS = 300_000; + +let activeFlow: ActiveFlow | null = null; +const terminalFlows = new Map(); + +export interface MainDeviceReauthDeps { + login?: (ctrl: OAuthController) => Promise; + beginCommit?: () => { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> }; + flowId?: () => string; + now?: () => number; +} + +function isTerminal(status: MainDeviceReauthStatus): boolean { + return status.status === "succeeded" || status.status === "cancelled" || status.status === "failed"; +} + +function sweepTerminal(now: number): void { + for (const [flowId, row] of terminalFlows) { + if (row.expiresAt <= now) terminalFlows.delete(flowId); + } +} + +function finish(flow: ActiveFlow, status: MainDeviceReauthStatus, now: number): void { + // Publication beats a racing cancellation: once auth.json was replaced the + // honest terminal is succeeded, never cancelled (080). Every other terminal + // is first-write-wins so a superseded or cancelled completion cannot + // publish a later result. + if (isTerminal(flow.status)) { + if (!(flow.published && status.status === "succeeded")) return; + } + if (status.status === "succeeded") flow.published = true; + flow.status = status; + terminalFlows.set(flow.flowId, { status, expiresAt: now + TERMINAL_RETENTION_MS }); +} + +function mapFailure(flowId: string, error: unknown): MainDeviceReauthStatus { + if (error instanceof NativeMainReauthIdentityMismatchError) { + return { flowId, status: "failed", code: "identity_mismatch" }; + } + if (error instanceof MainAuthJsonChangedDuringRefreshError) { + return { flowId, status: "failed", code: "credential_changed" }; + } + if (error instanceof NativeMainReauthUnavailableError) { + return { flowId, status: "failed", code: "native_main_unavailable" }; + } + const code = (error as { code?: unknown } | null)?.code; + if (code === "NATIVE_MAIN_CLAIM_UNAVAILABLE" || code === "NATIVE_MAIN_OWNER_UNAVAILABLE") { + return { flowId, status: "failed", code: "native_main_unavailable" }; + } + const name = (error as { name?: unknown } | null)?.name; + if (name === "TimeoutError" || name === "AbortError") { + return { flowId, status: "failed", code: "device_authorization_failed" }; + } + if (error instanceof Error && /device authorization/.test(error.message)) { + return { flowId, status: "failed", code: "device_authorization_failed" }; + } + return { flowId, status: "failed", code: "publication_failed" }; +} + +/** + * Start the one active flow. Returns the pending status; the URL/code arrive + * with the usercode response and are visible through the status endpoint. + */ +export function startMainDeviceReauth(deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus { + const now = (deps.now ?? Date.now)(); + sweepTerminal(now); + if (activeFlow && !isTerminal(activeFlow.status)) throw new MainDeviceReauthFlowBusyError(); + // Prepare NOW: the existing credential snapshot is captured at start (080), + // so a hub with no reauthenticatable main credential fails fast with + // native_main_unavailable instead of after the human completes the page. + const prepared = (deps.beginCommit ?? beginNativeMainReauth)(); + const flowId = (deps.flowId ?? randomUUID)(); + const flow: ActiveFlow = { + flowId, + controller: new AbortController(), + status: { flowId, status: "pending", verificationUrl: "", deviceCode: "" }, + published: false, + prepared, + }; + activeFlow = flow; + const login = deps.login ?? loginChatGPTNativeDevice; + const clock = deps.now ?? Date.now; + void (async () => { + try { + const grant = await login({ + signal: flow.controller.signal, + onAuth: info => { + // A superseded or cancelled flow may not publish its URL/code. + if (activeFlow !== flow || isTerminal(flow.status)) return; + flow.status = { + flowId, + status: "pending", + verificationUrl: info.url, + deviceCode: info.deviceCode ?? "", + }; + }, + }); + if (flow.controller.signal.aborted) return; + if (!isTerminal(flow.status)) flow.status = { flowId, status: "committing" }; + await flow.prepared.commit({ + accessToken: grant.credential.access, + refreshToken: grant.credential.refresh, + idToken: grant.idToken, + chatgptAccountId: grant.credential.accountId!, + }); + flow.published = true; + finish(flow, { flowId, status: "succeeded", credentialUpdated: true }, clock()); + } catch (error) { + if (flow.controller.signal.aborted && !flow.published) return; + finish(flow, mapFailure(flowId, error), clock()); + } + })(); + return flow.status; +} + +export function getMainDeviceReauthStatus(flowId: string, deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus | null { + const now = (deps.now ?? Date.now)(); + sweepTerminal(now); + if (activeFlow?.flowId === flowId) return activeFlow.status; + return terminalFlows.get(flowId)?.status ?? null; +} + +/** + * Cancel the flow. Cancellation after publication returns the published + * terminal (succeeded), never cancelled; a pending/committing flow aborts its + * grant and settles cancelled. + */ +export function cancelMainDeviceReauth(flowId: string, deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus | null { + const now = (deps.now ?? Date.now)(); + sweepTerminal(now); + if (activeFlow?.flowId === flowId && !isTerminal(activeFlow.status)) { + activeFlow.controller.abort(); + finish(activeFlow, { flowId, status: "cancelled" }, now); + return activeFlow.status; + } + return terminalFlows.get(flowId)?.status ?? null; +} + +/** Test hook: drop all in-memory flow state. Production never calls this. */ +export function resetMainDeviceReauthForTests(): void { + // Abort first: a reset that only clears the maps leaves a pending grant + // polling against real timers for up to the 15-minute device TTL. + activeFlow?.controller.abort(); + activeFlow = null; + terminalFlows.clear(); +} diff --git a/src/oauth/chatgpt-device.ts b/src/oauth/chatgpt-device.ts index 74fbeeabd9..fa4bdc9838 100644 --- a/src/oauth/chatgpt-device.ts +++ b/src/oauth/chatgpt-device.ts @@ -23,6 +23,19 @@ export const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device"; /** The grant's own lifetime. Polling past this only produces a worse error message. */ const DEVICE_FLOW_TTL_MS = 15 * 60 * 1000; +/** + * Per-fetch deadline for every device-flow HTTP call (#3898). Until this + * existed the only bounds were the 15-minute grant TTL and the caller's + * abort, so one stuck TCP connection could hold the login slot for the whole + * grant. A FRESH timeout per fetch attempt is required — a single timeout + * shared across the poll loop would kill the 15-minute grant. + */ +const DEVICE_FETCH_TIMEOUT_MS = 30_000; + +function deviceFetchSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(DEVICE_FETCH_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} const DEFAULT_POLL_INTERVAL_MS = 5_000; const MIN_POLL_INTERVAL_MS = 1_000; /** @@ -86,7 +99,7 @@ async function requestUserCode(signal?: AbortSignal): Promise { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }), - signal, + signal: deviceFetchSignal(signal), }); if (!response.ok) throw deviceError("request", response.status); const payload = (await response.json()) as Record; @@ -123,7 +136,7 @@ async function pollForGrant( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }), - signal, + signal: deviceFetchSignal(signal), }); if (response.status === 403 || response.status === 404) { // Cap the wait at the time actually left. Sleeping a full interval past @@ -149,7 +162,7 @@ async function pollForGrant( throw new Error("ChatGPT device authorization expired"); } -async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise { +async function exchangeGrantRaw(grant: DeviceGrant, signal?: AbortSignal): Promise> { const response = await fetch(CHATGPT_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -160,10 +173,54 @@ async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise< code_verifier: grant.codeVerifier, redirect_uri: DEVICE_REDIRECT_URI, }).toString(), - signal, + signal: deviceFetchSignal(signal), }); if (!response.ok) throw deviceError("token exchange", response.status); - return credsFromToken((await response.json()) as Record); + return (await response.json()) as Record; +} + +async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise { + return credsFromToken(await exchangeGrantRaw(grant, signal)); +} + +/** + * The native-main reauth result (#3898): the projected credential PLUS the + * id_token the pool projection deliberately drops. The id_token is the + * identity document the native auth.json requires + * (native-profile-store.ts), and it never leaves this process — it is + * written to the native main slot by the caller, never serialized into a + * DTO, log, or error. + */ +export interface NativeDeviceLogin { + credential: OAuthCredentials; + idToken: string; +} + +async function exchangeGrantNative(grant: DeviceGrant, signal?: AbortSignal): Promise { + const payload = await exchangeGrantRaw(grant, signal); + const credential = credsFromToken(payload); + const idToken = nonEmptyString(payload.id_token); + if (!idToken) throw new Error("ChatGPT device token response missing id_token"); + if (!credential.refresh) throw new Error("ChatGPT device token response missing refresh token"); + if (!credential.accountId) throw new Error("ChatGPT device token response missing account identity"); + return { credential, idToken }; +} + +/** + * Device flow for the native __main__ slot. Same grant as the pool flow, but + * nothing is persisted here and no OAuth store is touched: the caller + * (main-device-reauth service) owns the fenced commit into CODEX_HOME + * auth.json. + */ +export async function loginChatGPTNativeDevice(ctrl: OAuthController): Promise { + const device = await requestUserCode(ctrl.signal); + ctrl.onAuth?.({ + url: DEVICE_VERIFICATION_URL, + instructions: `Enter code: ${device.userCode}`, + deviceCode: device.userCode, + }); + const grant = await pollForGrant(device, ctrl.signal); + return exchangeGrantNative(grant, ctrl.signal); } /** diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 19b6aeec25..3eb0d775fa 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -388,6 +388,13 @@ export async function handleManagementAPI( } if (url.pathname.startsWith("/api/codex-auth/")) { + // Native-main device reauth (#3898): a dedicated namespace the generic + // codex-auth dispatch must not swallow (it would 404 as an unknown pool + // route). Same management origin/auth/session wrapping as every /api/*. + if (url.pathname === "/api/codex-auth/main/reauth-device") { + const { handleMainDeviceReauthAPI } = await import("../codex/main-device-reauth-api"); + return handleMainDeviceReauthAPI(req, url, config); + } const { handleCodexAuthAPI } = await import("../codex/auth-api"); const { ConfigMutationLockError } = await import("../config"); const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store"); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 9fb71e662c..105cdb2ba6 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -96,6 +96,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/accounts/clear-cooldown", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/accounts/refresh", module: "codex/auth-api", mutates: true }, + // codex/main-device-reauth-api (#3898): the native-main device reauth namespace; + // /api/codex-auth/login stays pool-only and keeps rejecting __main__. + { method: "POST", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: true }, + { method: "GET", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: false }, + { method: "DELETE", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/login", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/login/cancel", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/login/code", module: "codex/auth-api", mutates: true }, diff --git a/tests/codex-integration/main-device-reauth-api.test.ts b/tests/codex-integration/main-device-reauth-api.test.ts new file mode 100644 index 0000000000..64b6d35fba --- /dev/null +++ b/tests/codex-integration/main-device-reauth-api.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleMainDeviceReauthAPI } from "../../src/codex/main-device-reauth-api"; +import { resetMainDeviceReauthForTests } from "../../src/codex/main-device-reauth"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #3898 route contract: /api/codex-auth/main/reauth-device is the only + * device-reauth surface for the native main slot. Safe 400/404/405/409/503 + * shapes, strict request keys, and no token material in any payload. + */ + +const ROUTE = "http://localhost/api/codex-auth/main/reauth-device"; +const USERCODE = "https://auth.openai.com/api/accounts/deviceauth/usercode"; +const DEVICE_TOKEN = "https://auth.openai.com/api/accounts/deviceauth/token"; + +const realFetch = globalThis.fetch; +let home: string; +let previousCodexHome: string | undefined; + +const config = { port: 0 } as OcxConfig; + +function call(method: string, query = "", body?: string): Promise { + const url = new URL(ROUTE + query); + const req = body === undefined + ? new Request(url, { method }) + : new Request(url, { method, body, headers: { "content-type": "application/json" } }); + return handleMainDeviceReauthAPI(req, url, config); +} + +/** Device endpoints that stay pending forever, so the flow never leaves pending. */ +function stubPendingDevice(): void { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === USERCODE) { + return new Response(JSON.stringify({ + device_auth_id: "auth-id-opaque", + user_code: "ABCD-1234", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === DEVICE_TOKEN) { + return new Response("{}", { status: 403, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; +} + +beforeEach(() => { + resetMainDeviceReauthForTests(); + home = mkdtempSync(join(tmpdir(), "ocx-main-reauth-api-")); + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + resetMainDeviceReauthForTests(); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); +}); + +function writeMainCredential(): void { + writeFileSync(join(home, "auth.json"), JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "old-access", + refresh_token: "old-refresh", + account_id: "acct-main-1", + }, + })); +} + +describe("native main device reauth route (#3898)", () => { + test("other paths fall through", async () => { + const url = new URL("http://localhost/api/codex-auth/login"); + const handled = await handleMainDeviceReauthAPI(new Request(url, { method: "POST" }), url, config); + expect(handled).toBeNull(); + }); + + test("start without a native credential answers 503 native_main_unavailable", async () => { + const response = await call("POST"); + expect(response?.status).toBe(503); + const body = await response!.json() as { code: string }; + expect(body.code).toBe("native_main_unavailable"); + }); + + test("start rejects an unexpected body", async () => { + writeMainCredential(); + const response = await call("POST", "", JSON.stringify({ id: "__main__" })); + expect(response?.status).toBe(400); + }); + + test("status requires an exact flowId query", async () => { + expect((await call("GET"))?.status).toBe(400); + expect((await call("GET", "?flowId="))?.status).toBe(400); + expect((await call("GET", "?flowId=x&extra=1"))?.status).toBe(400); + }); + + test("unknown flows answer 404 for status and cancel", async () => { + expect((await call("GET", "?flowId=nope"))?.status).toBe(404); + expect((await call("DELETE", "?flowId=nope"))?.status).toBe(404); + }); + + test("unsupported methods answer 405", async () => { + expect((await call("PUT"))?.status).toBe(405); + }); + + test("start, poll and cancel round trip with a pending device grant", async () => { + writeMainCredential(); + stubPendingDevice(); + const started = await call("POST"); + expect(started?.status).toBe(200); + const pending = await started!.json() as { flowId: string; status: string }; + expect(pending.status).toBe("pending"); + // The URL/code arrive with the usercode response; give the microtask a turn. + await Bun.sleep(20); + const polled = await call("GET", `?flowId=${pending.flowId}`); + const polledBody = await polled!.json() as Record; + expect(polledBody.status).toBe("pending"); + expect(polledBody.deviceCode).toBe("ABCD-1234"); + expect(String(polledBody.verificationUrl)).toContain("codex/device"); + const cancelled = await call("DELETE", `?flowId=${pending.flowId}`); + expect(cancelled?.status).toBe(200); + expect(await cancelled!.json() as Record).toMatchObject({ status: "cancelled" }); + for (const payload of [pending, polledBody, await (await call("GET", `?flowId=${pending.flowId}`))!.json()]) { + const json = JSON.stringify(payload); + expect(json).not.toContain("old-access"); + expect(json).not.toContain("old-refresh"); + expect(json).not.toContain("acct-main-1"); + } + }); + + test("a second start while active answers 409 flow_in_progress", async () => { + writeMainCredential(); + stubPendingDevice(); + const first = await call("POST"); + expect(first?.status).toBe(200); + const second = await call("POST"); + expect(second?.status).toBe(409); + expect((await second!.json() as { code: string }).code).toBe("flow_in_progress"); + }); +}); diff --git a/tests/codex-integration/main-device-reauth.test.ts b/tests/codex-integration/main-device-reauth.test.ts new file mode 100644 index 0000000000..e2f18b8e07 --- /dev/null +++ b/tests/codex-integration/main-device-reauth.test.ts @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + cancelMainDeviceReauth, + getMainDeviceReauthStatus, + MainDeviceReauthFlowBusyError, + resetMainDeviceReauthForTests, + startMainDeviceReauth, + type MainDeviceReauthStatus, +} from "../../src/codex/main-device-reauth"; +import { + beginNativeMainReauth, + MainAuthJsonChangedDuringRefreshError, + NativeMainReauthIdentityMismatchError, + NativeMainReauthUnavailableError, + setMainAuthJsonBeforeRenameHookForTests, +} from "../../src/codex/main-account"; +import type { NativeDeviceLogin } from "../../src/oauth/chatgpt-device"; +import type { OAuthController } from "../../src/oauth/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #3898: the headless-hub native-main device reauth. One process-owned flow, + * same-identity fenced commit, and a DTO that can never carry tokens. + */ + +function grant(accountId = "acct-main-1"): NativeDeviceLogin { + return { + credential: { + access: "new-access-token", + refresh: "new-refresh-token", + expires: Date.now() + 3600_000, + accountId, + } as NativeDeviceLogin["credential"], + idToken: "new-id-token", + }; +} + +function loginStub( + behavior: (ctrl: OAuthController) => Promise, +): (ctrl: OAuthController) => Promise { + return behavior; +} + +async function waitForTerminal(flowId: string, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const status = getMainDeviceReauthStatus(flowId); + if (status && status.status !== "pending" && status.status !== "committing") return status; + if (Date.now() > deadline) throw new Error(`flow ${flowId} never settled: ${JSON.stringify(status)}`); + await Bun.sleep(5); + } +} + +beforeEach(() => resetMainDeviceReauthForTests()); +afterEach(() => resetMainDeviceReauthForTests()); + +describe("native main device reauth flow (#3898)", () => { + test("start publishes the verification URL and human code, then succeeds", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async ctrl => { + ctrl.onAuth?.({ url: "https://auth.openai.com/codex/device", deviceCode: "ABCD-1234" }); + return grant(); + }), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + expect(started.status).toBe("pending"); + const pending = getMainDeviceReauthStatus(started.flowId); + expect(pending).toMatchObject({ status: "pending", deviceCode: "ABCD-1234" }); + expect((pending as { verificationUrl?: string }).verificationUrl).toContain("codex/device"); + const terminal = await waitForTerminal(started.flowId); + expect(terminal).toMatchObject({ status: "succeeded", credentialUpdated: true }); + }); + + test("a second start while active is refused", () => { + let release!: (value: NativeDeviceLogin) => void; + const gate = new Promise(resolve => { release = resolve; }); + startMainDeviceReauth({ + login: loginStub(() => gate), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + expect(() => startMainDeviceReauth({ + login: loginStub(async () => grant()), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + })).toThrow(MainDeviceReauthFlowBusyError); + release(grant()); + }); + + test("identity mismatch fails without touching the credential", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async () => grant("acct-OTHER")), + beginCommit: () => ({ + commit: async () => { throw new NativeMainReauthIdentityMismatchError(); }, + }), + }); + const terminal = await waitForTerminal(started.flowId); + expect(terminal).toMatchObject({ status: "failed", code: "identity_mismatch" }); + expect((terminal as { credentialUpdated?: boolean }).credentialUpdated).toBeUndefined(); + }); + + test("a cancelled flow cannot publish a late grant", async () => { + let release!: (value: NativeDeviceLogin) => void; + const gate = new Promise(resolve => { release = resolve; }); + let commitCalled = false; + const started = startMainDeviceReauth({ + login: loginStub(() => gate), + beginCommit: () => ({ commit: async () => { commitCalled = true; return { chatgptAccountId: "acct-main-1" }; } }), + }); + const cancelled = cancelMainDeviceReauth(started.flowId); + expect(cancelled).toMatchObject({ status: "cancelled" }); + release(grant()); + await Bun.sleep(20); + expect(getMainDeviceReauthStatus(started.flowId)).toMatchObject({ status: "cancelled" }); + expect(commitCalled).toBe(false); + }); + + test("cancellation after publication returns succeeded, never cancelled", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async ctrl => { + ctrl.onAuth?.({ url: "https://auth.openai.com/codex/device", deviceCode: "WXYZ-9999" }); + return grant(); + }), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + await waitForTerminal(started.flowId); + expect(cancelMainDeviceReauth(started.flowId)).toMatchObject({ status: "succeeded" }); + }); + + test("claim-unavailable maps to native_main_unavailable", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async () => grant()), + beginCommit: () => ({ + commit: async () => { + const error = new Error("claim held elsewhere") as Error & { code: string }; + error.code = "NATIVE_MAIN_CLAIM_UNAVAILABLE"; + throw error; + }, + }), + }); + expect(await waitForTerminal(started.flowId)).toMatchObject({ + status: "failed", + code: "native_main_unavailable", + }); + }); + + test("device authorization failures map to device_authorization_failed", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async () => { throw new Error("ChatGPT device authorization poll failed: HTTP 500"); }), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + expect(await waitForTerminal(started.flowId)).toMatchObject({ + status: "failed", + code: "device_authorization_failed", + }); + }); + + test("no DTO ever carries token material", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async ctrl => { + ctrl.onAuth?.({ url: "https://auth.openai.com/codex/device", deviceCode: "ABCD-1234" }); + return grant(); + }), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + const terminal = await waitForTerminal(started.flowId); + for (const dto of [started, getMainDeviceReauthStatus(started.flowId), terminal]) { + const json = JSON.stringify(dto); + expect(json).not.toContain("new-access-token"); + expect(json).not.toContain("new-refresh-token"); + expect(json).not.toContain("new-id-token"); + expect(json).not.toContain("acct-main-1"); + } + }); +}); + +describe("beginNativeMainReauth commit (#3898)", () => { + let home: string; + let previousCodexHome: string | undefined; + let authPath: string; + + const original = { + auth_mode: "chatgpt", + tokens: { + access_token: "old-access", + refresh_token: "old-refresh", + id_token: "old-id-token", + account_id: "acct-main-1", + future_token_field: "preserve-token", + }, + future_root_field: { preserve: true }, + }; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-main-reauth-")); + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + authPath = join(home, "auth.json"); + writeFileSync(authPath, JSON.stringify(original)); + }); + + afterEach(() => { + setMainAuthJsonBeforeRenameHookForTests(null); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + }); + + function readTokens(): Record { + return (JSON.parse(readFileSync(authPath, "utf8")) as { tokens: Record }).tokens; + } + + test("same-identity commit writes all four token fields and preserves metadata", async () => { + const prepared = beginNativeMainReauth(); + const result = await prepared.commit({ + accessToken: "new-access", + refreshToken: "new-refresh", + idToken: "new-id-token", + chatgptAccountId: "acct-main-1", + }); + expect(result.chatgptAccountId).toBe("acct-main-1"); + const tokens = readTokens(); + expect(tokens.access_token).toBe("new-access"); + expect(tokens.refresh_token).toBe("new-refresh"); + expect(tokens.id_token).toBe("new-id-token"); + expect(tokens.account_id).toBe("acct-main-1"); + expect(tokens.future_token_field).toBe("preserve-token"); + expect(JSON.parse(readFileSync(authPath, "utf8")).future_root_field).toEqual({ preserve: true }); + }); + + test("a different account identity is refused and the file is untouched", async () => { + const before = readFileSync(authPath, "utf8"); + const prepared = beginNativeMainReauth(); + await expect(prepared.commit({ + accessToken: "new-access", + refreshToken: "new-refresh", + idToken: "new-id-token", + chatgptAccountId: "acct-someone-else", + })).rejects.toThrow(NativeMainReauthIdentityMismatchError); + expect(readFileSync(authPath, "utf8")).toBe(before); + }); + + test("an incomplete token set is refused before any claim work", async () => { + const before = readFileSync(authPath, "utf8"); + const prepared = beginNativeMainReauth(); + await expect(prepared.commit({ + accessToken: "new-access", + refreshToken: "", + idToken: "new-id-token", + chatgptAccountId: "acct-main-1", + })).rejects.toThrow(NativeMainReauthUnavailableError); + expect(readFileSync(authPath, "utf8")).toBe(before); + }); + + test("a concurrent writer during publish fails the commit closed", async () => { + const before = readFileSync(authPath, "utf8"); + setMainAuthJsonBeforeRenameHookForTests(() => { + writeFileSync(authPath, JSON.stringify({ tokens: { refresh_token: "foreign-writer" } })); + }); + const prepared = beginNativeMainReauth(); + await expect(prepared.commit({ + accessToken: "new-access", + refreshToken: "new-refresh", + idToken: "new-id-token", + chatgptAccountId: "acct-main-1", + })).rejects.toThrow(MainAuthJsonChangedDuringRefreshError); + expect(readFileSync(authPath, "utf8")).not.toBe(before); + expect(readTokens().refresh_token).toBe("foreign-writer"); + }); + + test("preparation without an existing credential fails fast", () => { + removeTreeWithRetry(home); + home = mkdtempSync(join(tmpdir(), "ocx-main-reauth-empty-")); + process.env.CODEX_HOME = home; + expect(() => beginNativeMainReauth()).toThrow(NativeMainReauthUnavailableError); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ed114b8a07..efb1afca69 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1203,5 +1203,7 @@ "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", - "usage-log-ws-stage.test.ts": "usage" + "usage-log-ws-stage.test.ts": "usage", + "main-device-reauth.test.ts": "codex-integration", + "main-device-reauth-api.test.ts": "codex-integration" } diff --git a/tests/oauth/chatgpt-device-auth.test.ts b/tests/oauth/chatgpt-device-auth.test.ts index 533f4da015..7753f9b6c1 100644 --- a/tests/oauth/chatgpt-device-auth.test.ts +++ b/tests/oauth/chatgpt-device-auth.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { loginChatGPT } from "../../src/oauth/chatgpt"; -import { loginChatGPTDevice } from "../../src/oauth/chatgpt-device"; +import { loginChatGPTDevice, loginChatGPTNativeDevice } from "../../src/oauth/chatgpt-device"; import type { OAuthController } from "../../src/oauth/types"; /** @@ -243,4 +243,69 @@ describe("ChatGPT device auth", () => { expect(calls.urls[0]).toBe(USERCODE); expect(creds.accountId).toBe("acct_device_123"); }); + + test("loginChatGPTNativeDevice retains the id_token the pool projection drops (#3898)", async () => { + routeFetch(); + const result = await loginChatGPTNativeDevice({}); + expect(result.credential.accountId).toBe("acct_device_123"); + expect(result.credential.refresh).toBe("refresh-value"); + expect(result.idToken).toBe(idToken()); + }); + + test("loginChatGPTNativeDevice refuses a grant without id_token", async () => { + routeFetch({ tokenBody: { access_token: "access-value", refresh_token: "refresh-value" } }); + await expect(loginChatGPTNativeDevice({})).rejects.toThrow(/missing id_token/); + }); + + test("loginChatGPTNativeDevice refuses a grant without account identity", async () => { + routeFetch({ tokenBody: { access_token: "access-value", refresh_token: "refresh-value", id_token: "header.e30.sig" } }); + await expect(loginChatGPTNativeDevice({})).rejects.toThrow(/missing account identity/); + }); + + test("every device fetch carries a fresh bounded signal (#3898)", async () => { + const signals: (AbortSignal | null | undefined)[] = []; + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + urls.push(url); + signals.push(init?.signal); + if (url === USERCODE) { + return jsonResponse({ device_auth_id: "auth-id-opaque", user_code: "ABCD-EFGH" }); + } + if (url === DEVICE_TOKEN) { + return jsonResponse({ authorization_code: "auth-code", code_verifier: "server-verifier" }); + } + if (url === OAUTH_TOKEN) { + return jsonResponse({ access_token: "a", refresh_token: "r", id_token: idToken() }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + await loginChatGPTNativeDevice({}); + expect(urls).toEqual([USERCODE, DEVICE_TOKEN, OAUTH_TOKEN]); + for (const signal of signals) { + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + } + }); + + test("the pool device login also carries the bounded per-fetch signal", async () => { + const signals: (AbortSignal | null | undefined)[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + signals.push(init?.signal); + if (url === USERCODE) { + return jsonResponse({ device_auth_id: "auth-id-opaque", user_code: "ABCD-EFGH" }); + } + if (url === DEVICE_TOKEN) { + return jsonResponse({ authorization_code: "auth-code", code_verifier: "server-verifier" }); + } + if (url === OAUTH_TOKEN) { + return jsonResponse({ access_token: "a", refresh_token: "r", id_token: idToken() }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + await loginChatGPTDevice({}); + expect(signals.length).toBe(3); + for (const signal of signals) expect(signal).toBeInstanceOf(AbortSignal); + }); }); From 647e52f30760fea81dd6735d046ef612dc47efde Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 01:37:07 +0900 Subject: [PATCH 14/21] feat(cli): ocx account main reauth --device with registry, docs, and structure sync (#3898) --- .../fr/reference/cli/providers-accounts.md | 3 + .../ja/reference/cli/providers-accounts.md | 3 + .../ko/reference/cli/providers-accounts.md | 3 + .../docs/reference/cli/providers-accounts.md | 5 ++ .../ru/reference/cli/providers-accounts.md | 3 + .../tr/reference/cli/providers-accounts.md | 3 + .../zh-cn/reference/cli/providers-accounts.md | 3 + .../zh-tw/reference/cli/providers-accounts.md | 3 + .../ocx/references/01_management_surface.md | 27 ++++++- src/cli/account-main.ts | 80 +++++++++++++++++++ src/cli/account.ts | 2 +- src/cli/capabilities.ts | 22 +++++ structure/codex-home.md | 13 +++ tests/cli/cli-native-profile.test.ts | 42 ++++++++++ 14 files changed, 209 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index b28642e0ca..b531e1b307 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -315,6 +315,9 @@ ocx account main doctor [--json] ocx account main list [--json] ocx account main register